[
  {
    "path": ".github/CODEOWNERS",
    "content": "# CODEOWNERS for OpenSandbox\n# Rules are evaluated top-to-bottom; the last matching pattern wins.\n\n# Default owners (fallback for files not matched by specific rules)\n* @jwx0925 @hittyt @hellomypastor @Pangjiping @ninan-nn\n\n# Control plane (server)\n/server/ @Pangjiping @hittyt @jwx0925 @Generalwin @ninan-nn\n\n# Runtime agent (execd) and sandbox images\n/components/execd/ @Pangjiping @hittyt @ninan-nn\n/components/ingress/ @Pangjiping @hittyt @Generalwin @Spground\n/components/egress/ @Pangjiping @hittyt @jwx0925\n/sandboxes/ @Pangjiping @ninan-nn @jwx0925 @hittyt @hellomypastor\n\n# Kubernetes controller\n/kubernetes/ @Spground @Generalwin @fengcone @kevinlynx @ninan-nn @hittyt @Pangjiping\n\n# SDKs\n/sdks/ @ninan-nn @jwx0925 @hittyt @hellomypastor\n\n# Specs and docs\n/specs/ @jwx0925 @hittyt @ninan-nn\n\n# OpenSandbox Enhancement Proposals\n/oseps/ @Spground @Generalwin @fengcone @kevinlynx @Pangjiping @ninan-nn @jwx0925 @hittyt\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.md",
    "content": "---\nname: Feature Request\nabout: Suggest an idea for OpenSandbox\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n## Why do you need it?\nIs your feature request related to a problem? Please describe in details\n\n\n## How could it be?\nA clear and concise description of what you want to happen. You can explain more about input of the feature, and output of it.\n\n\n## Other related information\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Any Questions or Suggestions?\n    url: https://github.com/alibaba/OpenSandbox/issues\n    about: Please ask and answer questions here.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "# Summary\n- What is changing and why?\n\n# Testing\n- [ ] Not run (explain why)\n- [ ] Unit tests\n- [ ] Integration tests\n- [ ] e2e / manual verification\n\n# Breaking Changes\n- [ ] None\n- [ ] Yes (describe impact and migration path)\n\n# Checklist\n- [ ] Linked Issue or clearly described motivation\n- [ ] Added/updated docs (if needed)\n- [ ] Added/updated tests (if needed)\n- [ ] Security impact considered\n- [ ] Backward compatibility considered\n"
  },
  {
    "path": ".github/workflows/deploy-docs-pages.yml",
    "content": "name: Deploy Docs Pages\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"docs/**\"\n      - \"specs/**\"\n      - \"scripts/spec-doc/**\"\n      - \"README.md\"\n      - \"CONTRIBUTING.md\"\n      - \"CODE_OF_CONDUCT.md\"\n      - \"server/**/README*.md\"\n      - \"server/**/DEVELOPMENT.md\"\n      - \"components/**/README*.md\"\n      - \"components/**/DEVELOPMENT.md\"\n      - \"sdks/**/README*.md\"\n      - \"sandboxes/**/README*.md\"\n      - \"kubernetes/**/README*.md\"\n      - \"examples/**/README*.md\"\n      - \"specs/**/README*.md\"\n      - \"oseps/**/*.md\"\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Setup Pages\n        id: pages\n        uses: actions/configure-pages@v5\n\n      - name: Setup Node 22\n        uses: actions/setup-node@v6\n        with:\n          node-version: \"22\"\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9.15.0\n\n      - name: Enable corepack\n        run: corepack enable\n\n      - name: Install docs dependencies\n        working-directory: docs\n        run: pnpm install --frozen-lockfile\n\n      - name: Build docs\n        working-directory: docs\n        env:\n          # Use root base when custom domain is configured via CNAME.\n          DOCS_BASE: ${{ hashFiles('docs/public/CNAME') != '' && '/' || steps.pages.outputs.base_path }}\n        run: pnpm docs:build\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: docs/.vitepress/dist\n\n  deploy:\n    runs-on: ubuntu-latest\n    needs: build\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/egress-test.yaml.yml",
    "content": "name: Egress Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'components/egress/**'\n      - 'components/internal/**'\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.24.0'\n\n      - name: Run Build\n        working-directory: components/egress\n        run: |\n          go vet ./...\n          go build .\n\n      - name: Run tests\n        working-directory: components/egress\n        run: |\n          go test ./...\n\n  smoke:\n    runs-on: self-hosted\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Run dns test\n        working-directory: components/egress\n        run: |\n          chmod +x tests/smoke-dns.sh\n          ./tests/smoke-dns.sh\n\n      - name: Run nft test\n        working-directory: components/egress\n        run: |\n          chmod +x tests/smoke-nft.sh\n          ./tests/smoke-nft.sh\n\n      - name: Run dynamic ip test\n        working-directory: components/egress\n        run: |\n          chmod +x tests/smoke-dynamic-ip.sh\n          ./tests/smoke-dynamic-ip.sh\n\n  bench:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Run bench test\n        working-directory: components/egress\n        run: |\n          chmod +x tests/bench-dns-nft.sh\n          ./tests/bench-dns-nft.sh\n        env:\n          BENCH_SAMPLE_SIZE: \"20\"\n\n      - name: Upload egress logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: egress-log-for-bench\n          path: /tmp/egress-logs/\n          retention-days: 5\n"
  },
  {
    "path": ".github/workflows/execd-test.yml",
    "content": "name: Execd Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'components/execd/**'\n      - 'components/internal/**'\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.24.0'\n\n      - name: Run golint\n        run: |\n          cd components/execd\n          make golint\n\n      - name: Build (Multi platform compile)\n        run: |\n          cd components/execd\n          #\n          make multi-build\n\n      - name: Run tests with coverage\n        run: |\n          cd components/execd\n          go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/...\n\n      - name: Calculate coverage and generate summary\n        id: coverage\n        run: |\n          cd components/execd\n          # Extract total coverage percentage\n          TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')\n          echo \"total_coverage=$TOTAL_COVERAGE\" >> $GITHUB_OUTPUT\n          \n          # Generate GitHub Actions job summary\n          echo \"## 📊 execd Test Coverage Report\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Total Line Coverage:** $TOTAL_COVERAGE\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"Coverage report generated for commit \\`${{ github.sha }}\\`\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"---\" >> $GITHUB_STEP_SUMMARY\n          echo \"*Coverage targets: Core packages >80%, API layer >70%*\" >> $GITHUB_STEP_SUMMARY\n\n  smoke:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.24.0'\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install make (Windows)\n        if: matrix.os == 'windows-latest'\n        shell: powershell\n        run: choco install make -y\n\n      - name: Build\n        run: |\n          cd components/execd\n          make build\n\n      - name: Run smoke test\n        run: |\n          cd components/execd\n          chmod +x tests/smoke.sh\n          ./tests/smoke.sh\n\n          sleep 5\n          python3 tests/smoke_api.py\n      - name: Show logs\n        if: always()\n        run: |\n          set -x\n          cat components/execd/startup.log || true\n          cat components/execd/execd.log || true\n"
  },
  {
    "path": ".github/workflows/ingress-test.yaml",
    "content": "name: Ingress Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'components/ingress/**'\n      - 'components/internal/**'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    permissions:\n      contents: read\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.24.0'\n\n      - name: Run golint\n        working-directory: components/ingress\n        run: |\n          make golint\n\n      - name: Run Build\n        working-directory: components/ingress\n        run: |\n          make build\n\n      - name: Run tests\n        working-directory: components/ingress\n        run: |\n          make test\n"
  },
  {
    "path": ".github/workflows/publish-components.yml",
    "content": "name: Publish Components Image\n\npermissions:\n  # required for bump step to push branch and create PR\n  contents: write\n  pull-requests: write\n\non:\n  workflow_dispatch:\n    inputs:\n      component:\n        description: 'Component to build'\n        required: true\n        type: choice\n        options:\n          - execd\n          - code-interpreter\n          - ingress\n          - egress\n          - controller\n          - task-executor\n        default: 'execd'\n      image_tag:\n        description: 'Docker image tag'\n        required: true\n        default: 'latest'\n  push:\n    tags:\n      - 'docker/execd/**'\n      - 'docker/code-interpreter/**'\n      - 'docker/ingress/**'\n      - 'docker/egress/**'\n      - 'k8s/controller/**'\n      - 'k8s/task-executor/**'\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Login to ACR\n        uses: docker/login-action@v3\n        with:\n          registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com\n          username: ${{ secrets.ACR_USERNAME }}\n          password: ${{ secrets.ACR_PASSWORD }}\n\n      - name: Parse tag and set variables\n        id: parse_tag\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/docker/* ]]; then\n            TAG_PATH=\"${{ github.ref }}\"\n            TAG_PATH=\"${TAG_PATH#refs/tags/}\"\n\n            COMPONENT=$(echo \"$TAG_PATH\" | cut -d'/' -f2)\n            IMAGE_TAG=$(echo \"$TAG_PATH\" | cut -d'/' -f3)\n\n            echo \"component=$COMPONENT\" >> $GITHUB_OUTPUT\n            echo \"image_tag=$IMAGE_TAG\" >> $GITHUB_OUTPUT\n          elif [[ \"${{ github.ref }}\" == refs/tags/k8s/* ]]; then\n            TAG_PATH=\"${{ github.ref }}\"\n            TAG_PATH=\"${TAG_PATH#refs/tags/}\"\n\n            COMPONENT=$(echo \"$TAG_PATH\" | cut -d'/' -f2)\n            IMAGE_TAG=$(echo \"$TAG_PATH\" | cut -d'/' -f3)\n\n            echo \"component=$COMPONENT\" >> $GITHUB_OUTPUT\n            echo \"image_tag=$IMAGE_TAG\" >> $GITHUB_OUTPUT\n          else\n            echo \"component=${{ inputs.component }}\" >> $GITHUB_OUTPUT\n            echo \"image_tag=${{ inputs.image_tag }}\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Free disk space\n        run: |\n          sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache\n          sudo apt-get clean\n          sudo rm -rf /var/lib/apt/lists/*\n          df -h\n\n      - name: Build and push to registries\n        run: |\n          COMPONENT=\"${{ steps.parse_tag.outputs.component }}\"\n          IMAGE_TAG=\"${{ steps.parse_tag.outputs.image_tag }}\"\n\n          if [ \"$COMPONENT\" == \"execd\" ]; then\n            cd components/execd\n          elif [ \"$COMPONENT\" == \"ingress\" ]; then\n            cd components/ingress\n          elif [ \"$COMPONENT\" == \"egress\" ]; then\n            cd components/egress\n          elif [ \"$COMPONENT\" == \"controller\" ]; then\n            cd kubernetes\n          elif [ \"$COMPONENT\" == \"task-executor\" ]; then\n            cd kubernetes\n          else\n            cd sandboxes/$COMPONENT\n          fi\n\n          export TAG=$IMAGE_TAG\n          export COMPONENT=$COMPONENT\n          chmod +x build.sh\n          ./build.sh\n\n      - name: Bump component version in repo\n        if: steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          COMPONENT=\"${{ steps.parse_tag.outputs.component }}\"\n          IMAGE_TAG=\"${{ steps.parse_tag.outputs.image_tag }}\"\n          # Ensure version has 'v' prefix for bump script\n          if [[ \"$IMAGE_TAG\" =~ ^v ]]; then\n            VERSION=\"$IMAGE_TAG\"\n          else\n            VERSION=\"v${IMAGE_TAG}\"\n          fi\n\n          ./scripts/bump-component-version.sh \"$COMPONENT\" \"$VERSION\"\n\n          BRANCH=\"bump/${COMPONENT}-${VERSION}\"\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git checkout -b \"$BRANCH\"\n          git add -A\n          git diff --staged --quiet && echo \"No changes to commit\" && exit 0\n          git commit -m \"chore: bump $COMPONENT to $VERSION\"\n          git push origin \"$BRANCH\"\n\n          gh pr create \\\n            --title \"chore: bump $COMPONENT to $VERSION\" \\\n            --body \"Auto-generated by Publish Components workflow after building \\`$COMPONENT:$VERSION\\`.\" \\\n            --base \"$(gh api repos/${{ github.repository }} --jq .default_branch)\"\n"
  },
  {
    "path": ".github/workflows/publish-csharp-sdks.yml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nname: Publish C# SDKs\n\non:\n  push:\n    tags:\n      - \"csharp/sandbox/v*\"\n      - \"csharp/code-interpreter/v*\"\n\npermissions:\n  contents: read\n\njobs:\n  publish:\n    name: Publish (${{ matrix.sdk.name }})\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        sdk:\n          - name: sandbox\n            tagPrefix: sandbox\n            csprojPath: sdks/sandbox/csharp/src/OpenSandbox/OpenSandbox.csproj\n          - name: code-interpreter\n            tagPrefix: code-interpreter\n            csprojPath: sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/OpenSandbox.CodeInterpreter.csproj\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up .NET\n        uses: actions/setup-dotnet@v5\n        with:\n          dotnet-version: \"10.0.x\"\n\n      - name: Parse package version from tag\n        if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))\n        shell: bash\n        run: |\n          VERSION=\"${GITHUB_REF_NAME#csharp/${{ matrix.sdk.tagPrefix }}/v}\"\n          echo \"PACKAGE_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n\n      - name: Restore\n        if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))\n        run: |\n          EXTRA_RESTORE_ARGS=\"\"\n          if [ \"${{ matrix.sdk.name }}\" = \"code-interpreter\" ]; then\n            EXTRA_RESTORE_ARGS=\"-p:UseLocalOpenSandboxProjectReference=false\"\n          fi\n          dotnet restore \"${{ matrix.sdk.csprojPath }}\" ${EXTRA_RESTORE_ARGS}\n\n      - name: Pack\n        if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))\n        run: |\n          EXTRA_PACK_ARGS=\"\"\n          if [ \"${{ matrix.sdk.name }}\" = \"code-interpreter\" ]; then\n            EXTRA_PACK_ARGS=\"-p:UseLocalOpenSandboxProjectReference=false\"\n          fi\n          dotnet pack \"${{ matrix.sdk.csprojPath }}\" \\\n            --configuration Release \\\n            --no-restore \\\n            -p:PackageVersion=\"${PACKAGE_VERSION}\" \\\n            -p:ContinuousIntegrationBuild=true \\\n            ${EXTRA_PACK_ARGS} \\\n            --output ./artifacts/${{ matrix.sdk.name }}\n\n      - name: Publish to NuGet\n        if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))\n        env:\n          NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}\n        run: |\n          dotnet nuget push \"./artifacts/${{ matrix.sdk.name }}/*.nupkg\" \\\n            --api-key \"$NUGET_API_KEY\" \\\n            --source \"https://api.nuget.org/v3/index.json\" \\\n            --skip-duplicate\n"
  },
  {
    "path": ".github/workflows/publish-helm-chart.yml",
    "content": "name: Publish Helm Chart\n\non:\n  workflow_dispatch:\n    inputs:\n      component:\n        description: 'Component to release'\n        required: true\n        type: choice\n        options:\n          - opensandbox-controller\n          - opensandbox-server\n          - opensandbox\n        default: 'opensandbox-controller'\n      app_version:\n        description: 'App version (without v prefix, e.g., 0.1.0)'\n        required: true\n        default: '0.1.0'\n  push:\n    tags:\n      - 'helm/**'  # Format: helm/<component>/<app_version>, e.g., helm/opensandbox-controller/0.1.0\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Configure Git\n        run: |\n          git config user.name \"$GITHUB_ACTOR\"\n          git config user.email \"$GITHUB_ACTOR@users.noreply.github.com\"\n\n      - name: Install Helm\n        uses: azure/setup-helm@v4\n        with:\n          version: 'latest'\n\n      - name: Parse tag and set variables\n        id: parse_tag\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/helm/* ]]; then\n            TAG_PATH=\"${{ github.ref }}\"\n            TAG_PATH=\"${TAG_PATH#refs/tags/}\"\n            \n            COMPONENT=$(echo \"$TAG_PATH\" | cut -d'/' -f2)\n            VERSION=$(echo \"$TAG_PATH\" | cut -d'/' -f3)\n            \n            # Remove 'v' prefix if present\n            VERSION=${VERSION#v}\n            \n            echo \"component=$COMPONENT\" >> $GITHUB_OUTPUT\n            echo \"app_version=$VERSION\" >> $GITHUB_OUTPUT\n          else\n            echo \"component=${{ inputs.component }}\" >> $GITHUB_OUTPUT\n            echo \"app_version=${{ inputs.app_version }}\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Set chart path\n        id: chart_path\n        run: |\n          COMPONENT=\"${{ steps.parse_tag.outputs.component }}\"\n          \n          if [ \"$COMPONENT\" == \"opensandbox-controller\" ]; then\n            CHART_PATH=\"kubernetes/charts/opensandbox-controller\"\n          elif [ \"$COMPONENT\" == \"opensandbox-server\" ]; then\n            CHART_PATH=\"kubernetes/charts/opensandbox-server\"\n          elif [ \"$COMPONENT\" == \"opensandbox\" ]; then\n            CHART_PATH=\"kubernetes/charts/opensandbox\"\n          else\n            echo \"Error: Unknown component: $COMPONENT\"\n            exit 1\n          fi\n          \n          echo \"path=$CHART_PATH\" >> $GITHUB_OUTPUT\n\n      - name: Get chart version from Chart.yaml\n        id: chart_version\n        run: |\n          CHART_PATH=\"${{ steps.chart_path.outputs.path }}\"\n          CHART_VERSION=$(grep '^version:' $CHART_PATH/Chart.yaml | awk '{print $2}')\n          echo \"version=$CHART_VERSION\" >> $GITHUB_OUTPUT\n          echo \"Chart version: $CHART_VERSION\"\n\n      - name: Update Chart.yaml with app version\n        run: |\n          APP_VERSION=\"${{ steps.parse_tag.outputs.app_version }}\"\n          CHART_PATH=\"${{ steps.chart_path.outputs.path }}\"\n          \n          # Only update appVersion, keep chart version as-is in Chart.yaml\n          sed -i \"s/^appVersion:.*/appVersion: \\\"$APP_VERSION\\\"/\" $CHART_PATH/Chart.yaml\n          \n          echo \"Updated Chart.yaml:\"\n          cat $CHART_PATH/Chart.yaml\n\n      - name: Build dependencies (for opensandbox all-in-one chart)\n        if: ${{ steps.parse_tag.outputs.component == 'opensandbox' }}\n        run: |\n          CHART_PATH=\"${{ steps.chart_path.outputs.path }}\"\n          echo \"Building dependencies for all-in-one chart...\"\n          helm dependency build $CHART_PATH\n\n      - name: Lint Helm chart\n        run: |\n          CHART_PATH=\"${{ steps.chart_path.outputs.path }}\"\n          helm lint $CHART_PATH\n\n      - name: Package Helm chart\n        run: |\n          CHART_PATH=\"${{ steps.chart_path.outputs.path }}\"\n          helm package $CHART_PATH\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }}\n          name: Helm Chart ${{ steps.parse_tag.outputs.component }} ${{ steps.chart_version.outputs.version }} (App v${{ steps.parse_tag.outputs.app_version }})\n          body: |\n            ## ${{ steps.parse_tag.outputs.component }} Helm Chart\n            \n            **Chart Version:** ${{ steps.chart_version.outputs.version }}\n            **App Version:** ${{ steps.parse_tag.outputs.app_version }}\n            \n            ### Installation\n            \n            直接从 GitHub Release 安装:\n            \n            ```bash\n            helm install ${{ steps.parse_tag.outputs.component }} \\\n              https://github.com/${{ github.repository }}/releases/download/helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \\\n              --namespace opensandbox-system \\\n              --create-namespace\n            ```\n            \n            或者先下载后安装:\n            \n            ```bash\n            # 下载\n            wget https://github.com/${{ github.repository }}/releases/download/helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz\n            \n            # 安装\n            helm install ${{ steps.parse_tag.outputs.component }} ./${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \\\n              --namespace opensandbox-system \\\n              --create-namespace\n            ```\n            \n            ${{ 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.' || '' }}\n            \n            ### What's Changed\n            \n            - Chart version: ${{ steps.chart_version.outputs.version }}\n            - App version: ${{ steps.parse_tag.outputs.app_version }}\n          files: |\n            ${{ steps.parse_tag.outputs.component }}-*.tgz\n          draft: false\n          prerelease: false\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/publish-java-sdks.yml",
    "content": "name: Publish Java SDKs\n\non:\n  push:\n    tags:\n      - \"java/sandbox/v*\"\n      - \"java/code-interpreter/v*\"\n\npermissions:\n  contents: read\n\njobs:\n  publish:\n    name: Publish (${{ matrix.sdk.name }})\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        sdk:\n          - name: sandbox\n            tagPrefix: sandbox\n            workingDirectory: sdks/sandbox/kotlin\n          - name: code-interpreter\n            tagPrefix: code-interpreter\n            workingDirectory: sdks/code-interpreter/kotlin\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: temurin\n          java-version: \"17\"\n\n      - name: Set up Gradle\n        uses: gradle/actions/setup-gradle@v5\n\n      - name: Publish to Maven Central\n        working-directory: ${{ matrix.sdk.workingDirectory }}\n        if: startsWith(github.ref, format('refs/tags/java/{0}/v', matrix.sdk.tagPrefix))\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }}\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEYPASSWORD }}\n        run: |\n          ./gradlew publishAndReleaseToMavenCentral\n"
  },
  {
    "path": ".github/workflows/publish-js-sdks.yml",
    "content": "name: Publish JavaScript SDKs\n\non:\n  push:\n    tags:\n      - \"js/sandbox/v*\"\n      - \"js/code-interpreter/v*\"\n\npermissions:\n  contents: read\n\njobs:\n  publish:\n    name: Publish (${{ matrix.sdk.name }})\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        sdk:\n          - name: sandbox\n            tagPrefix: sandbox\n            workingDirectory: sdks/sandbox/javascript\n            packageName: \"@alibaba-group/opensandbox\"\n          - name: code-interpreter\n            tagPrefix: code-interpreter\n            workingDirectory: sdks/code-interpreter/javascript\n            packageName: \"@alibaba-group/opensandbox-code-interpreter\"\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: \"20\"\n          registry-url: \"https://registry.npmjs.org\"\n\n      - name: Set up pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: latest\n\n      - name: Enable corepack\n        run: corepack enable\n\n      - name: Get pnpm store path\n        id: pnpm-store\n        run: echo \"STORE_PATH=$(corepack pnpm store path)\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Cache pnpm store\n        uses: actions/cache@v5\n        with:\n          path: ${{ steps.pnpm-store.outputs.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-${{ hashFiles('sdks/pnpm-lock.yaml') }}\n          restore-keys: ${{ runner.os }}-pnpm-\n\n      - name: Install workspace dependencies\n        working-directory: sdks\n        run: corepack pnpm install --frozen-lockfile\n\n      - name: Build SDK\n        working-directory: sdks\n        run: corepack pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build\n\n      - name: Publish to npm\n        if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix))\n        working-directory: ${{ matrix.sdk.workingDirectory }}\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          corepack pnpm publish --access public --no-git-checks\n"
  },
  {
    "path": ".github/workflows/publish-python-sdks.yml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nname: Publish Python SDKs\n\npermissions:\n  contents: read\n\non:\n  push:\n    tags:\n      - \"python/sandbox/v*\"\n      - \"python/code-interpreter/v*\"\n      - \"python/mcp/sandbox/v*\"\n\njobs:\n  publish-sandbox:\n    if: startsWith(github.ref, 'refs/tags/python/sandbox/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Generate API\n        working-directory: sdks/sandbox/python\n        run: |\n          uv run python scripts/generate_api.py\n\n      - name: Build package\n        working-directory: sdks/sandbox/python\n        run: |\n          uv build\n\n      - name: Publish to PyPI\n        working-directory: sdks/sandbox/python\n        env:\n          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}\n        run: |\n          uv publish\n\n  publish-code-interpreter:\n    if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Build package\n        working-directory: sdks/code-interpreter/python\n        run: |\n          uv build\n\n      - name: Publish to PyPI\n        working-directory: sdks/code-interpreter/python\n        env:\n          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}\n        run: |\n          uv publish\n\n  publish-mcp-sandbox:\n    if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.10\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Build package\n        working-directory: sdks/mcp/sandbox/python\n        run: |\n          uv build\n\n      - name: Publish to PyPI\n        working-directory: sdks/mcp/sandbox/python\n        env:\n          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}\n        run: |\n          uv publish\n"
  },
  {
    "path": ".github/workflows/publish-server.yml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nname: Publish Server\n\non:\n  push:\n    tags:\n      - 'server/v*'\n\npermissions:\n  contents: read\n\njobs:\n  publish-pypi:\n    if: startsWith(github.ref, 'refs/tags/server/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Build package\n        working-directory: server\n        run: |\n          uv build\n\n      - name: Publish to PyPI\n        working-directory: server\n        env:\n          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}\n        run: |\n          uv publish\n\n  publish-image:\n    if: startsWith(github.ref, 'refs/tags/server/v')\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_PASSWORD }}\n\n      - name: Login to ACR\n        uses: docker/login-action@v3\n        with:\n          registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com\n          username: ${{ secrets.ACR_USERNAME }}\n          password: ${{ secrets.ACR_PASSWORD }}\n\n      - name: Parse tag and set variables\n        id: parse_tag\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/server/* ]]; then\n            TAG_PATH=\"${{ github.ref }}\"\n            TAG_PATH=\"${TAG_PATH#refs/tags/}\"\n\n            IMAGE_TAG=\"${TAG_PATH#server/}\"\n\n            if [ -z \"$IMAGE_TAG\" ]; then\n              echo \"failed to parse image tag from $TAG_PATH\" >&2\n              exit 1\n            fi\n\n            echo \"image_tag=$IMAGE_TAG\" >> $GITHUB_OUTPUT\n          else\n            echo \"cannot parse tag\"\n            exit 1\n          fi\n\n      - name: Build and push to registries\n        working-directory: server\n        env:\n          TAG: ${{ steps.parse_tag.outputs.image_tag }}\n        run: |\n          chmod +x build.sh\n          ./build.sh\n"
  },
  {
    "path": ".github/workflows/real-e2e.yml",
    "content": "name: Real E2E Tests\n\npermissions:\n  contents: read\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'server/src/**'\n      - 'components/execd/**'\n      - 'components/egress/**'\n      - 'sdks/code-interpreter/**'\n      - 'sdks/sandbox/**'\n      - 'tests/**'\n  push:\n    branches: [ main ]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  python-e2e:\n    name: Python E2E (docker bridge)\n    runs-on: self-hosted\n    env:\n      UV_BIN: /home/admin/.local/bin\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up uv PATH and verify\n        run: |\n          echo \"${UV_BIN}\" >> \"$GITHUB_PATH\"\n          export PATH=\"${UV_BIN}:${PATH}\"\n          uv --version\n          uv run python --version\n\n      - name: Clean up previous E2E resources\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          # Remove root-owned files from previous sandbox runs by mounting parent dir\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n\n      - name: Build local egress image\n        run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .\n\n      - name: Run tests\n        run: |\n          set -e\n\n          # Create config file\n          cat <<EOF > ~/.sandbox.toml\n          [server]\n          host = \"127.0.0.1\"\n          port = 8080\n          log_level = \"INFO\"\n          api_key = \"\"\n          [runtime]\n          type = \"docker\"\n          execd_image = \"opensandbox/execd:local\"\n          [egress]\n          image = \"opensandbox/egress:local\"\n          mode = \"dns\"\n          [docker]\n          network_mode = \"bridge\"\n          [storage]\n          allowed_host_paths = [\"/tmp/opensandbox-e2e\"]\n          EOF\n\n          ./scripts/python-e2e.sh\n\n      - name: Eval server logs\n        if: ${{ always() }}\n        run: cat server/server.log\n\n      - name: Upload execd logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: execd-log-for-python-e2e\n          path: /tmp/opensandbox-e2e/logs/\n          retention-days: 5\n\n      - name: Clean up after E2E\n        if: always()\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n          pkill -f \"python -m src.main\" || true\n\n  java-e2e:\n    name: Java E2E (docker bridge)\n    runs-on: self-hosted\n    env:\n      UV_BIN: /home/admin/.local/bin\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up uv PATH and verify\n        run: |\n          echo \"${UV_BIN}\" >> \"$GITHUB_PATH\"\n          export PATH=\"${UV_BIN}:${PATH}\"\n          uv --version\n          uv run python --version\n\n      - name: Set up JDK 8\n        uses: actions/setup-java@v5\n        with:\n          distribution: temurin\n          java-version: \"8\"\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v5\n        with:\n          distribution: temurin\n          java-version: \"17\"\n\n      - name: Clean up previous E2E resources\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n\n      - name: Build local egress image\n        run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .\n\n      - name: Run tests\n        env:\n          GRADLE_USER_HOME: ${{ github.workspace }}/.gradle-user-home\n        run: |\n          set -e\n          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}\"\n\n          # Create config file\n          cat <<EOF > ~/.sandbox.toml\n          [server]\n          host = \"127.0.0.1\"\n          port = 8080\n          log_level = \"INFO\"\n          api_key = \"\"\n          [runtime]\n          type = \"docker\"\n          execd_image = \"opensandbox/execd:local\"\n          [egress]\n          image = \"opensandbox/egress:local\"\n          mode = \"dns+nft\"\n          [docker]\n          network_mode = \"bridge\"\n          [storage]\n          allowed_host_paths = [\"/tmp/opensandbox-e2e\"]\n          EOF\n\n          bash ./scripts/java-e2e.sh\n\n      - name: Eval server logs\n        if: ${{ always() }}\n        run: cat server/server.log\n\n      - name: Upload Test Report\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: java-test-report\n          path: tests/java/build/reports/tests/test/\n          retention-days: 5\n\n      - name: Upload execd logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: execd-log-for-java-e2e\n          path: /tmp/opensandbox-e2e/logs/\n          retention-days: 5\n\n      - name: Clean up after E2E\n        if: always()\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n          pkill -f \"python -m src.main\" || true\n\n  javascript-e2e:\n    name: JavaScript E2E (docker bridge)\n    runs-on: self-hosted\n    env:\n      UV_BIN: /home/admin/.local/bin\n      NODE_VERSION: \"20.19.0\"\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up uv PATH and verify\n        run: |\n          echo \"${UV_BIN}\" >> \"$GITHUB_PATH\"\n          export PATH=\"${UV_BIN}:${PATH}\"\n          uv --version\n          uv run python --version\n\n      - name: Set up Node.js\n        run: |\n          NODE_DIR=\"/home/admin/.local/node-v${NODE_VERSION}-linux-x64\"\n          if [ -x \"${NODE_DIR}/bin/node\" ]; then\n            echo \"Node.js ${NODE_VERSION} already cached\"\n          else\n            echo \"Downloading Node.js ${NODE_VERSION}...\"\n            mkdir -p /home/admin/.local\n            curl -fsSL \"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz\" \\\n              | tar -xJ -C /home/admin/.local/\n          fi\n          echo \"${NODE_DIR}/bin\" >> \"$GITHUB_PATH\"\n          export PATH=\"${NODE_DIR}/bin:${PATH}\"\n          node --version\n          npm --version\n\n      - name: Clean up previous E2E resources\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n\n      - name: Build local egress image\n        run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .\n\n      - name: Run tests\n        run: |\n          set -e\n\n          # Create config file (match other E2E jobs)\n          cat <<EOF > ~/.sandbox.toml\n          [server]\n          host = \"127.0.0.1\"\n          port = 8080\n          log_level = \"INFO\"\n          api_key = \"\"\n          [runtime]\n          type = \"docker\"\n          execd_image = \"opensandbox/execd:local\"\n          [egress]\n          image = \"opensandbox/egress:local\"\n          [docker]\n          network_mode = \"bridge\"\n          [storage]\n          allowed_host_paths = [\"/tmp/opensandbox-e2e\"]\n          EOF\n\n          bash ./scripts/javascript-e2e.sh\n\n      - name: Eval server logs\n        if: ${{ always() }}\n        run: cat server/server.log\n\n      - name: Upload Test Report\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: javascript-test-report\n          path: tests/javascript/build/test-results/junit.xml\n          retention-days: 5\n\n      - name: Upload execd logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: execd-log-for-js-e2e\n          path: /tmp/opensandbox-e2e/logs/\n          retention-days: 5\n\n      - name: Clean up after E2E\n        if: always()\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n          pkill -f \"python -m src.main\" || true\n\n  csharp-e2e:\n    name: C# E2E (docker bridge)\n    runs-on: self-hosted\n    env:\n      UV_BIN: /home/admin/.local/bin\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up uv PATH and verify\n        run: |\n          echo \"${UV_BIN}\" >> \"$GITHUB_PATH\"\n          export PATH=\"${UV_BIN}:${PATH}\"\n          uv --version\n          uv run python --version\n\n      - name: Set up .NET SDK\n        uses: actions/setup-dotnet@v5\n        env:\n          DOTNET_INSTALL_DIR: /home/admin/.local/dotnet\n        with:\n          dotnet-version: \"10.0.x\"\n\n      - name: Clean up previous E2E resources\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n\n      - name: Build local egress image\n        run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .\n\n      - name: Run tests\n        run: |\n          set -e\n\n          cat <<EOF > ~/.sandbox.toml\n          [server]\n          host = \"127.0.0.1\"\n          port = 8080\n          log_level = \"INFO\"\n          api_key = \"\"\n          [runtime]\n          type = \"docker\"\n          execd_image = \"opensandbox/execd:local\"\n          [egress]\n          image = \"opensandbox/egress:local\"\n          [docker]\n          network_mode = \"bridge\"\n          [storage]\n          allowed_host_paths = [\"/tmp/opensandbox-e2e\"]\n          EOF\n\n          bash ./scripts/csharp-e2e.sh\n\n      - name: Eval server logs\n        if: ${{ always() }}\n        run: cat server/server.log\n\n      - name: Upload Test Report\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: csharp-test-report\n          path: tests/csharp/build/test-results/\n          retention-days: 5\n\n      - name: Upload execd logs\n        if: always()\n        uses: actions/upload-artifact@v7\n        with:\n          name: execd-log-for-csharp-e2e\n          path: /tmp/opensandbox-e2e/logs/\n          retention-days: 5\n\n      - name: Clean up after E2E\n        if: always()\n        run: |\n          docker ps -aq --filter \"label=opensandbox\" | xargs -r docker rm -f || true\n          docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true\n          pkill -f \"python -m src.main\" || true\n"
  },
  {
    "path": ".github/workflows/sandbox-k8s-e2e.yml",
    "content": "name: Sandbox K8S E2E Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'kubernetes/**'\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\nenv:\n  GO_VERSION: '1.24'\n\njobs:\n  e2e-k8s:\n    name: E2E Tests (K8s v${{ matrix.k8s-version }})\n    strategy:\n      fail-fast: false\n      matrix:\n        k8s-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\"]\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: ${{ env.GO_VERSION }}\n\n      - name: Run tests\n        run: |\n          cd kubernetes\n          make test-e2e KIND_K8S_VERSION=v${{ matrix.k8s-version }}"
  },
  {
    "path": ".github/workflows/sandbox-k8s-test.yml",
    "content": "name: Sandbox K8S Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'kubernetes/**'\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: '1.24.0'\n\n      - name: Run golint\n        run: |\n          cd kubernetes\n          make lint\n\n      - name: Build binary\n        run: |\n          cd kubernetes\n          make build\n          make task-executor-build\n\n      - name: Run tests\n        run: |\n          cd kubernetes\n          make test\n"
  },
  {
    "path": ".github/workflows/sdk-tests.yml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nname: SDK Tests\n\non:\n  pull_request:\n    branches: [main]\n    paths:\n      - \"sdks/sandbox/**\"\n      - \"sdks/code-interpreter/**\"\n      - \"specs/**\"\n  push:\n    branches: [main]\n    paths:\n      - \"sdks/sandbox/**\"\n      - \"sdks/code-interpreter/**\"\n      - \"specs/**\"\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  python-sdk-quality:\n    name: Python SDK Quality (${{ matrix.package_name }})\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - package_name: sandbox\n            package_dir: sdks/sandbox/python\n          - package_name: code-interpreter\n            package_dir: sdks/code-interpreter/python\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.11\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Install dependencies\n        working-directory: ${{ matrix.package_dir }}\n        run: |\n          uv sync\n\n      - name: Generate API\n        if: matrix.package_name == 'sandbox'\n        working-directory: sdks/sandbox/python\n        run: |\n          uv run python scripts/generate_api.py\n\n      - name: Run ruff\n        working-directory: ${{ matrix.package_dir }}\n        run: |\n          uv run ruff check\n\n      - name: Run pyright\n        working-directory: ${{ matrix.package_dir }}\n        run: |\n          uv run pyright\n\n  python-sdk:\n    name: Python SDK Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.11\"\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          version: \"latest\"\n\n      - name: Generate API\n        working-directory: sdks/sandbox/python\n        run: |\n          uv sync\n          uv run python scripts/generate_api.py\n\n      - name: Run tests\n        working-directory: sdks/sandbox/python\n        run: |\n          uv run pytest tests/ -v\n\n  kotlin-sdk:\n    name: Kotlin SDK Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: temurin\n          java-version: \"17\"\n\n      - name: Set up Gradle\n        uses: gradle/actions/setup-gradle@v5\n\n      - name: Run tests\n        working-directory: sdks/sandbox/kotlin\n        run: |\n          ./gradlew :sandbox:test\n\n  csharp-sdk:\n    name: C# SDK Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up .NET 10\n        uses: actions/setup-dotnet@v5\n        with:\n          dotnet-version: \"10.0.x\"\n\n      - name: Run sandbox tests\n        working-directory: sdks/sandbox/csharp\n        run: |\n          dotnet test tests/OpenSandbox.Tests/OpenSandbox.Tests.csproj --configuration Release\n\n      - name: Run code interpreter tests\n        working-directory: sdks/code-interpreter/csharp\n        run: |\n          dotnet test tests/OpenSandbox.CodeInterpreter.Tests/OpenSandbox.CodeInterpreter.Tests.csproj --configuration Release\n"
  },
  {
    "path": ".github/workflows/server-test.yml",
    "content": "name: Server Tests\n\non:\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'server/src/**'\n      - 'server/tests/**'\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        run: |\n          pip install uv\n\n      - name: Run tests\n        run: |\n          cd server\n          uv sync --all-groups\n          uv run ruff check\n          uv run pytest\n\n  docker-smoke:\n    strategy:\n      matrix:\n        network: [host, bridge]\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Install uv\n        run: |\n          pip install uv\n\n      - name: Set up Docker\n        run: |\n          docker --version\n\n      - name: Run smoke test\n        run: |\n          set -e\n          cd server\n          uv sync --all-groups\n\n          # Create config file\n          cat <<EOF > ~/.sandbox.toml\n          [server]\n          host = \"127.0.0.1\"\n          port = 32888\n          log_level = \"INFO\"\n          api_key = \"\"\n          [runtime]\n          type = \"docker\"\n          execd_image = \"opensandbox/execd:latest\"\n          [egress]\n          image = \"opensandbox/egress:latest\"\n          [docker]\n          network_mode = \"${{ matrix.network }}\"\n          [storage]\n          allowed_host_paths = [\"/tmp/opensandbox-e2e\"]\n          EOF\n\n          # Start server in background\n          uv run python -m src.main > app.log 2>&1 &\n\n          # Wait for server to start\n          sleep 10\n\n          # Run smoke test\n          chmod +x tests/smoke.sh\n          ./tests/smoke.sh\n      - name: Show logs\n        if: always()\n        run: |\n          cat server/app.log\n"
  },
  {
    "path": ".github/workflows/verify-license.yml",
    "content": "name: Verify License Headers\n\non:\n  pull_request:\n    branches: [ main ]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  verify-license:\n    runs-on: self-hosted\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Run license verification\n        run: |\n          chmod +x scripts/verify-license.sh\n          ./scripts/verify-license.sh\n"
  },
  {
    "path": ".gitignore",
    "content": "# IDE and Editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n.DS_Store\nThumbs.db\n\n# Go\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool\n*.out\n\n# Dependency directories\nvendor/\n\n# Go workspace file\ngo.work\n\n# Java/Kotlin\n# Compiled class file\n*.class\n\n# Log file\n*.log\n\n# BlueJ files\n*.ctxt\n\n# Mobile Tools for Java (J2ME)\n.mtj.tmp/\n\n# Package Files\n*.jar\n*.war\n*.nar\n*.ear\n*.zip\n*.tar.gz\n*.rar\n\n# virtual machine crash logs\nhs_err_pid*\nreplay_pid*\n\n# Gradle\n.gradle/\nbuild/\n!**/gradle/wrapper/gradle-wrapper.jar\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n# Maven\ntarget/\npom.xml.tag\npom.xml.releaseBackup\npom.xml.versionsBackup\npom.xml.next\nrelease.properties\ndependency-reduced-pom.xml\nbuildNumber.properties\n.mvn/timing.properties\n.mvn/wrapper/maven-wrapper.jar\n\n# Node.js\n# Logs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# Yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\npublic\n!docs/public/\n!docs/public/CNAME\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# Virtual environments\nvenv/\nenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Docker\n*.pid\n*.seed\n*.pid.lock\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Temporary files\n*.tmp\n*.temp\n*~\n\n# Environment variables\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# API keys and secrets\nsecrets/\n*.pem\n*.key\n*.crt\n*.p12\n*.pfx\n\n# Generated API documentation\ndocs/generated/\ndocs/.vitepress/generated/\ndocs/.vitepress/dist/\ndocs/.vitepress/cache/\napidocs/\n\n# Test results\ntest-results/\ncoverage/\n*.coverage\n.nyc_output\n\n# Backup files\n*.bak\n*.backup\n*.old\n\n# Flattened POM files (Maven)\n.flattened-pom.xml\n\n# Kotlin\n*.kotlin_module\n\n# JetBrains specific\n.idea/\n*.iml\n*.ipr\n*.iws\nout/\n\n# Eclipse specific\n.project\n.classpath\n.settings/\nbin/\n\n# NetBeans specific\nnbproject/\nnbbuild/\nnbdist/\n.nb-gradle/\n\n# Generated files\ngenerated/\n**/generated/**\n\n# gVisor runtime binaries (downloaded dynamically)\nkubernetes/test/kind/gvisor/runsc\nkubernetes/test/kind/gvisor/containerd-shim-runsc-v1\nbin/\nobj/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# Minimal cross-language pre-commit hooks\n# Install: pip install pre-commit && pre-commit install\n# Run once on all files: pre-commit run --all-files\n\nrepos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.6.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: mixed-line-ending\n      - id: check-merge-conflict\n      - id: check-yaml\n      - id: detect-private-key\n\n  # Language-specific formatters/linters can be added later, for example:\n  # - repo: local\n  #   hooks:\n  #     - id: gofmt\n  #       name: gofmt\n  #       entry: gofmt\n  #       language: system\n  #       types: [go]\n  #     - id: ruff\n  #       name: ruff\n  #       entry: ruff check\n  #       language: system\n  #       types: [python]\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- `server/`: Python FastAPI service, configs, and tests.\n- `components/execd/`: Go execution daemon and related tests.\n- `sdks/`: Multi-language SDKs (`sdks/sandbox/*`, `sdks/code-interpreter/*`).\n- `sandboxes/`: Runtime sandbox implementations (e.g., `sandboxes/code-interpreter/`).\n- `specs/`: OpenAPI specs (`specs/execd-api.yaml`, `specs/sandbox-lifecycle.yml`).\n- `examples/`: End-to-end usage examples and integrations.\n- `tests/`: Cross-component/E2E tests (`tests/python/`, `tests/java/`).\n- `docs/`, `oseps/`, `scripts/`: Docs, proposals, and automation scripts.\n\n## Build, Test, and Development Commands\n- Server (Python):\n  - `cd server && uv sync` installs deps.\n  - `cp server/example.config.toml ~/.sandbox.toml` sets local config.\n  - `cd server && uv run python -m src.main` runs the API server.\n- execd (Go):\n  - `cd components/execd && go build -o bin/execd .` builds the daemon.\n  - `cd components/execd && make fmt` formats Go sources.\n- SDKs:\n  - Python: `cd sdks/sandbox/python && uv sync && uv run pytest`.\n  - Kotlin: `cd sdks/sandbox/kotlin && ./gradlew build`.\n- Specs: `node scripts/spec-doc/generate-spec.js` regenerates spec docs.\n\n## Coding Style & Naming Conventions\n- Python: PEP 8, `ruff` for lint/format, type hints on public APIs.\n- Go: `gofmt`, explicit error handling, standard import grouping.\n- Kotlin: Kotlin Coding Conventions, `ktlint` where configured.\n- Naming: classes `PascalCase`, functions `snake_case` (Python) / `camelCase` (Go/Kotlin), constants `UPPER_SNAKE_CASE`.\n\n## SDK API Implementation Conventions\n- Keep a clear split between generated API transport code and handwritten SDK business/adaptor code.\n- In adapter/infrastructure layers, default to integrating through generated API clients instead of handcrafted request wiring.\n- Prefer generated OpenAPI clients for standard request/response endpoints; use handwritten transport only for streaming or protocol-specific paths (for example SSE).\n- Do not manually edit generated client files. When specs change, regenerate first, then adapt handwritten layers.\n- For handwritten streaming paths, keep wire contracts aligned with OpenAPI field names/models and cover behavior with focused tests (especially parsing and error mapping).\n\n## Testing Guidelines\n- Python tests use `pytest` (async tests common).\n- Go tests use `go test` under `components/execd/pkg/...`.\n- Kotlin tests use Gradle (`./gradlew test`).\n- Coverage targets (from CONTRIBUTING): core packages >80%, API layer >70%.\n\n## Commit & Pull Request Guidelines\n- Commit messages follow Conventional Commits, e.g. `feat(server): add runtime`.\n- Use feature branches (e.g., `feature/...`, `fix/...`) and keep PRs focused.\n- PRs should include summary, testing status, and linked issues; follow the template in `CONTRIBUTING.md`.\n- For major API or architectural changes, submit an OSEP (`oseps/`).\n\n## Security & Configuration Tips\n- Local server config lives in `~/.sandbox.toml` (copied from `server/example.config.toml`).\n- Docker is required for local sandbox execution; keep images and keys out of commits.\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\nWe are committed to a welcoming, safe, and respectful community.\n\n## Expected Behavior\n- Be respectful and inclusive.\n- Assume good intent; seek to understand.\n- Provide constructive feedback; critique code, not people.\n- Follow project guidelines and security practices.\n\n## Unacceptable Behavior\n- Harassment, personal attacks, or discriminatory language.\n- Publishing private information without consent.\n- Disruptive or aggressive behavior in any project space.\n\n## Scope\nThis Code applies to all project spaces, including issues, pull requests, discussions, chat, and events.\n\n## Reporting\nReport incidents to: **conduct@opensandbox.io**. Include as much detail as possible (what happened, when/where, links, screenshots if applicable).\n\n## Enforcement\nMaintainers will investigate in good faith and may take appropriate action, including warnings, temporary bans, or removal from the community.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to OpenSandbox\n\nThank 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.\n\n## Table of Contents\n\n- [Code of Conduct](#code-of-conduct)\n- [Getting Started](#getting-started)\n- [Development Environment Setup](#development-environment-setup)\n- [Project Structure](#project-structure)\n- [Development Workflow](#development-workflow)\n- [Coding Standards](#coding-standards)\n- [Testing Guidelines](#testing-guidelines)\n- [Submitting Contributions](#submitting-contributions)\n- [Communication Channels](#communication-channels)\n\n## Code of Conduct\n\nOpenSandbox 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.\n\n## Getting Started\n\n### Ways to Contribute\n\nThere are many ways to contribute to OpenSandbox:\n\n- **Report Bugs**: Submit detailed bug reports through [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues)\n- **Suggest Features**: Propose new features or improvements\n- **Write Code**: Fix bugs, implement features, or improve performance\n- **Improve Documentation**: Enhance README files, write tutorials, or fix typos\n- **Write Tests**: Add test coverage or improve existing tests\n- **Review Pull Requests**: Help review and test others' contributions\n- **Answer Questions**: Help other users in GitHub Discussions or Issues\n\n### Before You Start\n\n1. **Search Existing Issues**: Check if your bug report or feature request already exists\n2. **Check Roadmap**: Review the project roadmap to see if your idea aligns with project goals\n3. **Discuss Major Changes**: For significant changes, open an issue first or submit an [OSEP](oseps/README.md) to discuss your approach\n4. **Review Architecture**: Read [docs/architecture.md](docs/architecture.md) to understand the system design\n\n## Development Environment Setup\n\n### Prerequisites\n\nDifferent components have different requirements:\n\n#### For Server (Python)\n\n- **Python 3.10+**\n- **uv** - Python package manager ([installation guide](https://github.com/astral-sh/uv))\n- **Docker** - For running sandboxes locally\n\n#### For execd (Go)\n\n- **Go 1.24+**\n- **Make** - Build automation (optional)\n- **Docker** - For building container images\n\n#### For SDKs\n\n- **Python SDK**: Python 3.10+, uv\n- **Java/Kotlin SDK**: JDK 17+, Gradle\n\n### Quick Setup\n\n#### Server Development\n\n```bash\n# Navigate to server directory\ncd server\n\n# Install dependencies\nuv sync\n\n# Copy example configuration\ncp example.config.toml ~/.sandbox.toml\n\n# Edit configuration for development\n# Set log_level = \"DEBUG\" and api_key\nnano ~/.sandbox.toml\n\n# Run server\nuv run python -m src.main\n```\n\nSee [server/DEVELOPMENT.md](server/DEVELOPMENT.md) for detailed server development guide.\n\n#### execd Development\n\n```bash\n# Navigate to execd directory\ncd components/execd\n\n# Download dependencies\ngo mod download\n\n# Build execd\ngo build -o bin/execd .\n\n# Run execd (requires Jupyter Server)\n./bin/execd --jupyter-host=http://localhost:8888 --port=44772\n```\n\nSee [components/execd/DEVELOPMENT.md](components/execd/DEVELOPMENT.md) for detailed execd development guide.\n\n#### SDK Development\n\n**Python SDK:**\n\n```bash\ncd sdks/sandbox/python\nuv sync\nuv run pytest\n```\n\n**Java/Kotlin SDK:**\n\n```bash\ncd sdks/sandbox/kotlin\n./gradlew build\n./gradlew test\n```\n\n## Project Structure\n\n```\nOpenSandbox/\n├── sdks/                     # Multi-language SDKs\n│   ├── code-interpreter/     # Code Interpreter SDK (Python, Kotlin)\n│   └── sandbox/              # Sandbox base SDK (Python, Kotlin)\n├── specs/                    # OpenAPI specifications\n│   ├── execd-api.yaml        # Execution API spec\n│   └── sandbox-lifecycle.yml # Lifecycle API spec\n├── server/                   # Sandbox server (Python/FastAPI)\n├── components/\n│   └── execd/                # Execution daemon (Go/Beego)\n├── sandboxes/                # Sandbox implementations\n│   └── code-interpreter/     # Code Interpreter sandbox\n├── examples/                 # Example integrations\n├── docs/                     # Documentation\n├── tests/                    # Cross-component tests\n│   └── e2e/                  # End-to-end tests\n└── scripts/                  # Build and utility scripts\n```\n\n## Development Workflow\n\n### Enhancement Proposals (OSEP)\n\nFor major features, architectural changes, or modifications to the core API/security model, we follow the **OSEP (OpenSandbox Enhancement Proposals)** process.\n\nPlease 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.\n\n### Branching Strategy\n\n- **main**: Stable production branch\n- **feature/[name]**: New features\n- **fix/[name]**: Bug fixes\n- **docs/[name]**: Documentation updates\n- **refactor/[name]**: Code refactoring\n- **test/[name]**: Test additions or improvements\n\n### Creating a Feature Branch\n\n```bash\n# Update main branch\ngit checkout main\ngit pull origin main\n\n# Create feature branch\ngit checkout -b feature/my-awesome-feature\n\n# Make your changes\n# ...\n\n# Commit your changes\ngit add .\ngit commit -m \"feat: add my awesome feature\"\n\n# Push to your fork\ngit push origin feature/my-awesome-feature\n```\n\n### Commit Message Format\n\nWe follow [Conventional Commits](https://www.conventionalcommits.org/) specification:\n\n```\n<type>(<scope>): <description>\n\n[optional body]\n\n[optional footer]\n```\n\n**Types:**\n\n- `feat`: New feature\n- `fix`: Bug fix\n- `docs`: Documentation changes\n- `style`: Code style changes (formatting, no logic change)\n- `refactor`: Code refactoring\n- `test`: Adding or updating tests\n- `chore`: Build process, dependencies, or tooling changes\n- `perf`: Performance improvements\n- `ci`: CI/CD changes\n\n**Examples:**\n\n```\nfeat(server): add Kubernetes runtime support\nfix(execd): resolve memory leak in session cleanup\ndocs(sdk): add Python SDK usage examples\ntest(server): add integration tests for Docker runtime\nrefactor(sdk): simplify filesystem API\n```\n\n### Making Changes\n\n1. **Write Clean Code**: Follow project coding standards (see below)\n2. **Add Tests**: Ensure your changes are covered by tests\n3. **Update Documentation**: Update relevant documentation files\n4. **Test Locally**: Run all tests and ensure they pass\n5. **Check Linting**: Run linters and fix any issues\n\n## Coding Standards\n\n### Python (Server, Python SDKs)\n\n- **Style Guide**: Follow [PEP 8](https://pep8.org/)\n- **Formatter**: Use `ruff` for formatting and linting\n- **Type Hints**: Always use type hints for function signatures\n- **Docstrings**: Use Google-style docstrings for public APIs\n\n```python\ndef create_sandbox(\n    image: ImageSpec,\n    timeout: timedelta,\n    entrypoint: Optional[List[str]] = None\n) -> Sandbox:\n    \"\"\"Create a new sandbox instance.\n\n    Args:\n        image: Container image specification\n        timeout: Sandbox timeout duration\n        entrypoint: Optional custom entrypoint command\n\n    Returns:\n        Created sandbox instance\n\n    Raises:\n        ValueError: If image or timeout is invalid\n    \"\"\"\n    # Implementation\n```\n\n**Running Linter:**\n\n```bash\ncd server\nuv run ruff check src tests\nuv run ruff format src tests\n```\n\n### Go (execd)\n\n- **Style Guide**: Follow [Effective Go](https://golang.org/doc/effective_go)\n- **Formatter**: Use `gofmt` for formatting\n- **Imports**: Organize in three groups (stdlib, third-party, internal)\n- **Error Handling**: Always handle errors explicitly\n\n```go\n// Good\nresult, err := someOperation()\nif err != nil {\n    logs.Error(\"operation failed: %v\", err)\n    return fmt.Errorf(\"failed to do something: %w\", err)\n}\n\n// Bad - silent failure\nresult, _ := someOperation()\n```\n\n**Running Formatter:**\n\n```bash\ncd components/execd\ngofmt -w .\n# Or\nmake fmt\n```\n\n### Java/Kotlin (Java/Kotlin SDKs)\n\n- **Style Guide**: Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)\n- **Formatter**: Use `ktlint`\n- **Null Safety**: Use Kotlin's null safety features\n\n```kotlin\nsuspend fun createSandbox(\n    image: ImageSpec,\n    timeout: Duration,\n    entrypoint: List<String>? = null\n): Sandbox {\n    // Implementation\n}\n```\n\n### General Guidelines\n\n- **Naming Conventions**:\n  - Functions/Methods: `snake_case` (Python), `camelCase` (Go, Kotlin)\n  - Classes: `PascalCase` (all languages)\n  - Constants: `UPPER_SNAKE_CASE` (all languages)\n  - Private members: `_leading_underscore` (Python), `unexported` (Go)\n\n- **Comments**: Write clear, concise comments explaining \"why\", not \"what\"\n- **Error Messages**: Provide actionable error messages with context\n- **Logging**: Use appropriate log levels (DEBUG, INFO, WARNING, ERROR)\n\n## Testing Guidelines\n\n### Test Coverage Requirements\n\n- **Core Packages**: Aim for >80% coverage\n- **API Layer**: Aim for >70% coverage\n- **Utilities**: Aim for >90% coverage\n\n### Writing Tests\n\n#### Python Tests (pytest)\n\n```python\nimport pytest\nfrom opensandbox import Sandbox\n\n@pytest.mark.asyncio\nasync def test_create_sandbox():\n    \"\"\"Test sandbox creation with valid parameters.\"\"\"\n    sandbox = await Sandbox.create(\n        image=\"python:3.11\",\n        timeout=timedelta(minutes=5)\n    )\n    assert sandbox.id is not None\n    assert sandbox.status == SandboxStatus.PENDING\n    await sandbox.kill()\n\n@pytest.mark.asyncio\nasync def test_invalid_timeout():\n    \"\"\"Test sandbox creation fails with invalid timeout.\"\"\"\n    with pytest.raises(ValueError):\n        await Sandbox.create(\n            image=\"python:3.11\",\n            timeout=timedelta(seconds=-1)\n        )\n```\n\n**Running Tests:**\n\n```bash\ncd server\nuv run pytest\nuv run pytest --cov=src --cov-report=html\n```\n\n#### Go Tests\n\n```go\nfunc TestController_Execute_Python(t *testing.T) {\n    ctrl := NewController(\"http://localhost:8888\", \"test-token\")\n\n    req := &ExecuteCodeRequest{\n        Language: Python,\n        Code:     \"print('hello')\",\n    }\n\n    err := ctrl.Execute(req)\n    assert.NoError(t, err)\n}\n```\n\n**Running Tests:**\n\n```bash\ncd components/execd\ngo test ./pkg/...\ngo test -v -cover ./pkg/...\n```\n\n#### Integration Tests\n\nIntegration tests require Docker:\n\n```bash\n# Server integration tests\ncd server\nuv run pytest tests/integration/\n\n# E2E tests\ncd tests/e2e/python\nuv run pytest\n```\n\n### Test Best Practices\n\n- **Test Names**: Use descriptive names that explain what is being tested\n- **Arrange-Act-Assert**: Structure tests clearly\n- **Isolation**: Each test should be independent\n- **Mocking**: Mock external dependencies appropriately\n- **Cleanup**: Always clean up resources (use fixtures, context managers)\n\n## Submitting Contributions\n\n### Pull Request Process\n\n1. **Create Feature Branch**: Branch from `main`\n2. **Make Changes**: Implement your feature or fix\n3. **Write Tests**: Add comprehensive test coverage\n4. **Update Documentation**: Update relevant docs\n5. **Test Locally**: Ensure all tests pass\n6. **Run Linters**: Fix any style issues\n7. **Commit Changes**: Use conventional commit messages\n8. **Push to Fork**: Push your branch to your fork\n9. **Create Pull Request**: Submit PR with detailed description\n\n### Pull Request Template\n\nWhen creating a PR, fill out the template:\n\n```markdown\n# Summary\n\n- What is changing and why?\n\n# Testing\n\n- [ ] Not run (explain why)\n- [ ] Unit tests\n- [ ] Integration tests\n- [ ] e2e / manual verification\n\n# Breaking Changes\n\n- [ ] None\n- [ ] Yes (describe impact and migration path)\n\n# Checklist\n\n- [ ] Linked Issue or clearly described motivation\n- [ ] Added/updated docs (if needed)\n- [ ] Added/updated tests (if needed)\n- [ ] Security impact considered\n- [ ] Backward compatibility considered\n```\n\n### Pull Request Guidelines\n\n**Do:**\n\n- Keep PRs focused and reasonably sized (< 500 lines if possible)\n- Write clear PR descriptions with motivation and context\n- Link related issues\n- Respond to review comments promptly\n- Update your PR based on feedback\n- Ensure CI passes before requesting review\n\n**Don't:**\n\n- Mix multiple unrelated changes in one PR\n- Submit PRs with failing tests\n- Ignore code review feedback\n- Force push after reviews have started (unless necessary)\n- Include commented-out code or debug statements\n\n### Code Review Process\n\n1. **Automated Checks**: CI runs tests, linters, and security scans\n2. **Maintainer Review**: A maintainer reviews your code\n3. **Feedback Loop**: Address review comments\n4. **Approval**: Once approved, a maintainer will merge your PR\n5. **Cleanup**: Delete your feature branch after merge\n\n## Communication Channels\n\n### GitHub Issues\n\nUse GitHub Issues for:\n\n- Bug reports\n- Feature requests\n- Documentation improvements\n- Questions about implementation\n\n**Bug Report Template:**\n\n```markdown\n**Description**\nA clear description of the bug.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Create sandbox with...\n2. Execute command...\n3. See error\n\n**Expected Behavior**\nWhat you expected to happen.\n\n**Environment**\n\n- OpenSandbox version:\n- Runtime (Docker/K8s):\n- OS:\n- Python/Go version:\n\n**Additional Context**\nLogs, screenshots, or other relevant information.\n```\n\n### GitHub Discussions\n\nUse GitHub Discussions for:\n\n- General questions\n- Design discussions\n- Brainstorming ideas\n- Community help\n\n### Getting Help\n\n- **Issues**: Technical problems or bugs\n- **Discussions**: Questions and community support\n- **Email**: For security issues, email conduct@opensandbox.io\n\n## Additional Resources\n\n### Documentation\n\n- [Architecture Overview](docs/architecture.md)\n- [Server Development Guide](server/DEVELOPMENT.md)\n- [execd Development Guide](components/execd/DEVELOPMENT.md)\n- [OpenAPI Specifications](specs/README.md)\n- [Python SDK Documentation](sdks/sandbox/python/README.md)\n- [Java/Kotlin SDK Documentation](sdks/sandbox/kotlin/README.md)\n\n### Examples\n\nBrowse [examples/](examples/) for real-world usage patterns:\n\n- Code Interpreter integration\n- AI Coding Agent integrations (Claude Code, Gemini CLI, etc.)\n- Browser automation (Chrome, Playwright)\n- Remote development (VS Code, Desktop)\n\n### External Resources\n\n- [FastAPI Documentation](https://fastapi.tiangolo.com/)\n- [Beego Documentation](https://beego.wiki/)\n- [Jupyter Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html)\n- [OpenAPI Specification](https://swagger.io/specification/)\n- [Docker API](https://docs.docker.com/engine/api/)\n\n## Acknowledgments\n\nThank you for contributing to OpenSandbox! Your contributions help make this project better for everyone in the AI and developer tools community.\n\nIf you have suggestions for improving this contributing guide, please open an issue or submit a pull request.\n\n## License\n\nBy contributing to OpenSandbox, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE).\n"
  },
  {
    "path": "LICENSE",
    "content": "Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"docs/assets/logo.svg\" alt=\"OpenSandbox logo\" width=\"150\" />\n\n  <h1>OpenSandbox</h1>\n\n  <p align=\"center\">\n    <a href=\"https://trendshift.io/repositories/21828\" target=\"_blank\">\n      <img src=\"https://trendshift.io/api/badge/repositories/21828\" alt=\"alibaba%2FOpenSandbox | Trendshift\" style=\"width: 320px; height: 70px;\" width=\"320\" height=\"70\" />\n    </a>\n  </p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/alibaba/OpenSandbox\">\n    <img src=\"https://img.shields.io/github/stars/alibaba/OpenSandbox.svg?style=social\" alt=\"GitHub stars\" />\n  </a>\n  <a href=\"https://deepwiki.com/alibaba/OpenSandbox\">\n    <img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\" />\n  </a>\n  <a href=\"https://www.apache.org/licenses/LICENSE-2.0.html\">\n    <img src=\"https://img.shields.io/badge/license-Apache%202.0-blue.svg\" alt=\"license\" />\n  </a>\n  <a href=\"https://badge.fury.io/py/opensandbox\">\n    <img src=\"https://badge.fury.io/py/opensandbox.svg\" alt=\"PyPI version\" />\n  </a>\n  <a href=\"https://badge.fury.io/js/@alibaba-group%2Fopensandbox\">\n    <img src=\"https://badge.fury.io/js/@alibaba-group%2Fopensandbox.svg\" alt=\"npm version\" />\n  </a>\n  <a href=\"https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox\">\n    <img src=\"https://img.shields.io/badge/CNCF-Landscape-0C66E4\" alt=\"CNCF Landscape\" />\n  </a>\n  <a href=\"https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11\">\n    <img src=\"https://img.shields.io/badge/DingTalk-Join-0089FF?logo=dingtalk&logoColor=white\" alt=\"DingTalk\" />\n  </a>\n  <a href=\"https://github.com/alibaba/OpenSandbox/actions\">\n    <img src=\"https://github.com/alibaba/OpenSandbox/actions/workflows/real-e2e.yml/badge.svg?branch=main\" alt=\"E2E Status\" />\n  </a>\n</p>\n\n  <hr />\n</div>\n\n[Documentation](https://open-sandbox.ai/) | [中文文档](https://open-sandbox.ai/zh/)\n\nOpenSandbox 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.\n\nOpenSandbox is now listed in the [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox).\n\n## Features\n\n- **Multi-language SDKs**: Provides sandbox SDKs in Python, Java/Kotlin, JavaScript/TypeScript, C#/.NET, Go (Roadmap), and more.\n- **Sandbox Protocol**: Defines sandbox lifecycle management APIs and sandbox execution APIs so you can extend custom sandbox runtimes.\n- **Sandbox Runtime**: Built-in lifecycle management supporting Docker and [high-performance Kubernetes runtime](./kubernetes), enabling both local runs and large-scale distributed scheduling.\n- **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).\n- **Network Policy**: Unified [Ingress Gateway](components/ingress) with multiple routing strategies plus per-sandbox [egress controls](components/egress).\n- **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/secure-container.md) for details.\n\n## Examples\n\n### Basic Sandbox Operations\n\nRequirements:\n\n- Docker (required for local execution)\n- Python 3.10+ (recommended for examples and local runtime)\n\n#### 1. Install and Configure the Sandbox Server\n\n```bash\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\n```\n\n> If you prefer working from source, you can still clone the repo for development, but you no longer need to clone this repository just to start the server.\n> You'll also require an instance of docker running.\n> ```bash\n> git clone https://github.com/alibaba/OpenSandbox.git\n> cd OpenSandbox/server\n> uv sync\n> cp example.config.toml ~/.sandbox.toml # Copy configuration file\n> uv run python -m src.main # Start the service\n> ```\n\n#### 2. Start the Sandbox Server\n\n```bash\nopensandbox-server\n\n# Show help\nopensandbox-server -h\n```\n\n#### 3. Create a Code Interpreter and Execute Commands\n\nInstall the Code Interpreter SDK\n\n```bash\nuv pip install opensandbox-code-interpreter\n```\n\nCreate a sandbox and execute commands\n\n```python\nimport asyncio\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.models import WriteEntry\n\nasync def main() -> None:\n    # 1. Create a sandbox\n    sandbox = await Sandbox.create(\n        \"opensandbox/code-interpreter:v1.0.2\",\n        entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n        env={\"PYTHON_VERSION\": \"3.11\"},\n        timeout=timedelta(minutes=10),\n    )\n\n    async with sandbox:\n\n        # 2. Execute a shell command\n        execution = await sandbox.commands.run(\"echo 'Hello OpenSandbox!'\")\n        print(execution.logs.stdout[0].text)\n\n        # 3. Write a file\n        await sandbox.files.write_files([\n            WriteEntry(path=\"/tmp/hello.txt\", data=\"Hello World\", mode=644)\n        ])\n\n        # 4. Read a file\n        content = await sandbox.files.read_file(\"/tmp/hello.txt\")\n        print(f\"Content: {content}\") # Content: Hello World\n\n        # 5. Create a code interpreter\n        interpreter = await CodeInterpreter.create(sandbox)\n\n        # 6. Execute Python code (single-run, pass language directly)\n        result = await interpreter.codes.run(\n              \"\"\"\n                  import sys\n                  print(sys.version)\n                  result = 2 + 2\n                  result\n              \"\"\",\n              language=SupportedLanguage.PYTHON,\n        )\n\n        print(result.result[0].text) # 4\n        print(result.logs.stdout[0].text) # 3.11.14\n\n    # 7. Cleanup the sandbox\n    await sandbox.kill()\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### More Examples\n\nOpenSandbox provides examples covering SDK usage, agent integrations, browser automation, and training workloads. All example code is located in the `examples/` directory.\n\n#### 🎯 Basic Examples\n\n- **[code-interpreter](examples/code-interpreter/README.md)** - End-to-end Code Interpreter SDK workflow in a sandbox.\n- **[aio-sandbox](examples/aio-sandbox/README.md)** - All-in-One sandbox setup using the OpenSandbox SDK.\n- **[agent-sandbox](examples/agent-sandbox/README.md)** - Example integration for running OpenSandbox workloads on Kubernetes with [kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox).\n\n#### 🤖 Coding Agent Integrations\n\n- **[claude-code](examples/claude-code/README.md)** - Run Claude Code inside OpenSandbox.\n- **[gemini-cli](examples/gemini-cli/README.md)** - Run Google Gemini CLI inside OpenSandbox.\n- **[codex-cli](examples/codex-cli/README.md)** - Run OpenAI Codex CLI inside OpenSandbox.\n- **[kimi-cli](examples/kimi-cli/README.md)** - Run [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) (Moonshot AI) inside OpenSandbox.\n- **[langgraph](examples/langgraph/README.md)** - LangGraph state-machine workflow that creates/runs a sandbox job with fallback retry.\n- **[google-adk](examples/google-adk/README.md)** - Google ADK agent using OpenSandbox tools to write/read files and run commands.\n- **[nullclaw](examples/nullclaw/README.md)** - Launch a [Nullclaw](https://github.com/nullclaw/nullclaw) Gateway inside a sandbox.\n- **[openclaw](examples/openclaw/README.md)** - Launch an OpenClaw Gateway inside a sandbox.\n\n#### 🌐 Browser and Desktop Environments\n\n- **[chrome](examples/chrome/README.md)** - Chromium sandbox with VNC and DevTools access for automation and debugging.\n- **[playwright](examples/playwright/README.md)** - Playwright + Chromium headless scraping and testing example.\n- **[desktop](examples/desktop/README.md)** - Full desktop environment in a sandbox with VNC access.\n- **[vscode](examples/vscode/README.md)** - code-server (VS Code Web) running inside a sandbox for remote dev.\n\n#### 🧠 ML and Training\n\n- **[rl-training](examples/rl-training/README.md)** - DQN CartPole training in a sandbox with checkpoints and summary output.\n\nFor more details, please refer to [examples](examples/README.md) and the README files in each example directory.\n\n## Project Structure\n\n| Directory | Description                                                      |\n|-----------|------------------------------------------------------------------|\n| [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin, TypeScript/JavaScript, C#/.NET) |\n| [`specs/`](specs/README.md) | OpenAPI specs and lifecycle specifications                      |\n| [`server/`](server/README.md) | Python FastAPI sandbox lifecycle server                          |\n| [`kubernetes/`](kubernetes/README.md) | Kubernetes deployment and examples                               |\n| [`components/execd/`](components/execd/README.md) | Sandbox execution daemon (commands and file operations)          |\n| [`components/ingress/`](components/ingress/README.md) | Sandbox traffic ingress proxy                                    |\n| [`components/egress/`](components/egress/README.md) | Sandbox network egress control                                   |\n| [`sandboxes/`](sandboxes/) | Runtime sandbox implementations                                   |\n| [`examples/`](examples/README.md) | Integration examples and use cases                               |\n| [`oseps/`](oseps/README.md) | OpenSandbox Enhancement Proposals                                |\n| [`docs/`](docs/) | Architecture and design documentation                            |\n| [`tests/`](tests/) | Cross-component E2E tests                                        |\n| [`scripts/`](scripts/) | Development and maintenance scripts                              |\n\nFor detailed architecture, see [docs/architecture.md](docs/architecture.md).\n\n## Documentation\n\n- [docs/architecture.md](docs/architecture.md) – Overall architecture & design philosophy\n- [oseps/README.md](oseps/README.md) – OpenSandbox Enhancement Proposals\n- SDK\n  - 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)) - includes sandbox lifecycle, command execution, file operations\n  - 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\n- [specs/README.md](specs/README.md) - OpenAPI definitions for sandbox lifecycle API and sandbox execution API\n- [server/README.md](server/README.md) - Sandbox server startup and configuration; supports Docker and Kubernetes runtimes\n\n## License\n\nThis project is open source under the [Apache 2.0 License](LICENSE).\n\n## Roadmap [2026.03]\n\n### SDK\n\n- **Sandbox client connection pool** - Client-side sandbox connection pool management, providing pre-provisioned sandboxes to obtain an environment at X ms.\n- **Go SDK** - Go client SDK for sandbox lifecycle management, command execution, and file operations.\n\n### Sandbox Runtime\n\n- **Persistent volumes** - Mountable persistent volumes for sandboxes (see [Proposal 0003](oseps/0003-volume-and-volumebinding-support.md)).\n- **Local lightweight sandbox** - Lightweight sandbox for AI tools running directly on PCs.\n- **Secure Container** - Secure sandbox for AI Agents running inside container.\n\n### Deployment\n\n- **Guide** - Deployment guide for self-hosted Kubernetes cluster.\n\n## Contact and Discussion\n\n- Issues: Submit bugs, feature requests, or design discussions through GitHub Issues\n- 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)\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#alibaba/OpenSandbox&type=date&legend=top-left)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting Security Issues\n\nThe OpenSandbox team takes security seriously. If you discover a security vulnerability, please report it responsibly.\n\n### How to Report\n\n- **GitHub Security Advisories**: Open a private security advisory on GitHub\n- **Email**: Contact the maintainers directly with \"[SECURITY]\" in the subject\n\n### What to Include\n\n- Clear description of the vulnerability\n- Steps to reproduce\n- Potential impact and scope\n- Suggested remediation (if available)\n\n## Response Process\n\n1. Acknowledgment within 48 hours\n2. Investigation and validation\n3. Fix development and testing\n4. Coordinated disclosure\n\n## Supported Versions\n\nOnly the latest release and main branch are actively supported with security updates.\n\n## Security Best Practices\n\nWhen deploying OpenSandbox:\n- Keep dependencies up to date\n- Use network policies to restrict sandbox egress\n- Monitor audit logs regularly\n- Follow principle of least privilege\n"
  },
  {
    "path": "cli/README.md",
    "content": "# OpenSandbox CLI\n\nA command-line interface for managing OpenSandbox environments from your terminal. Built on top of the [OpenSandbox Python SDK](../sdks/sandbox/python/README.md), the CLI provides intuitive commands for sandbox lifecycle management, file operations, command execution, and code interpretation.\n\n## Installation\n\n### pip\n\n```bash\npip install opensandbox-cli\n```\n\n### uv\n\n```bash\nuv add opensandbox-cli\n```\n\n### pipx (recommended for global CLI usage)\n\n```bash\npipx install opensandbox-cli\n```\n\n## Overview\n\n```bash\nosb --help\n```\n\n![CLI Help](assets/cli_help.png)\n\n## Quick Start\n\n### Step 0: Start the OpenSandbox Server\n\nBefore using the CLI, make sure the OpenSandbox server is running. See the root [README.md](../README.md) for startup instructions.\n\n```bash\nopensandbox-server\n```\n\n![Start OpenSandbox Server](assets/start_opensandbox_server.png)\n\n### Step 1: Install the CLI\n\n```bash\ncd cli\nuv pip install -e .\n```\n\n![Install CLI](assets/install_cli.png)\n\n### Step 2: Initialize Configuration\n\n```bash\nosb config init\nosb config set connection.domain localhost:8080\nosb config set connection.protocol http\n```\n\n![Init CLI](assets/init_cli.png)\n\n### Step 3: Create a Sandbox\n\n```bash\nosb sandbox create --image python:3.12\n```\n\n![Create Sandbox](assets/cli_create_sandbox.png)\n\n### Step 4: List Sandboxes\n\n```bash\n# Table output (default)\nosb sandbox list\n\n# JSON output for scripting\nosb -o json sandbox list\n```\n\n![List Sandboxes](assets/cli_list_sandbox.png)\n\n![List Sandboxes JSON](assets/cli_list_sandbox_json.png)\n\n### Short ID Matching\n\nLike Docker, you don't need to type the full sandbox ID — just enough characters to uniquely identify the target sandbox:\n\n```bash\n# Full ID\nosb sandbox get db027570-4f86-45f8-b1a8-c31a2dd90da8\n\n# Short prefix — as long as it's unambiguous\nosb sandbox get db02\nosb exec db02 -- echo \"hello\"\n```\n\nIf the prefix matches multiple sandboxes, the CLI will report an error listing the matches so you can be more specific.\n\n![Short ID Matching](assets/cli_sandbox_search.png)\n\n### Step 5: Execute Commands\n\n```bash\nosb exec <sandbox-id> -- echo \"hello world\"\nosb exec <sandbox-id> -- python -c \"print(1+1)\"\n```\n\n![Execute Commands](assets/cli_sandbox_exec.png)\n\n### Step 6: File Operations\n\n```bash\n# Write a file\nosb file write <sandbox-id> /tmp/test.txt -c \"hello\"\n\n# Read it back\nosb file cat <sandbox-id> /tmp/test.txt\n```\n\n![File Operations](assets/cli_sandbox_file.png)\n\n### Step 7: Cleanup\n\n```bash\nosb sandbox kill <sandbox-id>\nosb sandbox list\n```\n\n![Kill Sandbox](assets/cli_kill_sandbox.png)\n\n## Command Reference\n\n### `osb sandbox` — Lifecycle Management\n\n| Command    | Description                                 |\n| ---------- | ------------------------------------------- |\n| `create`   | Create a new sandbox                        |\n| `list`     | List sandboxes (with optional filters)      |\n| `get`      | Get sandbox details by ID                   |\n| `kill`     | Terminate one or more sandboxes             |\n| `pause`    | Pause a running sandbox                     |\n| `resume`   | Resume a paused sandbox                     |\n| `renew`    | Renew sandbox expiration                    |\n| `endpoint` | Get public endpoint for a sandbox port      |\n| `health`   | Check sandbox health                        |\n| `metrics`  | Get sandbox resource metrics (CPU, memory)  |\n\n### `osb command` — Command Execution\n\n| Command     | Description                               |\n| ----------- | ----------------------------------------- |\n| `run`       | Run a shell command in the sandbox        |\n| `status`    | Get command execution status              |\n| `logs`      | Get background command logs               |\n| `interrupt` | Interrupt a running command               |\n\n### `osb exec` — Quick Command Shortcut\n\n```bash\nosb exec <sandbox-id> -- <command>\n```\n\nShortcut for `osb command run`. Everything after `--` is passed as the command.\n\n### `osb file` — File Operations\n\n| Command    | Description                                |\n| ---------- | ------------------------------------------ |\n| `cat`      | Read file contents                         |\n| `write`    | Write content to a file                    |\n| `upload`   | Upload a local file to the sandbox         |\n| `download` | Download a file from the sandbox           |\n| `rm`       | Delete files                               |\n| `mv`       | Move or rename a file                      |\n| `mkdir`    | Create directories                         |\n| `rmdir`    | Remove directories                         |\n| `search`   | Search for files by pattern                |\n| `info`     | Get file/directory metadata                |\n| `chmod`    | Set file permissions                       |\n| `replace`  | Find and replace content in a file         |\n\n### `osb code` — Code Interpreter\n\n| Command     | Description                               |\n| ----------- | ----------------------------------------- |\n| `run`       | Execute code in a sandbox                 |\n| `context`   | Manage code execution contexts            |\n| `interrupt` | Interrupt a running code execution        |\n\n### `osb config` — Configuration\n\n| Command | Description                                |\n| ------- | ------------------------------------------ |\n| `init`  | Create a default config file               |\n| `show`  | Show resolved configuration                |\n\n## Configuration\n\nThe CLI resolves configuration from multiple sources with the following priority (highest to lowest):\n\n1. **CLI flags** — `--api-key`, `--domain`, `--protocol`, `--timeout`\n2. **Environment variables** — `OPEN_SANDBOX_API_KEY`, `OPEN_SANDBOX_DOMAIN`, `OPEN_SANDBOX_PROTOCOL`, `OPEN_SANDBOX_REQUEST_TIMEOUT`, `OPEN_SANDBOX_OUTPUT`\n3. **Config file** — `~/.opensandbox/config.toml` (or path specified via `--config`)\n4. **SDK defaults**\n\n### Config File Format\n\n```toml\n[connection]\napi_key = \"your-api-key\"\ndomain = \"localhost:8080\"\nprotocol = \"http\"\nrequest_timeout = 30\n\n[output]\nformat = \"table\"    # table | json | yaml\ncolor = true\n\n[defaults]\nimage = \"python:3.11\"\ntimeout = \"10m\"\n```\n\n## Global Options\n\n| Option                        | Description                      |\n| ----------------------------- | -------------------------------- |\n| `--api-key TEXT`              | API key for authentication       |\n| `--domain TEXT`               | API server domain                |\n| `--protocol [http\\|https]`    | Protocol                         |\n| `--timeout INTEGER`           | Request timeout in seconds       |\n| `-o, --output [table\\|json\\|yaml]` | Output format              |\n| `--config PATH`               | Config file path                 |\n| `-v, --verbose`               | Enable debug output              |\n| `--no-color`                  | Disable colored output           |\n| `--version`                   | Show version                     |\n\n## Output Formats\n\nThe CLI supports three output formats via the `-o` / `--output` flag:\n\n- **`table`** (default) — Human-friendly tables powered by [Rich](https://github.com/Textualize/rich)\n- **`json`** — Machine-readable JSON\n- **`yaml`** — YAML output\n\n```bash\n# Table (default)\nosb sandbox list\n\n# JSON for scripting\nosb -o json sandbox list\n\n# YAML\nosb -o yaml sandbox list\n```\n"
  },
  {
    "path": "cli/pyproject.toml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"opensandbox-cli\"\ndynamic = [\"version\"]\ndescription = \"OpenSandbox CLI - Command-line interface for managing sandboxes\"\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"ninan.nn@alibaba-inc.com\" }\n]\nlicense = { file = \"LICENSE\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nkeywords = [\"sandbox\", \"cli\", \"opensandbox\"]\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Software Development :: Libraries\",\n]\ndependencies = [\n    \"opensandbox>=0.1.4,<0.2.0\",\n    \"opensandbox-code-interpreter>=0.1.0,<0.2.0\",\n    \"click>=8.1.0,<9.0\",\n    \"rich>=13.0.0,<14.0\",\n    \"pyyaml>=6.0,<7.0\",\n    \"tomli>=2.0.0; python_version < '3.11'\",\n]\n\n[project.urls]\nHomepage = \"https://open-sandbox.ai\"\nRepository = \"https://github.com/alibaba/OpenSandbox\"\nDocumentation = \"https://open-sandbox.ai\"\nIssues = \"https://github.com/alibaba/OpenSandbox/issues\"\n\n[project.scripts]\nopensandbox = \"opensandbox_cli.main:cli\"\nosb = \"opensandbox_cli.main:cli\"\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.hatch.version.raw-options]\nroot = \"..\"\ntag_regex = \"^python/cli/v(?P<version>\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[\\\\.\\\\w\\\\+\\\\-]*)?)$\"\ngit_describe_command = 'git describe --dirty --tags --long --match \"python/cli/v*\"'\nfallback_version = \"0.1.0\"\n\n[tool.hatch.build]\ninclude = [\n    \"LICENSE\",\n    \"src/**/py.typed\",\n    \"src/opensandbox_cli\",\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/opensandbox_cli\"]\n\n[dependency-groups]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-cov>=4.0.0\",\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.0\",\n]\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 88\n\n[tool.ruff.lint]\nselect = [\n    \"E\",  # pycodestyle errors\n    \"W\",  # pycodestyle warnings\n    \"F\",  # pyflakes\n    \"I\",  # isort\n    \"B\",  # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"UP\", # pyupgrade\n]\nignore = [\n    \"E501\", # line too long, handled by formatter\n    \"B008\", # do not perform function calls in argument defaults\n    \"C901\", # too complex\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.10\"\npythonPlatform = \"All\"\ninclude = [\"src\"]\nexclude = [\n    \"**/node_modules\",\n    \"**/__pycache__\",\n]\nreportMissingImports = true\nreportMissingTypeStubs = false\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = \"-ra -q --strict-markers --strict-config\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\", \"*_test.py\"]\n\n[tool.coverage.run]\nsource = [\"src\"]\nbranch = true\n\n[tool.uv.sources]\nopensandbox = { path = \"../sdks/sandbox/python\", editable = true }\nopensandbox-code-interpreter = { path = \"../sdks/code-interpreter/python\", editable = true }\n"
  },
  {
    "path": "cli/src/opensandbox_cli/__init__.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\ntry:\n    from importlib.metadata import version\n\n    __version__ = version(\"opensandbox-cli\")\nexcept Exception:\n    __version__ = \"0.0.0-dev\"\n"
  },
  {
    "path": "cli/src/opensandbox_cli/__main__.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Allow running as ``python -m opensandbox_cli``.\"\"\"\n\nfrom opensandbox_cli.main import cli\n\nif __name__ == \"__main__\":\n    cli()\n"
  },
  {
    "path": "cli/src/opensandbox_cli/client.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"SDK client factory stored in Click context.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass, field\nfrom datetime import timedelta\nfrom typing import Any\n\nimport click\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import SandboxFilter\nfrom opensandbox.sync.manager import SandboxManagerSync\nfrom opensandbox.sync.sandbox import SandboxSync\n\nfrom opensandbox_cli.output import OutputFormatter\n\n# Full UUID pattern: 8-4-4-4-12 hex characters\n_UUID_RE = re.compile(\n    r\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\"\n)\n\n\n@dataclass\nclass ClientContext:\n    \"\"\"Shared context passed via ``ctx.obj`` to all Click commands.\"\"\"\n\n    resolved_config: dict[str, Any]\n    output: OutputFormatter\n    _connection_config: ConnectionConfigSync | None = field(\n        default=None, init=False, repr=False\n    )\n    _manager: SandboxManagerSync | None = field(\n        default=None, init=False, repr=False\n    )\n\n    @property\n    def connection_config(self) -> ConnectionConfigSync:\n        if self._connection_config is None:\n            cfg = self.resolved_config\n            self._connection_config = ConnectionConfigSync(\n                api_key=cfg.get(\"api_key\"),\n                domain=cfg.get(\"domain\"),\n                protocol=cfg.get(\"protocol\", \"http\"),\n                request_timeout=timedelta(seconds=cfg.get(\"request_timeout\", 30)),\n            )\n        return self._connection_config\n\n    def get_manager(self) -> SandboxManagerSync:\n        \"\"\"Return a lazily-created ``SandboxManagerSync``.\"\"\"\n        if self._manager is None:\n            self._manager = SandboxManagerSync.create(self.connection_config)\n        return self._manager\n\n    def resolve_sandbox_id(self, prefix: str) -> str:\n        \"\"\"Resolve a sandbox ID prefix to the full ID (Docker-style).\n\n        If *prefix* looks like a complete UUID, it is returned as-is without\n        querying the server.  Otherwise **all pages** of sandboxes are fetched\n        so that prefix collisions on later pages are never missed.\n        \"\"\"\n        # Skip resolution for full UUIDs\n        if _UUID_RE.match(prefix):\n            return prefix\n\n        mgr = self.get_manager()\n        matches: list[str] = []\n        page = 0\n\n        while True:\n            result = mgr.list_sandbox_infos(\n                SandboxFilter(page=page, page_size=100)\n            )\n            if result.sandbox_infos:\n                matches.extend(\n                    info.id\n                    for info in result.sandbox_infos\n                    if info.id.startswith(prefix)\n                )\n            # Stop early if we already found >1 match (ambiguous)\n            if len(matches) > 1:\n                break\n            if not result.pagination.has_next_page:\n                break\n            page += 1\n\n        if len(matches) == 1:\n            return matches[0]\n        elif len(matches) == 0:\n            raise click.ClickException(\n                f\"No sandbox found with ID prefix '{prefix}'\"\n            )\n        else:\n            ids_str = \", \".join(matches[:5])\n            if len(matches) > 5:\n                ids_str += \", ...\"\n            raise click.ClickException(\n                f\"Ambiguous ID prefix '{prefix}' matches {len(matches)} sandboxes: {ids_str}\"\n            )\n\n    def connect_sandbox(\n        self, sandbox_id: str, *, skip_health_check: bool = True\n    ) -> SandboxSync:\n        \"\"\"Connect to an existing sandbox by ID (supports prefix matching).\"\"\"\n        sandbox_id = self.resolve_sandbox_id(sandbox_id)\n        return SandboxSync.connect(\n            sandbox_id,\n            connection_config=self.connection_config,\n            skip_health_check=skip_health_check,\n        )\n\n    def close(self) -> None:\n        \"\"\"Release resources.\"\"\"\n        if self._manager is not None:\n            self._manager.close()\n            self._manager = None\n        if self._connection_config is not None:\n            self._connection_config.close_transport_if_owned()\n            self._connection_config = None\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/__init__.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/code.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Code execution commands: run, context management, interrupt.\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\n\nimport click\n\nfrom opensandbox.models.execd import OutputMessage\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.utils import handle_errors\n\n\n@click.group(\"code\", invoke_without_command=True)\n@click.pass_context\ndef code_group(ctx: click.Context) -> None:\n    \"\"\"💻 Execute code in a sandbox (via Code Interpreter).\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n# ---- run ------------------------------------------------------------------\n\n@code_group.command(\"run\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--language\", \"-l\", required=True, help=\"Language (python, javascript, java, go, bash, ...).\")\n@click.option(\"--code\", \"-c\", default=None, help=\"Code to execute. Reads from stdin if not provided.\")\n@click.option(\"--context-id\", default=None, help=\"Execution context ID for stateful sessions.\")\n@click.pass_obj\n@handle_errors\ndef code_run(\n    obj: ClientContext,\n    sandbox_id: str,\n    language: str,\n    code: str | None,\n    context_id: str | None,\n) -> None:\n    \"\"\"Execute code in a sandbox.\"\"\"\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n    if code is None:\n        if sys.stdin.isatty():\n            click.echo(\"Reading code from stdin (Ctrl+D to finish):\", err=True)\n        code = sys.stdin.read()\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        interpreter = CodeInterpreterSync.create(sandbox)\n\n        kwargs: dict = {}\n        if context_id:\n            ctx = interpreter.codes.get_context(context_id)\n            kwargs[\"context\"] = ctx\n\n        def on_stdout(msg: OutputMessage) -> None:\n            sys.stdout.write(msg.text)\n            sys.stdout.flush()\n\n        def on_stderr(msg: OutputMessage) -> None:\n            sys.stderr.write(msg.text)\n            sys.stderr.flush()\n\n        handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)\n        execution = interpreter.codes.run(\n            code, language=language, handlers=handlers, **kwargs\n        )\n\n        if execution.error:\n            obj.output.error(\n                f\"{execution.error.name}: {execution.error.value}\"\n            )\n            sys.exit(1)\n    finally:\n        sandbox.close()\n\n\n# ---- context group --------------------------------------------------------\n\n@code_group.group(\"context\", invoke_without_command=True)\n@click.pass_context\ndef context_group(ctx: click.Context) -> None:\n    \"\"\"Manage code execution contexts.\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n@context_group.command(\"create\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--language\", \"-l\", required=True, help=\"Language for the context.\")\n@click.pass_obj\n@handle_errors\ndef context_create(obj: ClientContext, sandbox_id: str, language: str) -> None:\n    \"\"\"Create a new code execution context.\"\"\"\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        interpreter = CodeInterpreterSync.create(sandbox)\n        ctx = interpreter.codes.create_context(language)\n        obj.output.success_panel(\n            {\"context_id\": ctx.id, \"language\": language},\n            title=\"Context Created\",\n        )\n    finally:\n        sandbox.close()\n\n\n@context_group.command(\"list\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--language\", \"-l\", required=True, help=\"Language to list contexts for.\")\n@click.pass_obj\n@handle_errors\ndef context_list(obj: ClientContext, sandbox_id: str, language: str) -> None:\n    \"\"\"List code execution contexts.\"\"\"\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        interpreter = CodeInterpreterSync.create(sandbox)\n        contexts = interpreter.codes.list_contexts(language)\n        for ctx in contexts:\n            click.echo(f\"{ctx.id}\")\n    finally:\n        sandbox.close()\n\n\n@context_group.command(\"delete\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"context_id\")\n@click.pass_obj\n@handle_errors\ndef context_delete(obj: ClientContext, sandbox_id: str, context_id: str) -> None:\n    \"\"\"Delete a code execution context.\"\"\"\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        interpreter = CodeInterpreterSync.create(sandbox)\n        interpreter.codes.delete_context(context_id)\n        obj.output.success(f\"Deleted context: {context_id}\")\n    finally:\n        sandbox.close()\n\n\n@context_group.command(\"delete-all\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--language\", \"-l\", required=True, help=\"Language to delete all contexts for.\")\n@click.pass_obj\n@handle_errors\ndef context_delete_all(obj: ClientContext, sandbox_id: str, language: str) -> None:\n    \"\"\"Delete all code execution contexts for a language.\"\"\"\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        interpreter = CodeInterpreterSync.create(sandbox)\n        interpreter.codes.delete_contexts(language)\n        obj.output.success(f\"Deleted all {language} contexts\")\n    finally:\n        sandbox.close()\n\n\n# ---- interrupt ------------------------------------------------------------\n\n@code_group.command(\"interrupt\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"execution_id\")\n@click.pass_obj\n@handle_errors\ndef code_interrupt(obj: ClientContext, sandbox_id: str, execution_id: str) -> None:\n    \"\"\"Interrupt a running code execution.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.commands.interrupt(execution_id)\n        obj.output.success(f\"Interrupted: {execution_id}\")\n    finally:\n        sandbox.close()\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/command.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Command execution commands: run, status, logs, interrupt + top-level exec alias.\"\"\"\n\nfrom __future__ import annotations\n\nimport shlex\nimport sys\nfrom datetime import timedelta\n\nimport click\n\nfrom opensandbox.models.execd import OutputMessage, RunCommandOpts\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.utils import DURATION, handle_errors\n\n\n@click.group(\"command\", invoke_without_command=True)\n@click.pass_context\ndef command_group(ctx: click.Context) -> None:\n    \"\"\"⚡ Execute commands in a sandbox.\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n# ---- run ------------------------------------------------------------------\n\ndef _run_command(\n    obj: ClientContext,\n    sandbox_id: str,\n    command: tuple[str, ...],\n    background: bool,\n    workdir: str | None,\n    timeout: timedelta | None,\n) -> None:\n    \"\"\"Shared implementation for 'command run' and top-level 'exec'.\"\"\"\n    cmd_str = \" \".join(shlex.quote(arg) for arg in command)\n    sandbox = obj.connect_sandbox(sandbox_id)\n\n    try:\n        opts = RunCommandOpts(\n            background=background,\n            working_directory=workdir,\n            timeout=timeout,\n        )\n\n        if background:\n            execution = sandbox.commands.run(cmd_str, opts=opts)\n            obj.output.success_panel(\n                {\n                    \"execution_id\": execution.id,\n                    \"sandbox_id\": sandbox_id,\n                    \"mode\": \"background\",\n                },\n                title=\"Background Command Started\",\n            )\n            return\n\n        # Foreground: stream stdout/stderr to terminal\n        last_text = \"\"\n\n        def on_stdout(msg: OutputMessage) -> None:\n            nonlocal last_text\n            last_text = msg.text\n            sys.stdout.write(msg.text)\n            sys.stdout.flush()\n\n        def on_stderr(msg: OutputMessage) -> None:\n            nonlocal last_text\n            last_text = msg.text\n            sys.stderr.write(msg.text)\n            sys.stderr.flush()\n\n        handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)\n        execution = sandbox.commands.run(cmd_str, opts=opts, handlers=handlers)\n\n        # Ensure terminal prompt starts on a new line\n        if last_text and not last_text.endswith(\"\\n\"):\n            sys.stdout.write(\"\\n\")\n            sys.stdout.flush()\n\n        if execution.error:\n            obj.output.error_panel(\n                f\"{execution.error.name}: {execution.error.value}\",\n                title=\"Execution Error\",\n            )\n            sys.exit(1)\n    finally:\n        sandbox.close()\n\n\n@command_group.command(\"run\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"command\", nargs=-1, required=True)\n@click.option(\"-d\", \"--background\", is_flag=True, default=False, help=\"Run in background.\")\n@click.option(\"-w\", \"--workdir\", default=None, help=\"Working directory.\")\n@click.option(\"-t\", \"--timeout\", type=DURATION, default=None, help=\"Command timeout (e.g. 30s, 5m).\")\n@click.pass_obj\n@handle_errors\ndef command_run(\n    obj: ClientContext,\n    sandbox_id: str,\n    command: tuple[str, ...],\n    background: bool,\n    workdir: str | None,\n    timeout: timedelta | None,\n) -> None:\n    \"\"\"Run a command in a sandbox.\"\"\"\n    _run_command(obj, sandbox_id, command, background, workdir, timeout)\n\n\n# ---- status ---------------------------------------------------------------\n\n@command_group.command(\"status\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"execution_id\")\n@click.pass_obj\n@handle_errors\ndef command_status(obj: ClientContext, sandbox_id: str, execution_id: str) -> None:\n    \"\"\"Get command execution status.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        status = sandbox.commands.get_command_status(execution_id)\n        obj.output.print_model(status, title=\"Command Status\")\n    finally:\n        sandbox.close()\n\n\n# ---- logs -----------------------------------------------------------------\n\n@command_group.command(\"logs\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"execution_id\")\n@click.option(\"--cursor\", type=int, default=None, help=\"Cursor for incremental reads.\")\n@click.pass_obj\n@handle_errors\ndef command_logs(\n    obj: ClientContext, sandbox_id: str, execution_id: str, cursor: int | None\n) -> None:\n    \"\"\"Get background command logs.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        logs = sandbox.commands.get_background_command_logs(execution_id, cursor=cursor)\n        if obj.output.fmt in (\"json\", \"yaml\"):\n            obj.output.print_model(logs, title=\"Command Logs\")\n        else:\n            click.echo(logs.content)\n    finally:\n        sandbox.close()\n\n\n# ---- interrupt ------------------------------------------------------------\n\n@command_group.command(\"interrupt\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"execution_id\")\n@click.pass_obj\n@handle_errors\ndef command_interrupt(obj: ClientContext, sandbox_id: str, execution_id: str) -> None:\n    \"\"\"Interrupt a running command.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.commands.interrupt(execution_id)\n        obj.output.success(f\"Interrupted: {execution_id}\")\n    finally:\n        sandbox.close()\n\n\n# ---- top-level exec alias ------------------------------------------------\n\n@click.command(\"exec\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"command\", nargs=-1, required=True)\n@click.option(\"-d\", \"--background\", is_flag=True, default=False, help=\"Run in background.\")\n@click.option(\"-w\", \"--workdir\", default=None, help=\"Working directory.\")\n@click.option(\"-t\", \"--timeout\", type=DURATION, default=None, help=\"Command timeout (e.g. 30s, 5m).\")\n@click.pass_obj\n@handle_errors\ndef exec_cmd(\n    obj: ClientContext,\n    sandbox_id: str,\n    command: tuple[str, ...],\n    background: bool,\n    workdir: str | None,\n    timeout: timedelta | None,\n) -> None:\n    \"\"\"🚀 Execute a command in a sandbox (shortcut for 'command run').\"\"\"\n    _run_command(obj, sandbox_id, command, background, workdir, timeout)\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/config_cmd.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Config management commands: init, show, set.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport click\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.config import DEFAULT_CONFIG_PATH, init_config_file\nfrom opensandbox_cli.utils import handle_errors\n\n\n@click.group(\"config\", invoke_without_command=True)\n@click.pass_context\ndef config_group(ctx: click.Context) -> None:\n    \"\"\"⚙️  Manage CLI configuration.\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n# ---- init -----------------------------------------------------------------\n\n@config_group.command(\"init\")\n@click.option(\"--force\", is_flag=True, default=False, help=\"Overwrite existing config file.\")\n@click.option(\"--path\", \"config_path\", type=click.Path(path_type=Path), default=None, help=\"Config file path.\")\n@handle_errors\ndef config_init(force: bool, config_path: Path | None) -> None:\n    \"\"\"Create a default configuration file.\"\"\"\n    # config_init doesn't have @click.pass_obj, get formatter from context\n    ctx = click.get_current_context(silent=True)\n    obj = getattr(ctx, \"obj\", None) if ctx else None\n    output = getattr(obj, \"output\", None) if obj else None\n\n    try:\n        path = init_config_file(config_path, force=force)\n        if output:\n            output.success(f\"Config file created: {path}\")\n        else:\n            click.echo(f\"Config file created: {path}\")\n    except FileExistsError as exc:\n        if output:\n            output.warning(str(exc))\n        else:\n            click.secho(str(exc), fg=\"yellow\", err=True)\n\n\n# ---- show -----------------------------------------------------------------\n\n@config_group.command(\"show\")\n@click.pass_obj\n@handle_errors\ndef config_show(obj: ClientContext) -> None:\n    \"\"\"Show the resolved configuration.\"\"\"\n    obj.output.print_dict(obj.resolved_config, title=\"Resolved Configuration\")\n\n\n# ---- set ------------------------------------------------------------------\n\n@config_group.command(\"set\")\n@click.argument(\"key\")\n@click.argument(\"value\")\n@click.option(\"--path\", \"config_path\", type=click.Path(path_type=Path), default=None, help=\"Config file path.\")\n@handle_errors\ndef config_set(key: str, value: str, config_path: Path | None) -> None:\n    \"\"\"Set a configuration value (e.g. 'connection.domain' 'localhost:9090').\"\"\"\n    path = config_path or DEFAULT_CONFIG_PATH\n    if not path.exists():\n        click.secho(f\"Config file not found: {path}. Run 'osb config init' first.\", fg=\"red\", err=True)\n        return\n\n    content = path.read_text()\n\n    # Simple key replacement in TOML\n    # Supports dotted keys like connection.domain\n    parts = key.split(\".\", 1)\n    if len(parts) == 2:\n        section, field = parts\n        # Try to find and update existing value\n        import re\n\n        section_pattern = rf\"(\\[{re.escape(section)}\\].*?)(?=\\n\\[|\\Z)\"\n        section_match = re.search(section_pattern, content, re.DOTALL)\n\n        # Infer TOML value type: bool > int > float > string\n        def _toml_value(raw: str) -> str:\n            if raw.lower() in (\"true\", \"false\"):\n                return raw.lower()\n            try:\n                int(raw)\n                return raw\n            except ValueError:\n                pass\n            try:\n                float(raw)\n                return raw\n            except ValueError:\n                pass\n            return f'\"{raw}\"'\n\n        toml_val = _toml_value(value)\n\n        if section_match:\n            section_text = section_match.group(1)\n            field_pattern = rf'^(#?\\s*{re.escape(field)}\\s*=\\s*).*$'\n            field_match = re.search(field_pattern, section_text, re.MULTILINE)\n            if field_match:\n                new_line = f'{field} = {toml_val}'\n                new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():]\n                content = content[:section_match.start()] + new_section + content[section_match.end():]\n            else:\n                # Add field to section\n                insert_pos = section_match.end()\n                content = content[:insert_pos] + f'\\n{field} = {toml_val}' + content[insert_pos:]\n        else:\n            # Add new section\n            content += f'\\n[{section}]\\n{field} = {toml_val}\\n'\n    else:\n        click.secho(\"Key must be in 'section.field' format (e.g. connection.domain).\", fg=\"red\", err=True)\n        return\n\n    path.write_text(content)\n\n    # config_set doesn't have @click.pass_obj, get formatter from context\n    ctx = click.get_current_context(silent=True)\n    obj = getattr(ctx, \"obj\", None) if ctx else None\n    output = getattr(obj, \"output\", None) if obj else None\n    if output:\n        output.success(f\"Set {key} = {value}\")\n    else:\n        click.echo(f\"Set {key} = {value}\")\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/file.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace.\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nfrom pathlib import Path\n\nimport click\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.utils import handle_errors\n\n\n@click.group(\"file\", invoke_without_command=True)\n@click.pass_context\ndef file_group(ctx: click.Context) -> None:\n    \"\"\"📁 File operations on a sandbox.\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n# ---- cat (read) -----------------------------------------------------------\n\n@file_group.command(\"cat\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"path\")\n@click.option(\"--encoding\", default=\"utf-8\", help=\"File encoding.\")\n@click.pass_obj\n@handle_errors\ndef file_cat(obj: ClientContext, sandbox_id: str, path: str, encoding: str) -> None:\n    \"\"\"Read a file from the sandbox.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        content = sandbox.files.read_file(path, encoding=encoding)\n        click.echo(content, nl=False)\n    finally:\n        sandbox.close()\n\n\n# ---- write ----------------------------------------------------------------\n\n@file_group.command(\"write\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"path\")\n@click.option(\"--content\", \"-c\", default=None, help=\"Content to write. Reads from stdin if not provided.\")\n@click.option(\"--encoding\", default=\"utf-8\", help=\"File encoding.\")\n@click.option(\"--mode\", default=None, help=\"File permission mode (e.g. 0644).\")\n@click.option(\"--owner\", default=None, help=\"File owner.\")\n@click.option(\"--group\", default=None, help=\"File group.\")\n@click.pass_obj\n@handle_errors\ndef file_write(\n    obj: ClientContext,\n    sandbox_id: str,\n    path: str,\n    content: str | None,\n    encoding: str,\n    mode: str | None,\n    owner: str | None,\n    group: str | None,\n) -> None:\n    \"\"\"Write content to a file in the sandbox.\"\"\"\n    if content is None:\n        if sys.stdin.isatty():\n            click.echo(\"Reading from stdin (Ctrl+D to finish):\", err=True)\n        content = sys.stdin.read()\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        kwargs: dict = {\"encoding\": encoding}\n        if mode is not None:\n            kwargs[\"mode\"] = mode\n        if owner is not None:\n            kwargs[\"owner\"] = owner\n        if group is not None:\n            kwargs[\"group\"] = group\n        sandbox.files.write_file(path, content, **kwargs)\n        obj.output.success(f\"Written: {path}\")\n    finally:\n        sandbox.close()\n\n\n# ---- upload ---------------------------------------------------------------\n\n@file_group.command(\"upload\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"local_path\", type=click.Path(exists=True))\n@click.argument(\"remote_path\")\n@click.pass_obj\n@handle_errors\ndef file_upload(\n    obj: ClientContext, sandbox_id: str, local_path: str, remote_path: str\n) -> None:\n    \"\"\"Upload a local file to the sandbox.\"\"\"\n    data = Path(local_path).read_bytes()\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.write_file(remote_path, data)\n        obj.output.success(f\"Uploaded: {local_path} → {remote_path}\")\n    finally:\n        sandbox.close()\n\n\n# ---- download -------------------------------------------------------------\n\n@file_group.command(\"download\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"remote_path\")\n@click.argument(\"local_path\", type=click.Path())\n@click.pass_obj\n@handle_errors\ndef file_download(\n    obj: ClientContext, sandbox_id: str, remote_path: str, local_path: str\n) -> None:\n    \"\"\"Download a file from the sandbox to local disk.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        content = sandbox.files.read_bytes(remote_path)\n        Path(local_path).write_bytes(content)\n        obj.output.success(f\"Downloaded: {remote_path} → {local_path}\")\n    finally:\n        sandbox.close()\n\n\n# ---- rm (delete) ----------------------------------------------------------\n\n@file_group.command(\"rm\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"paths\", nargs=-1, required=True)\n@click.pass_obj\n@handle_errors\ndef file_rm(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:\n    \"\"\"Delete files from the sandbox.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.delete_files(list(paths))\n        for p in paths:\n            obj.output.success(f\"Deleted: {p}\")\n    finally:\n        sandbox.close()\n\n\n# ---- mv (move) ------------------------------------------------------------\n\n@file_group.command(\"mv\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"source\")\n@click.argument(\"destination\")\n@click.pass_obj\n@handle_errors\ndef file_mv(\n    obj: ClientContext, sandbox_id: str, source: str, destination: str\n) -> None:\n    \"\"\"Move/rename a file in the sandbox.\"\"\"\n    from opensandbox.models.filesystem import MoveEntry\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.move_files([MoveEntry(source=source, destination=destination)])\n        obj.output.success(f\"Moved: {source} → {destination}\")\n    finally:\n        sandbox.close()\n\n\n# ---- mkdir ----------------------------------------------------------------\n\n@file_group.command(\"mkdir\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"paths\", nargs=-1, required=True)\n@click.option(\"--mode\", default=None, help=\"Directory permission mode.\")\n@click.option(\"--owner\", default=None, help=\"Directory owner.\")\n@click.option(\"--group\", default=None, help=\"Directory group.\")\n@click.pass_obj\n@handle_errors\ndef file_mkdir(\n    obj: ClientContext,\n    sandbox_id: str,\n    paths: tuple[str, ...],\n    mode: str | None,\n    owner: str | None,\n    group: str | None,\n) -> None:\n    \"\"\"Create directories in the sandbox.\"\"\"\n    from opensandbox.models.filesystem import WriteEntry\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        entries = []\n        for p in paths:\n            kwargs: dict = {\"path\": p}\n            if mode is not None:\n                kwargs[\"mode\"] = mode\n            if owner is not None:\n                kwargs[\"owner\"] = owner\n            if group is not None:\n                kwargs[\"group\"] = group\n            entries.append(WriteEntry(**kwargs))\n        sandbox.files.create_directories(entries)\n        for p in paths:\n            obj.output.success(f\"Created: {p}\")\n    finally:\n        sandbox.close()\n\n\n# ---- rmdir ----------------------------------------------------------------\n\n@file_group.command(\"rmdir\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"paths\", nargs=-1, required=True)\n@click.pass_obj\n@handle_errors\ndef file_rmdir(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:\n    \"\"\"Delete directories from the sandbox.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.delete_directories(list(paths))\n        for p in paths:\n            obj.output.success(f\"Removed: {p}\")\n    finally:\n        sandbox.close()\n\n\n# ---- search ---------------------------------------------------------------\n\n@file_group.command(\"search\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"path\")\n@click.option(\"--pattern\", \"-p\", required=True, help=\"Glob pattern to search for.\")\n@click.pass_obj\n@handle_errors\ndef file_search(\n    obj: ClientContext, sandbox_id: str, path: str, pattern: str\n) -> None:\n    \"\"\"Search for files in the sandbox.\"\"\"\n    from opensandbox.models.filesystem import SearchEntry\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        results = sandbox.files.search(SearchEntry(path=path, pattern=pattern))\n        if not results:\n            if obj.output.fmt in (\"json\", \"yaml\"):\n                obj.output.print_models([], columns=[])\n            else:\n                obj.output.info(\"No files found.\")\n            return\n        if obj.output.fmt in (\"json\", \"yaml\"):\n            obj.output.print_models(results, columns=[\"path\", \"size\", \"mode\", \"owner\", \"modified_at\"])\n        else:\n            obj.output.print_models(results, columns=[\"path\", \"size\", \"owner\"], title=\"Search Results\")\n    finally:\n        sandbox.close()\n\n\n# ---- info (stat) ----------------------------------------------------------\n\n@file_group.command(\"info\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"paths\", nargs=-1, required=True)\n@click.pass_obj\n@handle_errors\ndef file_info(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None:\n    \"\"\"Get file/directory info.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        info_map = sandbox.files.get_file_info(list(paths))\n        for path, entry in info_map.items():\n            obj.output.print_dict(\n                {\"path\": path, **entry.model_dump(mode=\"json\")},\n                title=path,\n            )\n    finally:\n        sandbox.close()\n\n\n# ---- chmod ----------------------------------------------------------------\n\n@file_group.command(\"chmod\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"path\")\n@click.option(\"--mode\", required=True, help=\"Permission mode (e.g. 0755).\")\n@click.option(\"--owner\", default=None, help=\"File owner.\")\n@click.option(\"--group\", default=None, help=\"File group.\")\n@click.pass_obj\n@handle_errors\ndef file_chmod(\n    obj: ClientContext,\n    sandbox_id: str,\n    path: str,\n    mode: str,\n    owner: str | None,\n    group: str | None,\n) -> None:\n    \"\"\"Set file permissions.\"\"\"\n    from opensandbox.models.filesystem import SetPermissionEntry\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.set_permissions(\n            [SetPermissionEntry(path=path, mode=mode, owner=owner, group=group)]\n        )\n        obj.output.success(f\"Permissions set: {path}\")\n    finally:\n        sandbox.close()\n\n\n# ---- replace --------------------------------------------------------------\n\n@file_group.command(\"replace\")\n@click.argument(\"sandbox_id\")\n@click.argument(\"path\")\n@click.option(\"--old\", required=True, help=\"Text to search for.\")\n@click.option(\"--new\", required=True, help=\"Replacement text.\")\n@click.pass_obj\n@handle_errors\ndef file_replace(\n    obj: ClientContext, sandbox_id: str, path: str, old: str, new: str\n) -> None:\n    \"\"\"Replace content in a file.\"\"\"\n    from opensandbox.models.filesystem import ContentReplaceEntry\n\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        sandbox.files.replace_contents(\n            [ContentReplaceEntry(path=path, old_content=old, new_content=new)]\n        )\n        obj.output.success(f\"Replaced in: {path}\")\n    finally:\n        sandbox.close()\n"
  },
  {
    "path": "cli/src/opensandbox_cli/commands/sandbox.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Sandbox lifecycle commands: create, list, get, kill, pause, resume, renew, endpoint, health, metrics.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom datetime import timedelta\n\nimport click\n\nfrom opensandbox.models.sandboxes import NetworkPolicy, SandboxFilter\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.utils import DURATION, KEY_VALUE, handle_errors\n\n\n@click.group(\"sandbox\", invoke_without_command=True)\n@click.pass_context\ndef sandbox_group(ctx: click.Context) -> None:\n    \"\"\"📦 Manage sandbox lifecycle.\"\"\"\n    if ctx.invoked_subcommand is None:\n        click.echo(ctx.get_help())\n\n\n# Alias: osb sb ...\nsandbox_group.name = \"sandbox\"\n\n\n# ---- create ---------------------------------------------------------------\n\n@sandbox_group.command(\"create\")\n@click.option(\"--image\", \"-i\", required=True, help=\"Container image (e.g. python:3.11).\")\n@click.option(\"--timeout\", \"-t\", \"timeout\", type=DURATION, default=None, help=\"Sandbox lifetime (e.g. 10m, 1h).\")\n@click.option(\"--env\", \"-e\", \"envs\", multiple=True, type=KEY_VALUE, help=\"Environment variable (KEY=VALUE). Repeatable.\")\n@click.option(\"--metadata\", \"-m\", \"metadata_kv\", multiple=True, type=KEY_VALUE, help=\"Metadata (KEY=VALUE). Repeatable.\")\n@click.option(\"--resource\", \"resources_kv\", multiple=True, type=KEY_VALUE, help=\"Resource limit (e.g. cpu=1 memory=2Gi). Repeatable.\")\n@click.option(\"--entrypoint\", default=None, help=\"Entrypoint command (JSON array or shell string).\")\n@click.option(\"--network-policy-file\", type=click.Path(exists=True), default=None, help=\"Network policy JSON file.\")\n@click.option(\"--skip-health-check\", is_flag=True, default=False, help=\"Skip waiting for sandbox readiness.\")\n@click.option(\"--ready-timeout\", type=DURATION, default=None, help=\"Max wait time for sandbox readiness (e.g. 30s).\")\n@click.pass_obj\n@handle_errors\ndef sandbox_create(\n    obj: ClientContext,\n    image: str,\n    timeout: timedelta | None,\n    envs: tuple[tuple[str, str], ...],\n    metadata_kv: tuple[tuple[str, str], ...],\n    resources_kv: tuple[tuple[str, str], ...],\n    entrypoint: str | None,\n    network_policy_file: str | None,\n    skip_health_check: bool,\n    ready_timeout: timedelta | None,\n) -> None:\n    \"\"\"Create a new sandbox.\"\"\"\n    from opensandbox.sync.sandbox import SandboxSync\n\n    kwargs: dict = {\n        \"connection_config\": obj.connection_config,\n        \"skip_health_check\": skip_health_check,\n    }\n    if timeout is not None:\n        kwargs[\"timeout\"] = timeout\n    if ready_timeout is not None:\n        kwargs[\"ready_timeout\"] = ready_timeout\n    if envs:\n        kwargs[\"env\"] = dict(envs)\n    if metadata_kv:\n        kwargs[\"metadata\"] = dict(metadata_kv)\n    if resources_kv:\n        kwargs[\"resource\"] = dict(resources_kv)\n    if entrypoint:\n        try:\n            kwargs[\"entrypoint\"] = json.loads(entrypoint)\n        except json.JSONDecodeError:\n            kwargs[\"entrypoint\"] = [\"sh\", \"-c\", entrypoint]\n    if network_policy_file:\n        with open(network_policy_file) as f:\n            kwargs[\"network_policy\"] = NetworkPolicy(**json.load(f))\n\n    with obj.output.spinner(\"Creating sandbox...\"):\n        sandbox = SandboxSync.create(image, **kwargs)\n    obj.output.success_panel(\n        {\"id\": sandbox.id, \"image\": image, \"status\": \"created\"},\n        title=\"Sandbox Created\",\n    )\n\n\n# ---- list -----------------------------------------------------------------\n\n@sandbox_group.command(\"list\")\n@click.option(\"--state\", \"-s\", \"states\", multiple=True, help=\"Filter by state (Pending, Running, Paused, ...). Repeatable.\")\n@click.option(\"--metadata\", \"-m\", \"metadata_kv\", multiple=True, type=KEY_VALUE, help=\"Metadata filter (KEY=VALUE). Repeatable.\")\n@click.option(\"--page\", type=int, default=None, help=\"Page number (0-indexed).\")\n@click.option(\"--page-size\", type=int, default=None, help=\"Items per page.\")\n@click.pass_obj\n@handle_errors\ndef sandbox_list(\n    obj: ClientContext,\n    states: tuple[str, ...],\n    metadata_kv: tuple[tuple[str, str], ...],\n    page: int | None,\n    page_size: int | None,\n) -> None:\n    \"\"\"List sandboxes.\"\"\"\n    mgr = obj.get_manager()\n    filt = SandboxFilter(\n        states=list(states) if states else None,\n        metadata=dict(metadata_kv) if metadata_kv else None,\n        page=page,\n        page_size=page_size,\n    )\n    with obj.output.spinner(\"Fetching sandboxes...\"):\n        result = mgr.list_sandbox_infos(filt)\n    if not result.sandbox_infos:\n        if obj.output.fmt in (\"json\", \"yaml\"):\n            obj.output.print_rows(\n                [], columns=[\"id\", \"status\", \"image\", \"created_at\", \"expires_at\"],\n                title=\"Sandboxes\",\n            )\n        else:\n            obj.output.info(\"No sandboxes found.\")\n        return\n\n    raw_rows = [info.model_dump(mode=\"json\") for info in result.sandbox_infos]\n\n    # For machine-readable formats, preserve the original structure\n    if obj.output.fmt in (\"json\", \"yaml\"):\n        obj.output.print_rows(\n            raw_rows,\n            columns=[\"id\", \"status\", \"image\", \"created_at\", \"expires_at\"],\n            title=\"Sandboxes\",\n        )\n        return\n\n    # Flatten nested status/image objects for clean table display\n    rows = []\n    for d in raw_rows:\n        flat = dict(d)\n        status_val = flat.get(\"status\")\n        if isinstance(status_val, dict):\n            flat[\"status\"] = status_val.get(\"state\", str(status_val))\n        image_val = flat.get(\"image\")\n        if isinstance(image_val, dict):\n            flat[\"image\"] = image_val.get(\"image\", str(image_val))\n        rows.append(flat)\n\n    obj.output.print_rows(\n        rows,\n        columns=[\"id\", \"status\", \"image\", \"created_at\", \"expires_at\"],\n        title=\"Sandboxes\",\n    )\n\n\n# ---- get ------------------------------------------------------------------\n\n@sandbox_group.command(\"get\")\n@click.argument(\"sandbox_id\")\n@click.pass_obj\n@handle_errors\ndef sandbox_get(obj: ClientContext, sandbox_id: str) -> None:\n    \"\"\"Get sandbox details.\"\"\"\n    sandbox_id = obj.resolve_sandbox_id(sandbox_id)\n    mgr = obj.get_manager()\n    info = mgr.get_sandbox_info(sandbox_id)\n    d = info.model_dump(mode=\"json\")\n\n    # For machine-readable formats, preserve the original structure\n    if obj.output.fmt in (\"json\", \"yaml\"):\n        obj.output.print_dict(d, title=\"Sandbox Info\")\n        return\n\n    # Flatten nested objects for clean table display\n    status_val = d.get(\"status\")\n    if isinstance(status_val, dict):\n        d[\"status\"] = status_val.get(\"state\", str(status_val))\n        if status_val.get(\"reason\"):\n            d[\"status_reason\"] = status_val[\"reason\"]\n        if status_val.get(\"message\"):\n            d[\"status_message\"] = status_val[\"message\"]\n    image_val = d.get(\"image\")\n    if isinstance(image_val, dict):\n        d[\"image\"] = image_val.get(\"image\", str(image_val))\n    obj.output.print_dict(d, title=\"Sandbox Info\")\n\n\n# ---- kill -----------------------------------------------------------------\n\n@sandbox_group.command(\"kill\")\n@click.argument(\"sandbox_ids\", nargs=-1, required=True)\n@click.pass_obj\n@handle_errors\ndef sandbox_kill(obj: ClientContext, sandbox_ids: tuple[str, ...]) -> None:\n    \"\"\"Terminate one or more sandboxes.\"\"\"\n    mgr = obj.get_manager()\n    for sid in sandbox_ids:\n        resolved = obj.resolve_sandbox_id(sid)\n        with obj.output.spinner(f\"Killing sandbox {resolved}...\"):\n            mgr.kill_sandbox(resolved)\n        obj.output.success(f\"Sandbox terminated: {resolved}\")\n\n\n# ---- pause ----------------------------------------------------------------\n\n@sandbox_group.command(\"pause\")\n@click.argument(\"sandbox_id\")\n@click.pass_obj\n@handle_errors\ndef sandbox_pause(obj: ClientContext, sandbox_id: str) -> None:\n    \"\"\"Pause a running sandbox.\"\"\"\n    sandbox_id = obj.resolve_sandbox_id(sandbox_id)\n    mgr = obj.get_manager()\n    with obj.output.spinner(\"Pausing sandbox...\"):\n        mgr.pause_sandbox(sandbox_id)\n    obj.output.success(f\"Sandbox paused: {sandbox_id}\")\n\n\n# ---- resume ---------------------------------------------------------------\n\n@sandbox_group.command(\"resume\")\n@click.argument(\"sandbox_id\")\n@click.pass_obj\n@handle_errors\ndef sandbox_resume(obj: ClientContext, sandbox_id: str) -> None:\n    \"\"\"Resume a paused sandbox.\"\"\"\n    sandbox_id = obj.resolve_sandbox_id(sandbox_id)\n    mgr = obj.get_manager()\n    with obj.output.spinner(\"Resuming sandbox...\"):\n        mgr.resume_sandbox(sandbox_id)\n    obj.output.success(f\"Sandbox resumed: {sandbox_id}\")\n\n\n# ---- renew ----------------------------------------------------------------\n\n@sandbox_group.command(\"renew\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--timeout\", \"-t\", required=True, type=DURATION, help=\"New TTL duration (e.g. 30m, 2h).\")\n@click.pass_obj\n@handle_errors\ndef sandbox_renew(obj: ClientContext, sandbox_id: str, timeout: timedelta) -> None:\n    \"\"\"Renew sandbox expiration.\"\"\"\n    sandbox_id = obj.resolve_sandbox_id(sandbox_id)\n    mgr = obj.get_manager()\n    with obj.output.spinner(\"Renewing sandbox...\"):\n        resp = mgr.renew_sandbox(sandbox_id, timeout)\n    obj.output.success_panel(\n        {\"sandbox_id\": sandbox_id, \"expires_at\": str(resp.expires_at)},\n        title=\"Sandbox Renewed\",\n    )\n\n\n# ---- endpoint -------------------------------------------------------------\n\n@sandbox_group.command(\"endpoint\")\n@click.argument(\"sandbox_id\")\n@click.option(\"--port\", \"-p\", required=True, type=int, help=\"Port number.\")\n@click.pass_obj\n@handle_errors\ndef sandbox_endpoint(obj: ClientContext, sandbox_id: str, port: int) -> None:\n    \"\"\"Get the public endpoint for a sandbox port.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        ep = sandbox.get_endpoint(port)\n        obj.output.print_model(ep, title=\"Sandbox Endpoint\")\n    finally:\n        sandbox.close()\n\n\n# ---- health ---------------------------------------------------------------\n\n@sandbox_group.command(\"health\")\n@click.argument(\"sandbox_id\")\n@click.pass_obj\n@handle_errors\ndef sandbox_health(obj: ClientContext, sandbox_id: str) -> None:\n    \"\"\"Check sandbox health.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        healthy = sandbox.is_healthy()\n        if obj.output.fmt == \"table\":\n            if healthy:\n                obj.output.success(f\"Sandbox {sandbox_id} is healthy\")\n            else:\n                obj.output.error(f\"Sandbox {sandbox_id} is unhealthy\")\n        else:\n            obj.output.print_dict(\n                {\"sandbox_id\": sandbox_id, \"healthy\": healthy},\n                title=\"Health Check\",\n            )\n    finally:\n        sandbox.close()\n\n\n# ---- metrics --------------------------------------------------------------\n\n@sandbox_group.command(\"metrics\")\n@click.argument(\"sandbox_id\")\n@click.pass_obj\n@handle_errors\ndef sandbox_metrics(obj: ClientContext, sandbox_id: str) -> None:\n    \"\"\"Get sandbox resource metrics.\"\"\"\n    sandbox = obj.connect_sandbox(sandbox_id)\n    try:\n        m = sandbox.get_metrics()\n        obj.output.print_model(m, title=\"Sandbox Metrics\")\n    finally:\n        sandbox.close()\n"
  },
  {
    "path": "cli/src/opensandbox_cli/config.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"CLI configuration loading and management.\n\nPriority (highest to lowest):\n  1. CLI flags\n  2. Environment variables\n  3. Config file (~/.opensandbox/config.toml)\n  4. SDK defaults\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Any\n\nif sys.version_info >= (3, 11):\n    import tomllib\nelse:\n    try:\n        import tomli as tomllib  # type: ignore[no-redef]\n    except ModuleNotFoundError:  # pragma: no cover\n        tomllib = None  # type: ignore[assignment]\n\n\nDEFAULT_CONFIG_DIR = Path.home() / \".opensandbox\"\nDEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / \"config.toml\"\n\nDEFAULT_CONFIG_TEMPLATE = \"\"\"\\\n# OpenSandbox CLI configuration\n# Priority: CLI flags > environment variables > this file > SDK defaults\n\n[connection]\n# api_key = \"your-api-key\"\n# domain = \"localhost:8080\"\n# protocol = \"http\"\n# request_timeout = 30\n\n[output]\n# format = \"table\"    # table | json | yaml\n# color = true\n\n[defaults]\n# image = \"python:3.11\"\n# timeout = \"10m\"\n\"\"\"\n\n\ndef load_config_file(config_path: Path | None = None) -> dict[str, Any]:\n    \"\"\"Load and parse the TOML config file.\n\n    Returns an empty dict if the file doesn't exist or tomllib is unavailable.\n    \"\"\"\n    path = config_path or DEFAULT_CONFIG_PATH\n    if not path.exists():\n        return {}\n    if tomllib is None:\n        return {}\n    with open(path, \"rb\") as f:\n        return tomllib.load(f)\n\n\ndef resolve_config(\n    *,\n    cli_api_key: str | None = None,\n    cli_domain: str | None = None,\n    cli_protocol: str | None = None,\n    cli_timeout: int | None = None,\n    cli_output: str | None = None,\n    config_path: Path | None = None,\n) -> dict[str, Any]:\n    \"\"\"Merge config from all sources and return a flat dict.\n\n    Keys returned:\n      - api_key, domain, protocol, request_timeout (int seconds)\n      - output_format (\"table\" | \"json\" | \"yaml\")\n      - default_image, default_timeout (str like \"10m\")\n    \"\"\"\n    file_cfg = load_config_file(config_path)\n    conn = file_cfg.get(\"connection\", {})\n    output_cfg = file_cfg.get(\"output\", {})\n    defaults = file_cfg.get(\"defaults\", {})\n\n    return {\n        \"api_key\": cli_api_key\n        or os.getenv(\"OPEN_SANDBOX_API_KEY\")\n        or conn.get(\"api_key\"),\n        \"domain\": cli_domain\n        or os.getenv(\"OPEN_SANDBOX_DOMAIN\")\n        or conn.get(\"domain\"),\n        \"protocol\": cli_protocol\n        or os.getenv(\"OPEN_SANDBOX_PROTOCOL\")\n        or conn.get(\"protocol\")\n        or \"http\",\n        \"request_timeout\": cli_timeout\n        or _int_or_none(os.getenv(\"OPEN_SANDBOX_REQUEST_TIMEOUT\"))\n        or conn.get(\"request_timeout\")\n        or 30,\n        \"output_format\": cli_output\n        or os.getenv(\"OPEN_SANDBOX_OUTPUT\")\n        or output_cfg.get(\"format\")\n        or \"table\",\n        \"color\": output_cfg.get(\"color\", True),\n        \"default_image\": defaults.get(\"image\"),\n        \"default_timeout\": defaults.get(\"timeout\"),\n    }\n\n\ndef init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path:\n    \"\"\"Create a default config file. Returns the path written.\"\"\"\n    path = config_path or DEFAULT_CONFIG_PATH\n    if path.exists() and not force:\n        raise FileExistsError(\n            f\"Config file already exists at {path}. Use --force to overwrite.\"\n        )\n    path.parent.mkdir(parents=True, exist_ok=True)\n    path.write_text(DEFAULT_CONFIG_TEMPLATE)\n    return path\n\n\ndef _int_or_none(value: str | None) -> int | None:\n    if value is None:\n        return None\n    try:\n        return int(value)\n    except ValueError:\n        return None\n"
  },
  {
    "path": "cli/src/opensandbox_cli/main.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Root Click group with global options.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nimport click\nfrom rich.console import Console\nfrom rich.text import Text\n\nfrom opensandbox_cli import __version__\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.commands.code import code_group\nfrom opensandbox_cli.commands.command import command_group, exec_cmd\nfrom opensandbox_cli.commands.config_cmd import config_group\nfrom opensandbox_cli.commands.file import file_group\nfrom opensandbox_cli.commands.sandbox import sandbox_group\nfrom opensandbox_cli.config import resolve_config\nfrom opensandbox_cli.output import OutputFormatter\n\n# ---------------------------------------------------------------------------\n# Banner\n# ---------------------------------------------------------------------------\n\nBANNER = r\"\"\"[bold cyan]\n   ____                   _____                 _ _\n  / __ \\                 / ____|               | | |\n | |  | |_ __   ___ _ _| (___   __ _ _ __   __| | |__   _____  __\n | |  | | '_ \\ / _ \\ '_ \\___ \\ / _` | '_ \\ / _` | '_ \\ / _ \\ \\/ /\n | |__| | |_) |  __/ | | |___) | (_| | | | | (_| | |_) | (_) >  <\n  \\____/| .__/ \\___|_| |_|____/ \\__,_|_| |_|\\__,_|_.__/ \\___/_/\\_\\\n        | |\n        |_|[/]  [dim]v{version}[/]\n\"\"\"\n\n\nclass BannerGroup(click.Group):\n    \"\"\"Custom Click group that shows a banner before help text.\"\"\"\n\n    def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:\n        console = Console(stderr=False)\n        console.print(BANNER.format(version=__version__))\n        super().format_help(ctx, formatter)\n\n\n@click.group(cls=BannerGroup, context_settings={\"help_option_names\": [\"-h\", \"--help\"]})\n@click.option(\"--api-key\", envvar=\"OPEN_SANDBOX_API_KEY\", default=None, help=\"API key for authentication.\")\n@click.option(\"--domain\", envvar=\"OPEN_SANDBOX_DOMAIN\", default=None, help=\"API server domain (e.g. localhost:8080).\")\n@click.option(\"--protocol\", type=click.Choice([\"http\", \"https\"]), default=None, help=\"Protocol (http/https).\")\n@click.option(\"--timeout\", \"request_timeout\", type=int, default=None, help=\"Request timeout in seconds.\")\n@click.option(\"-o\", \"--output\", \"output_format\", type=click.Choice([\"table\", \"json\", \"yaml\"]), default=None, help=\"Output format.\")\n@click.option(\"--config\", \"config_path\", type=click.Path(exists=False, path_type=Path), default=None, help=\"Config file path.\")\n@click.option(\"-v\", \"--verbose\", is_flag=True, default=False, help=\"Enable verbose/debug output.\")\n@click.option(\"--no-color\", is_flag=True, default=False, help=\"Disable colored output.\")\n@click.version_option(version=__version__, prog_name=\"opensandbox\")\n@click.pass_context\ndef cli(\n    ctx: click.Context,\n    api_key: str | None,\n    domain: str | None,\n    protocol: str | None,\n    request_timeout: int | None,\n    output_format: str | None,\n    config_path: Path | None,\n    verbose: bool,\n    no_color: bool,\n) -> None:\n    \"\"\"OpenSandbox CLI — manage sandboxes from your terminal.\"\"\"\n    if verbose:\n        import logging\n\n        logging.basicConfig(level=logging.DEBUG)\n\n    resolved = resolve_config(\n        cli_api_key=api_key,\n        cli_domain=domain,\n        cli_protocol=protocol,\n        cli_timeout=request_timeout,\n        cli_output=output_format,\n        config_path=config_path,\n    )\n\n    formatter = OutputFormatter(\n        resolved[\"output_format\"],\n        color=not no_color and resolved.get(\"color\", True),\n    )\n\n    ctx.obj = ClientContext(resolved_config=resolved, output=formatter)\n    ctx.call_on_close(lambda: ctx.obj.close())\n\n\n# Register sub-command groups\ncli.add_command(sandbox_group)\ncli.add_command(command_group)\ncli.add_command(exec_cmd)\ncli.add_command(file_group)\ncli.add_command(code_group)\ncli.add_command(config_group)\n"
  },
  {
    "path": "cli/src/opensandbox_cli/output.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Output formatting: table (rich), JSON, YAML.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport sys\nfrom contextlib import contextmanager\nfrom typing import Any, Generator, Sequence\n\nimport click\n\ntry:\n    import yaml\nexcept ImportError:  # pragma: no cover\n    yaml = None  # type: ignore[assignment]\n\nfrom pydantic import BaseModel\nfrom rich import box\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.status import Status\nfrom rich.table import Table\nfrom rich.text import Text\n\n# ---------------------------------------------------------------------------\n# Status badge styling  (sandbox state → color + icon)\n# ---------------------------------------------------------------------------\n\n_STATUS_STYLES: dict[str, tuple[str, str]] = {\n    # state → (rich style, icon)\n    \"running\": (\"bold green\", \"●\"),\n    \"ready\": (\"bold green\", \"●\"),\n    \"healthy\": (\"bold green\", \"●\"),\n    \"pending\": (\"bold yellow\", \"◐\"),\n    \"creating\": (\"bold yellow\", \"◐\"),\n    \"starting\": (\"bold yellow\", \"◐\"),\n    \"paused\": (\"bold blue\", \"⏸\"),\n    \"stopped\": (\"dim\", \"○\"),\n    \"terminated\": (\"dim\", \"○\"),\n    \"killed\": (\"dim\", \"○\"),\n    \"error\": (\"bold red\", \"✗\"),\n    \"failed\": (\"bold red\", \"✗\"),\n    \"unhealthy\": (\"bold red\", \"✗\"),\n    \"created\": (\"bold cyan\", \"✦\"),\n}\n\n# Columns that contain status-like values\n_STATUS_COLUMNS = {\"status\", \"state\", \"healthy\"}\n\n# Columns that should be rendered in a dimmer style (long IDs, timestamps)\n_DIM_COLUMNS = {\"created_at\", \"expires_at\", \"modified_at\", \"updated_at\"}\n\n# Columns that are primary identifiers\n_ID_COLUMNS = {\"id\", \"sandbox_id\", \"execution_id\", \"context_id\"}\n\n\ndef _style_value(col: str, value: str) -> Text:\n    \"\"\"Apply contextual styling to a cell value.\"\"\"\n    lower = value.lower()\n\n    if col in _STATUS_COLUMNS:\n        style, icon = _STATUS_STYLES.get(lower, (\"\", \"\"))\n        if style:\n            return Text(f\"{icon} {value}\", style=style)\n\n    if col in _DIM_COLUMNS:\n        return Text(value, style=\"dim\")\n\n    if col in _ID_COLUMNS:\n        return Text(value, style=\"bold cyan\")\n\n    return Text(value)\n\n\nclass OutputFormatter:\n    \"\"\"Renders data in table / json / yaml format.\"\"\"\n\n    def __init__(self, fmt: str = \"table\", *, color: bool = True) -> None:\n        self.fmt = fmt\n        self.color = color\n        self.console = Console(\n            stderr=False, no_color=not color, force_terminal=None\n        )\n        self._err_console = Console(\n            stderr=True, no_color=not color, force_terminal=None\n        )\n\n    # ------------------------------------------------------------------\n    # Status messages with icons\n    # ------------------------------------------------------------------\n\n    def success(self, msg: str) -> None:\n        \"\"\"Print a success message with ✅ icon.\"\"\"\n        if self.color:\n            self.console.print(f\"  [bold green]✅ {msg}[/]\")\n        else:\n            click.echo(f\"OK: {msg}\")\n\n    def info(self, msg: str) -> None:\n        \"\"\"Print an info message with ℹ️  icon.\"\"\"\n        if self.color:\n            self.console.print(f\"  [bold blue]ℹ️  {msg}[/]\")\n        else:\n            click.echo(f\"INFO: {msg}\")\n\n    def warning(self, msg: str) -> None:\n        \"\"\"Print a warning message with ⚠️  icon.\"\"\"\n        if self.color:\n            self._err_console.print(f\"  [bold yellow]⚠️  {msg}[/]\")\n        else:\n            click.echo(f\"WARN: {msg}\", err=True)\n\n    def error(self, msg: str) -> None:\n        \"\"\"Print an error message with ❌ icon.\"\"\"\n        if self.color:\n            self._err_console.print(f\"  [bold red]❌ {msg}[/]\")\n        else:\n            click.echo(f\"ERROR: {msg}\", err=True)\n\n    def error_panel(self, msg: str, title: str = \"Error\") -> None:\n        \"\"\"Print an error with a bold header and message.\"\"\"\n        if self.color:\n            self._err_console.print()\n            self._err_console.print(f\"  [bold red]{title}[/]\")\n            self._err_console.print(f\"  [dim]{'─' * (len(title) + 2)}[/]\")\n            for line in msg.splitlines():\n                self._err_console.print(f\"  {line}\")\n            self._err_console.print()\n        else:\n            click.echo(f\"ERROR [{title}]: {msg}\", err=True)\n\n    # ------------------------------------------------------------------\n    # Spinner for long-running operations\n    # ------------------------------------------------------------------\n\n    @contextmanager\n    def spinner(self, msg: str) -> Generator[Status, None, None]:\n        \"\"\"Context manager that shows a spinner while work is in progress.\"\"\"\n        if self.color and self.fmt == \"table\":\n            with self._err_console.status(f\"[bold cyan]⏳ {msg}[/]\", spinner=\"dots\") as status:\n                yield status\n        else:\n            # No spinner in non-color or non-table mode\n            yield None  # type: ignore[arg-type]\n\n    # ------------------------------------------------------------------\n    # Panel output\n    # ------------------------------------------------------------------\n\n    def panel(self, content: str, *, title: str | None = None, style: str = \"cyan\") -> None:\n        \"\"\"Print content inside a styled panel.\"\"\"\n        if self.color:\n            self.console.print(Panel(\n                content,\n                title=title,\n                title_align=\"left\",\n                border_style=style,\n                box=box.ROUNDED,\n                padding=(0, 1),\n            ))\n        else:\n            if title:\n                click.echo(f\"--- {title} ---\")\n            click.echo(content)\n\n    def success_panel(self, data: dict[str, Any], *, title: str = \"Success\") -> None:\n        \"\"\"Print a success result with a header and indented key-value pairs.\"\"\"\n        if self.fmt != \"table\":\n            if self.fmt == \"json\":\n                self._print_json(data)\n            elif self.fmt == \"yaml\":\n                self._print_yaml(data)\n            return\n\n        if self.color:\n            self.console.print()\n            self.console.print(f\"  [bold green]✓ {title}[/]\")\n            self.console.print(f\"  [dim]{'─' * (len(title) + 2)}[/]\")\n            for k, v in data.items():\n                self.console.print(f\"  [bold]{k}:[/] [cyan]{v}[/]\")\n            self.console.print()\n        else:\n            click.echo(f\"--- {title} ---\")\n            for k, v in data.items():\n                click.echo(f\"  {k}: {v}\")\n\n    # ------------------------------------------------------------------\n    # Public helpers\n    # ------------------------------------------------------------------\n\n    def print_model(self, model: BaseModel, title: str | None = None) -> None:\n        \"\"\"Print a single Pydantic model as key-value panel or JSON/YAML.\"\"\"\n        data = _model_to_dict(model)\n        if self.fmt == \"json\":\n            self._print_json(data)\n        elif self.fmt == \"yaml\":\n            self._print_yaml(data)\n        else:\n            self._print_kv_table(data, title=title)\n\n    def print_models(\n        self,\n        models: Sequence[BaseModel],\n        columns: list[str],\n        *,\n        title: str | None = None,\n    ) -> None:\n        \"\"\"Print a list of Pydantic models as a table or JSON/YAML.\"\"\"\n        rows = [_model_to_dict(m) for m in models]\n        if self.fmt == \"json\":\n            self._print_json(rows)\n        elif self.fmt == \"yaml\":\n            self._print_yaml(rows)\n        else:\n            self._print_table(rows, columns, title=title)\n\n    def print_rows(\n        self,\n        rows: list[dict[str, Any]],\n        columns: list[str],\n        *,\n        title: str | None = None,\n    ) -> None:\n        \"\"\"Print pre-processed rows (list of dicts) as a table or JSON/YAML.\"\"\"\n        if self.fmt == \"json\":\n            self._print_json(rows)\n        elif self.fmt == \"yaml\":\n            self._print_yaml(rows)\n        else:\n            self._print_table(rows, columns, title=title)\n\n    def print_dict(self, data: dict[str, Any], title: str | None = None) -> None:\n        \"\"\"Print a flat dict.\"\"\"\n        if self.fmt == \"json\":\n            self._print_json(data)\n        elif self.fmt == \"yaml\":\n            self._print_yaml(data)\n        else:\n            self._print_kv_table(data, title=title)\n\n    def print_text(self, text: str) -> None:\n        \"\"\"Print raw text (ignores format).\"\"\"\n        click.echo(text)\n\n    # ------------------------------------------------------------------\n    # Internal renderers\n    # ------------------------------------------------------------------\n\n    def _print_json(self, data: Any) -> None:\n        if self.color:\n            self.console.print_json(json.dumps(data, default=str))\n        else:\n            click.echo(json.dumps(data, indent=2, default=str))\n\n    def _print_yaml(self, data: Any) -> None:\n        if yaml is None:\n            click.secho(\n                \"PyYAML is not installed. Use --output json instead.\", fg=\"red\", err=True\n            )\n            sys.exit(1)\n        click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip())\n\n    def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None:\n        table = Table(\n            title=title,\n            show_header=True,\n            header_style=\"bold magenta\",\n            title_style=\"bold cyan\",\n            box=box.ROUNDED,\n            border_style=\"bright_black\",\n            padding=(0, 1),\n            show_lines=True,\n        )\n        table.add_column(\"Key\", style=\"bold cyan\", no_wrap=True)\n        table.add_column(\"Value\")\n        for k, v in data.items():\n            val_text = _style_value(k, str(v)) if v is not None else Text(\"-\", style=\"dim\")\n            table.add_row(str(k), val_text)\n        self.console.print(table)\n\n    def _print_table(\n        self,\n        rows: list[dict[str, Any]],\n        columns: list[str],\n        *,\n        title: str | None = None,\n    ) -> None:\n        table = Table(\n            title=title,\n            show_header=True,\n            header_style=\"bold magenta\",\n            title_style=\"bold cyan\",\n            box=box.ROUNDED,\n            border_style=\"bright_black\",\n            padding=(0, 1),\n            row_styles=[\"\", \"dim\"],\n        )\n        for col in columns:\n            style = \"\"\n            if col in _ID_COLUMNS:\n                style = \"bold cyan\"\n            elif col in _DIM_COLUMNS:\n                style = \"dim\"\n            table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS))\n\n        for row in rows:\n            cells: list[Text | str] = []\n            for col in columns:\n                val = str(row.get(col, \"-\"))\n                if col in _STATUS_COLUMNS:\n                    cells.append(_style_value(col, val))\n                else:\n                    cells.append(val)\n            table.add_row(*cells)\n        self.console.print(table)\n\n\n# ------------------------------------------------------------------\n# Helpers\n# ------------------------------------------------------------------\n\n\ndef _model_to_dict(model: BaseModel) -> dict[str, Any]:\n    return model.model_dump(mode=\"json\")\n"
  },
  {
    "path": "cli/src/opensandbox_cli/utils.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Shared CLI utilities: duration parsing, error handling, key-value parsing.\"\"\"\n\nfrom __future__ import annotations\n\nimport functools\nimport re\nimport sys\nfrom datetime import timedelta\n\nimport click\n\n\n# ---------------------------------------------------------------------------\n# Duration parsing  (e.g. \"10m\", \"1h30m\", \"90s\", \"2h\")\n# ---------------------------------------------------------------------------\n\n_DURATION_RE = re.compile(\n    r\"^(?:(?P<hours>\\d+)h)?(?:(?P<minutes>\\d+)m)?(?:(?P<seconds>\\d+)s)?$\"\n)\n\n\ndef parse_duration(value: str) -> timedelta:\n    \"\"\"Parse a human-friendly duration string into a ``timedelta``.\n\n    Supported formats: ``10m``, ``1h30m``, ``90s``, ``2h``, ``1h30m45s``.\n    A plain integer is treated as seconds.\n    \"\"\"\n    value = value.strip()\n    if not value:\n        raise click.BadParameter(\"Duration cannot be empty\")\n\n    # Plain integer → seconds\n    if value.isdigit():\n        return timedelta(seconds=int(value))\n\n    m = _DURATION_RE.match(value)\n    if not m or not m.group(0):\n        raise click.BadParameter(\n            f\"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.\"\n        )\n\n    hours = int(m.group(\"hours\") or 0)\n    minutes = int(m.group(\"minutes\") or 0)\n    seconds = int(m.group(\"seconds\") or 0)\n    return timedelta(hours=hours, minutes=minutes, seconds=seconds)\n\n\nclass DurationType(click.ParamType):\n    \"\"\"Click parameter type for duration strings.\"\"\"\n\n    name = \"duration\"\n\n    def convert(\n        self, value: str, param: click.Parameter | None, ctx: click.Context | None\n    ) -> timedelta:\n        if isinstance(value, timedelta):\n            return value\n        try:\n            return parse_duration(value)\n        except click.BadParameter:\n            self.fail(\n                f\"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.\",\n                param,\n                ctx,\n            )\n\n\nDURATION = DurationType()\n\n\n# ---------------------------------------------------------------------------\n# Key=Value parsing  (e.g. --env FOO=bar)\n# ---------------------------------------------------------------------------\n\n\nclass KeyValueType(click.ParamType):\n    \"\"\"Click parameter type that parses ``KEY=VALUE`` strings into a tuple.\"\"\"\n\n    name = \"KEY=VALUE\"\n\n    def convert(\n        self, value: str, param: click.Parameter | None, ctx: click.Context | None\n    ) -> tuple[str, str]:\n        if isinstance(value, tuple):\n            return value\n        if \"=\" not in value:\n            self.fail(f\"Expected KEY=VALUE format, got '{value}'\", param, ctx)\n        key, _, val = value.partition(\"=\")\n        return (key, val)\n\n\nKEY_VALUE = KeyValueType()\n\n\n# ---------------------------------------------------------------------------\n# Error handling decorator\n# ---------------------------------------------------------------------------\n\n\ndef handle_errors(fn):  # type: ignore[no-untyped-def]\n    \"\"\"Decorator that catches SDK / HTTP exceptions and prints a friendly message.\"\"\"\n\n    @functools.wraps(fn)\n    def wrapper(*args, **kwargs):  # type: ignore[no-untyped-def]\n        try:\n            return fn(*args, **kwargs)\n        except click.exceptions.Exit:\n            raise\n        except click.ClickException:\n            raise\n        except Exception as exc:\n            # Import here to avoid circular imports at module level\n            from opensandbox.exceptions import SandboxException\n\n            # Try to get the OutputFormatter from the Click context\n            ctx = click.get_current_context(silent=True)\n            obj = getattr(ctx, \"obj\", None) if ctx else None\n            output = getattr(obj, \"output\", None) if obj else None\n\n            if output and hasattr(output, \"error_panel\"):\n                if isinstance(exc, SandboxException):\n                    output.error_panel(str(exc), title=\"Sandbox Error\")\n                else:\n                    output.error_panel(\n                        f\"{str(exc)}\\n\\n[dim]Type: {type(exc).__qualname__}[/]\",\n                        title=type(exc).__name__,\n                    )\n            else:\n                click.secho(f\"Error: {exc}\", fg=\"red\", err=True)\n            sys.exit(1)\n\n    return wrapper\n"
  },
  {
    "path": "cli/tests/__init__.py",
    "content": ""
  },
  {
    "path": "cli/tests/conftest.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Shared test fixtures.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock\n\nimport pytest\nfrom click.testing import CliRunner\n\nfrom opensandbox_cli.output import OutputFormatter\n\n\n@pytest.fixture()\ndef runner() -> CliRunner:\n    return CliRunner()\n\n\n@pytest.fixture()\ndef mock_manager() -> MagicMock:\n    return MagicMock()\n\n\n@pytest.fixture()\ndef mock_sandbox() -> MagicMock:\n    return MagicMock()\n\n\n@pytest.fixture()\ndef mock_client_context(mock_manager: MagicMock, mock_sandbox: MagicMock) -> MagicMock:\n    \"\"\"A mock ClientContext that avoids real SDK/HTTP calls.\"\"\"\n    ctx = MagicMock()\n    ctx.resolved_config = {\n        \"api_key\": \"test-key\",\n        \"domain\": \"localhost:8080\",\n        \"protocol\": \"http\",\n        \"request_timeout\": 30,\n        \"output_format\": \"json\",\n        \"color\": False,\n        \"default_image\": None,\n        \"default_timeout\": None,\n    }\n    ctx.output = OutputFormatter(\"json\", color=False)\n    ctx.get_manager.return_value = mock_manager\n    ctx.connect_sandbox.return_value = mock_sandbox\n    ctx.connection_config = MagicMock()\n    ctx.close = MagicMock()\n    return ctx\n"
  },
  {
    "path": "cli/tests/test_cli_help.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests that all CLI commands register correctly and --help exits cleanly.\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\nfrom click.testing import CliRunner\n\nfrom opensandbox_cli.main import cli\n\n\n@pytest.fixture()\ndef runner() -> CliRunner:\n    return CliRunner()\n\n\n# ---------------------------------------------------------------------------\n# Root\n# ---------------------------------------------------------------------------\n\n\nclass TestRootCLI:\n    def test_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"--help\"])\n        assert result.exit_code == 0\n        assert \"OpenSandbox CLI\" in result.output\n\n    def test_version(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"--version\"])\n        assert result.exit_code == 0\n        assert \"opensandbox\" in result.output\n\n    def test_root_lists_commands(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"--help\"])\n        for cmd in (\"sandbox\", \"command\", \"exec\", \"file\", \"code\", \"config\"):\n            assert cmd in result.output\n\n\n# ---------------------------------------------------------------------------\n# Sandbox sub-commands\n# ---------------------------------------------------------------------------\n\n\nclass TestSandboxHelp:\n    def test_sandbox_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"sandbox\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"create\", \"list\", \"get\", \"kill\", \"pause\", \"resume\", \"renew\", \"endpoint\", \"health\", \"metrics\"):\n            assert subcmd in result.output\n\n    @pytest.mark.parametrize(\n        \"subcmd\",\n        [\"create\", \"list\", \"get\", \"kill\", \"pause\", \"resume\", \"renew\", \"endpoint\", \"health\", \"metrics\"],\n    )\n    def test_sandbox_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:\n        result = runner.invoke(cli, [\"sandbox\", subcmd, \"--help\"])\n        assert result.exit_code == 0\n        assert subcmd in result.output.lower() or \"usage\" in result.output.lower()\n\n\n# ---------------------------------------------------------------------------\n# Command sub-commands\n# ---------------------------------------------------------------------------\n\n\nclass TestCommandHelp:\n    def test_command_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"command\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"run\", \"status\", \"logs\", \"interrupt\"):\n            assert subcmd in result.output\n\n    @pytest.mark.parametrize(\"subcmd\", [\"run\", \"status\", \"logs\", \"interrupt\"])\n    def test_command_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:\n        result = runner.invoke(cli, [\"command\", subcmd, \"--help\"])\n        assert result.exit_code == 0\n\n\n# ---------------------------------------------------------------------------\n# exec (top-level shortcut)\n# ---------------------------------------------------------------------------\n\n\nclass TestExecHelp:\n    def test_exec_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"exec\", \"--help\"])\n        assert result.exit_code == 0\n        assert \"shortcut\" in result.output.lower() or \"command\" in result.output.lower()\n\n\n# ---------------------------------------------------------------------------\n# File sub-commands\n# ---------------------------------------------------------------------------\n\n\nclass TestFileHelp:\n    def test_file_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"file\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"cat\", \"write\", \"upload\", \"download\", \"rm\", \"mv\", \"mkdir\", \"rmdir\", \"search\", \"info\", \"chmod\", \"replace\"):\n            assert subcmd in result.output\n\n    @pytest.mark.parametrize(\n        \"subcmd\",\n        [\"cat\", \"write\", \"upload\", \"download\", \"rm\", \"mv\", \"mkdir\", \"rmdir\", \"search\", \"info\", \"chmod\", \"replace\"],\n    )\n    def test_file_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:\n        result = runner.invoke(cli, [\"file\", subcmd, \"--help\"])\n        assert result.exit_code == 0\n\n\n# ---------------------------------------------------------------------------\n# Code sub-commands\n# ---------------------------------------------------------------------------\n\n\nclass TestCodeHelp:\n    def test_code_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"code\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"run\", \"context\", \"interrupt\"):\n            assert subcmd in result.output\n\n    def test_code_context_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"code\", \"context\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"create\", \"list\", \"delete\", \"delete-all\"):\n            assert subcmd in result.output\n\n\n# ---------------------------------------------------------------------------\n# Config sub-commands\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigHelp:\n    def test_config_help(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"config\", \"--help\"])\n        assert result.exit_code == 0\n        for subcmd in (\"init\", \"show\", \"set\"):\n            assert subcmd in result.output\n\n    @pytest.mark.parametrize(\"subcmd\", [\"init\", \"show\", \"set\"])\n    def test_config_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:\n        result = runner.invoke(cli, [\"config\", subcmd, \"--help\"])\n        assert result.exit_code == 0\n"
  },
  {
    "path": "cli/tests/test_commands.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for CLI commands with mocked SDK calls.\n\nStrategy: patch ``opensandbox_cli.main.ClientContext`` and ``resolve_config``\nso the root ``cli`` callback creates our mock instead of a real SDK client.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom click.testing import CliRunner\n\nfrom opensandbox_cli.main import cli\nfrom opensandbox_cli.output import OutputFormatter\n\n\n@pytest.fixture()\ndef runner() -> CliRunner:\n    return CliRunner()\n\n\ndef _build_mock_client_context(\n    *,\n    manager: MagicMock | None = None,\n    sandbox: MagicMock | None = None,\n    output_format: str = \"json\",\n) -> MagicMock:\n    ctx = MagicMock()\n    ctx.resolved_config = {\n        \"api_key\": \"test-key\",\n        \"domain\": \"localhost:8080\",\n        \"protocol\": \"http\",\n        \"request_timeout\": 30,\n        \"output_format\": output_format,\n        \"color\": False,\n        \"default_image\": None,\n        \"default_timeout\": None,\n    }\n    ctx.output = OutputFormatter(output_format, color=False)\n    ctx.get_manager.return_value = manager or MagicMock()\n    ctx.connect_sandbox.return_value = sandbox or MagicMock()\n    ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix  # passthrough\n    ctx.connection_config = MagicMock()\n    ctx.close = MagicMock()\n    return ctx\n\n\ndef _invoke(\n    runner: CliRunner,\n    args: list[str],\n    *,\n    manager: MagicMock | None = None,\n    sandbox: MagicMock | None = None,\n    output_format: str = \"json\",\n) -> object:\n    \"\"\"Invoke CLI with mocked ClientContext.\"\"\"\n    mock_ctx = _build_mock_client_context(\n        manager=manager, sandbox=sandbox, output_format=output_format\n    )\n\n    with patch(\"opensandbox_cli.main.resolve_config\") as mock_resolve, \\\n         patch(\"opensandbox_cli.main.ClientContext\", return_value=mock_ctx), \\\n         patch(\"opensandbox_cli.main.OutputFormatter\", side_effect=lambda fmt, **kw: OutputFormatter(fmt, **kw)):\n        mock_resolve.return_value = mock_ctx.resolved_config\n        result = runner.invoke(cli, args, catch_exceptions=False)\n    return result\n\n\n# ---------------------------------------------------------------------------\n# Config commands (no SDK mocking needed)\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigInit:\n    def test_init_creates_file(self, runner: CliRunner, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        result = runner.invoke(cli, [\"config\", \"init\", \"--path\", str(cfg_path)])\n        assert result.exit_code == 0\n        assert \"Config file created\" in result.output\n\n    def test_init_refuses_overwrite(self, runner: CliRunner, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        cfg_path.write_text(\"existing\")\n        result = runner.invoke(cli, [\"config\", \"init\", \"--path\", str(cfg_path)])\n        assert \"already exists\" in result.output\n\n    def test_init_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        cfg_path.write_text(\"old\")\n        result = runner.invoke(cli, [\"config\", \"init\", \"--path\", str(cfg_path), \"--force\"])\n        assert result.exit_code == 0\n        assert \"Config file created\" in result.output\n\n\nclass TestConfigShow:\n    def test_show_json_output(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"-o\", \"json\", \"config\", \"show\"])\n        assert result.exit_code == 0\n        data = json.loads(result.output)\n        assert \"api_key\" in data\n\n    def test_show_table_output(self, runner: CliRunner) -> None:\n        result = runner.invoke(cli, [\"config\", \"show\"])\n        assert result.exit_code == 0\n        assert \"api_key\" in result.output\n\n\nclass TestConfigSet:\n    def test_set_updates_existing_field(self, runner: CliRunner, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        runner.invoke(cli, [\"config\", \"init\", \"--path\", str(cfg_path)])\n        result = runner.invoke(cli, [\"config\", \"set\", \"connection.domain\", \"new.host\", \"--path\", str(cfg_path)])\n        assert result.exit_code == 0\n        assert \"Set connection.domain = new.host\" in result.output\n\n    def test_set_rejects_flat_key(self, runner: CliRunner, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        cfg_path.write_text(\"[connection]\\n\")\n        result = runner.invoke(cli, [\"config\", \"set\", \"flat_key\", \"value\", \"--path\", str(cfg_path)])\n        assert \"section.field\" in result.output\n\n\n# ---------------------------------------------------------------------------\n# Sandbox commands\n# ---------------------------------------------------------------------------\n\n\nclass TestSandboxList:\n    def test_list_invokes_manager(self, runner: CliRunner) -> None:\n        mock_mgr = MagicMock()\n        mock_result = MagicMock()\n        mock_result.sandbox_infos = []\n        mock_mgr.list_sandbox_infos.return_value = mock_result\n\n        result = _invoke(runner, [\"-o\", \"json\", \"sandbox\", \"list\"], manager=mock_mgr)\n        assert result.exit_code == 0\n        mock_mgr.list_sandbox_infos.assert_called_once()\n\n\nclass TestSandboxKill:\n    def test_kill_multiple(self, runner: CliRunner) -> None:\n        mock_mgr = MagicMock()\n        result = _invoke(runner, [\"sandbox\", \"kill\", \"id1\", \"id2\"], manager=mock_mgr)\n        assert result.exit_code == 0\n        assert mock_mgr.kill_sandbox.call_count == 2\n        assert \"Sandbox terminated: id1\" in result.output\n        assert \"Sandbox terminated: id2\" in result.output\n\n\nclass TestSandboxPause:\n    def test_pause_calls_manager(self, runner: CliRunner) -> None:\n        mock_mgr = MagicMock()\n        result = _invoke(runner, [\"sandbox\", \"pause\", \"sb-123\"], manager=mock_mgr)\n        assert result.exit_code == 0\n        mock_mgr.pause_sandbox.assert_called_once_with(\"sb-123\")\n        assert \"Sandbox paused: sb-123\" in result.output\n\n\nclass TestSandboxResume:\n    def test_resume_calls_manager(self, runner: CliRunner) -> None:\n        mock_mgr = MagicMock()\n        result = _invoke(runner, [\"sandbox\", \"resume\", \"sb-123\"], manager=mock_mgr)\n        assert result.exit_code == 0\n        mock_mgr.resume_sandbox.assert_called_once_with(\"sb-123\")\n        assert \"Sandbox resumed: sb-123\" in result.output\n\n\n# ---------------------------------------------------------------------------\n# File commands\n# ---------------------------------------------------------------------------\n\n\nclass TestFileCat:\n    def test_cat_outputs_content(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        mock_sb.files.read_file.return_value = \"hello world\"\n        result = _invoke(runner, [\"file\", \"cat\", \"sb-1\", \"/etc/hostname\"], sandbox=mock_sb)\n        assert result.exit_code == 0\n        assert \"hello world\" in result.output\n        mock_sb.files.read_file.assert_called_once_with(\"/etc/hostname\", encoding=\"utf-8\")\n\n\nclass TestFileWrite:\n    def test_write_with_content_flag(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner,\n            [\"file\", \"write\", \"sb-1\", \"/tmp/test.txt\", \"-c\", \"content here\"],\n            sandbox=mock_sb,\n        )\n        assert result.exit_code == 0\n        assert \"Written\" in result.output\n        mock_sb.files.write_file.assert_called_once()\n\n\nclass TestFileRm:\n    def test_rm_deletes_files(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner, [\"file\", \"rm\", \"sb-1\", \"/tmp/a\", \"/tmp/b\"], sandbox=mock_sb\n        )\n        assert result.exit_code == 0\n        mock_sb.files.delete_files.assert_called_once_with([\"/tmp/a\", \"/tmp/b\"])\n\n\nclass TestFileMv:\n    def test_mv_moves_file(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner, [\"file\", \"mv\", \"sb-1\", \"/tmp/old\", \"/tmp/new\"], sandbox=mock_sb\n        )\n        assert result.exit_code == 0\n        assert \"Moved: /tmp/old\" in result.output and \"/tmp/new\" in result.output\n\n\nclass TestFileMkdir:\n    def test_mkdir_creates_dirs(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner, [\"file\", \"mkdir\", \"sb-1\", \"/tmp/dir1\", \"/tmp/dir2\"], sandbox=mock_sb\n        )\n        assert result.exit_code == 0\n        assert \"Created: /tmp/dir1\" in result.output\n        assert \"Created: /tmp/dir2\" in result.output\n\n\nclass TestFileRmdir:\n    def test_rmdir_removes_dirs(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner, [\"file\", \"rmdir\", \"sb-1\", \"/workspace/old\"], sandbox=mock_sb\n        )\n        assert result.exit_code == 0\n        assert \"Removed: /workspace/old\" in result.output\n\n\n# ---------------------------------------------------------------------------\n# Command execution\n# ---------------------------------------------------------------------------\n\n\nclass TestCommandRun:\n    def test_background_run(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        mock_execution = MagicMock()\n        mock_execution.id = \"exec-123\"\n        mock_sb.commands.run.return_value = mock_execution\n\n        result = _invoke(\n            runner,\n            [\"-o\", \"json\", \"command\", \"run\", \"sb-1\", \"-d\", \"echo\", \"hello\"],\n            sandbox=mock_sb,\n        )\n        assert result.exit_code == 0\n        data = json.loads(result.output)\n        assert data[\"execution_id\"] == \"exec-123\"\n        assert data[\"mode\"] == \"background\"\n\n\nclass TestExecShortcut:\n    def test_exec_passes_to_run(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        mock_execution = MagicMock()\n        mock_execution.id = \"exec-456\"\n        mock_sb.commands.run.return_value = mock_execution\n\n        result = _invoke(\n            runner,\n            [\"-o\", \"json\", \"exec\", \"sb-1\", \"-d\", \"--\", \"ls\", \"-la\"],\n            sandbox=mock_sb,\n        )\n        assert result.exit_code == 0\n        mock_sb.commands.run.assert_called_once()\n\n\nclass TestCommandInterrupt:\n    def test_interrupt_calls_sdk(self, runner: CliRunner) -> None:\n        mock_sb = MagicMock()\n        result = _invoke(\n            runner, [\"command\", \"interrupt\", \"sb-1\", \"exec-789\"], sandbox=mock_sb\n        )\n        assert result.exit_code == 0\n        mock_sb.commands.interrupt.assert_called_once_with(\"exec-789\")\n        assert \"Interrupted: exec-789\" in result.output\n"
  },
  {
    "path": "cli/tests/test_config.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for opensandbox_cli.config — config loading and priority merging.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\n\nimport pytest\n\nfrom opensandbox_cli.config import (\n    DEFAULT_CONFIG_TEMPLATE,\n    init_config_file,\n    load_config_file,\n    resolve_config,\n)\n\n\n# ---------------------------------------------------------------------------\n# load_config_file\n# ---------------------------------------------------------------------------\n\n\nclass TestLoadConfigFile:\n    def test_returns_empty_when_file_missing(self, tmp_path: Path) -> None:\n        result = load_config_file(tmp_path / \"nonexistent.toml\")\n        assert result == {}\n\n    def test_parses_toml_file(self, tmp_path: Path) -> None:\n        cfg = tmp_path / \"config.toml\"\n        cfg.write_text(\n            '[connection]\\napi_key = \"abc\"\\ndomain = \"example.com\"\\n'\n        )\n        result = load_config_file(cfg)\n        assert result[\"connection\"][\"api_key\"] == \"abc\"\n        assert result[\"connection\"][\"domain\"] == \"example.com\"\n\n    def test_parses_all_sections(self, tmp_path: Path) -> None:\n        cfg = tmp_path / \"config.toml\"\n        cfg.write_text(\n            '[connection]\\napi_key = \"k\"\\n\\n'\n            '[output]\\nformat = \"json\"\\ncolor = false\\n\\n'\n            '[defaults]\\nimage = \"alpine\"\\ntimeout = \"5m\"\\n'\n        )\n        result = load_config_file(cfg)\n        assert result[\"output\"][\"format\"] == \"json\"\n        assert result[\"output\"][\"color\"] is False\n        assert result[\"defaults\"][\"image\"] == \"alpine\"\n        assert result[\"defaults\"][\"timeout\"] == \"5m\"\n\n\n# ---------------------------------------------------------------------------\n# resolve_config — priority: CLI > env > file > defaults\n# ---------------------------------------------------------------------------\n\n\nclass TestResolveConfig:\n    def test_defaults_when_nothing_configured(self, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"empty.toml\"\n        cfg_path.write_text(\"\")\n        result = resolve_config(config_path=cfg_path)\n        assert result[\"api_key\"] is None\n        assert result[\"domain\"] is None\n        assert result[\"protocol\"] == \"http\"\n        assert result[\"request_timeout\"] == 30\n        assert result[\"output_format\"] == \"table\"\n        assert result[\"color\"] is True\n\n    def test_file_values_override_defaults(self, tmp_path: Path) -> None:\n        cfg = tmp_path / \"config.toml\"\n        cfg.write_text(\n            '[connection]\\napi_key = \"file-key\"\\ndomain = \"file.host\"\\n'\n            'protocol = \"https\"\\nrequest_timeout = 60\\n\\n'\n            '[output]\\nformat = \"json\"\\ncolor = false\\n\\n'\n            '[defaults]\\nimage = \"node:20\"\\ntimeout = \"15m\"\\n'\n        )\n        result = resolve_config(config_path=cfg)\n        assert result[\"api_key\"] == \"file-key\"\n        assert result[\"domain\"] == \"file.host\"\n        assert result[\"protocol\"] == \"https\"\n        assert result[\"request_timeout\"] == 60\n        assert result[\"output_format\"] == \"json\"\n        assert result[\"color\"] is False\n        assert result[\"default_image\"] == \"node:20\"\n        assert result[\"default_timeout\"] == \"15m\"\n\n    def test_env_overrides_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        cfg = tmp_path / \"config.toml\"\n        cfg.write_text('[connection]\\napi_key = \"file-key\"\\ndomain = \"file.host\"\\n')\n\n        monkeypatch.setenv(\"OPEN_SANDBOX_API_KEY\", \"env-key\")\n        monkeypatch.setenv(\"OPEN_SANDBOX_DOMAIN\", \"env.host\")\n        monkeypatch.setenv(\"OPEN_SANDBOX_PROTOCOL\", \"https\")\n        monkeypatch.setenv(\"OPEN_SANDBOX_REQUEST_TIMEOUT\", \"120\")\n        monkeypatch.setenv(\"OPEN_SANDBOX_OUTPUT\", \"yaml\")\n\n        result = resolve_config(config_path=cfg)\n        assert result[\"api_key\"] == \"env-key\"\n        assert result[\"domain\"] == \"env.host\"\n        assert result[\"protocol\"] == \"https\"\n        assert result[\"request_timeout\"] == 120\n        assert result[\"output_format\"] == \"yaml\"\n\n    def test_cli_overrides_everything(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        cfg = tmp_path / \"config.toml\"\n        cfg.write_text('[connection]\\napi_key = \"file-key\"\\n')\n        monkeypatch.setenv(\"OPEN_SANDBOX_API_KEY\", \"env-key\")\n\n        result = resolve_config(\n            cli_api_key=\"cli-key\",\n            cli_domain=\"cli.host\",\n            cli_protocol=\"https\",\n            cli_timeout=999,\n            cli_output=\"yaml\",\n            config_path=cfg,\n        )\n        assert result[\"api_key\"] == \"cli-key\"\n        assert result[\"domain\"] == \"cli.host\"\n        assert result[\"protocol\"] == \"https\"\n        assert result[\"request_timeout\"] == 999\n        assert result[\"output_format\"] == \"yaml\"\n\n    def test_invalid_timeout_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:\n        cfg = tmp_path / \"empty.toml\"\n        cfg.write_text(\"\")\n        monkeypatch.setenv(\"OPEN_SANDBOX_REQUEST_TIMEOUT\", \"not-a-number\")\n        result = resolve_config(config_path=cfg)\n        # Falls through to default 30\n        assert result[\"request_timeout\"] == 30\n\n\n# ---------------------------------------------------------------------------\n# init_config_file\n# ---------------------------------------------------------------------------\n\n\nclass TestInitConfigFile:\n    def test_creates_default_config(self, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \".opensandbox\" / \"config.toml\"\n        result = init_config_file(cfg_path)\n        assert result == cfg_path\n        assert cfg_path.exists()\n        content = cfg_path.read_text()\n        assert \"[connection]\" in content\n        assert \"[output]\" in content\n        assert \"[defaults]\" in content\n\n    def test_refuses_overwrite_without_force(self, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        cfg_path.write_text(\"existing\")\n        with pytest.raises(FileExistsError, match=\"already exists\"):\n            init_config_file(cfg_path)\n\n    def test_force_overwrites(self, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"config.toml\"\n        cfg_path.write_text(\"old content\")\n        init_config_file(cfg_path, force=True)\n        assert cfg_path.read_text() == DEFAULT_CONFIG_TEMPLATE\n\n    def test_creates_parent_directories(self, tmp_path: Path) -> None:\n        cfg_path = tmp_path / \"a\" / \"b\" / \"c\" / \"config.toml\"\n        init_config_file(cfg_path)\n        assert cfg_path.exists()\n"
  },
  {
    "path": "cli/tests/test_output.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for opensandbox_cli.output — table, JSON, YAML rendering.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nimport pytest\nfrom pydantic import BaseModel\n\nfrom opensandbox_cli.output import OutputFormatter\n\n\n# ---------------------------------------------------------------------------\n# Test models\n# ---------------------------------------------------------------------------\n\n\nclass FakeItem(BaseModel):\n    id: str\n    name: str\n    score: int\n\n\n# ---------------------------------------------------------------------------\n# JSON output\n# ---------------------------------------------------------------------------\n\n\nclass TestJsonOutput:\n    def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"json\", color=False)\n        fmt.print_dict({\"key\": \"value\", \"num\": 42})\n        captured = capsys.readouterr()\n        data = json.loads(captured.out)\n        assert data == {\"key\": \"value\", \"num\": 42}\n\n    def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"json\", color=False)\n        item = FakeItem(id=\"abc\", name=\"test\", score=100)\n        fmt.print_model(item)\n        captured = capsys.readouterr()\n        data = json.loads(captured.out)\n        assert data[\"id\"] == \"abc\"\n        assert data[\"name\"] == \"test\"\n        assert data[\"score\"] == 100\n\n    def test_print_models(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"json\", color=False)\n        items = [\n            FakeItem(id=\"1\", name=\"a\", score=10),\n            FakeItem(id=\"2\", name=\"b\", score=20),\n        ]\n        fmt.print_models(items, columns=[\"id\", \"name\", \"score\"])\n        captured = capsys.readouterr()\n        data = json.loads(captured.out)\n        assert len(data) == 2\n        assert data[0][\"id\"] == \"1\"\n        assert data[1][\"name\"] == \"b\"\n\n\n# ---------------------------------------------------------------------------\n# YAML output\n# ---------------------------------------------------------------------------\n\n\nclass TestYamlOutput:\n    def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"yaml\", color=False)\n        fmt.print_dict({\"key\": \"value\"})\n        captured = capsys.readouterr()\n        assert \"key: value\" in captured.out\n\n    def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"yaml\", color=False)\n        item = FakeItem(id=\"x\", name=\"y\", score=5)\n        fmt.print_model(item)\n        captured = capsys.readouterr()\n        assert \"id: x\" in captured.out\n        assert \"name: y\" in captured.out\n        assert \"score: 5\" in captured.out\n\n\n# ---------------------------------------------------------------------------\n# Table output\n# ---------------------------------------------------------------------------\n\n\nclass TestTableOutput:\n    def test_print_dict_contains_values(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"table\", color=False)\n        fmt.print_dict({\"host\": \"example.com\", \"port\": 8080}, title=\"Config\")\n        captured = capsys.readouterr()\n        assert \"example.com\" in captured.out\n        assert \"8080\" in captured.out\n        assert \"Config\" in captured.out\n\n    def test_print_dict_none_renders_dash(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"table\", color=False)\n        fmt.print_dict({\"key\": None})\n        captured = capsys.readouterr()\n        assert \"-\" in captured.out\n\n    def test_print_models_shows_headers(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"table\", color=False)\n        items = [FakeItem(id=\"1\", name=\"a\", score=10)]\n        fmt.print_models(items, columns=[\"id\", \"name\", \"score\"], title=\"Items\")\n        captured = capsys.readouterr()\n        assert \"ID\" in captured.out\n        assert \"NAME\" in captured.out\n        assert \"SCORE\" in captured.out\n\n    def test_print_text_ignores_format(self, capsys: pytest.CaptureFixture[str]) -> None:\n        fmt = OutputFormatter(\"json\", color=False)\n        fmt.print_text(\"hello world\")\n        captured = capsys.readouterr()\n        assert captured.out.strip() == \"hello world\"\n"
  },
  {
    "path": "cli/tests/test_resolve_id.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for Docker-style sandbox ID prefix matching.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock\n\nimport click\nimport pytest\n\nfrom opensandbox_cli.client import ClientContext\nfrom opensandbox_cli.output import OutputFormatter\n\n\ndef _make_sandbox_info(sandbox_id: str) -> MagicMock:\n    \"\"\"Create a mock SandboxInfo with given ID.\"\"\"\n    info = MagicMock()\n    info.id = sandbox_id\n    return info\n\n\ndef _make_paged_result(\n    sandbox_ids: list[str], *, has_next_page: bool = False\n) -> MagicMock:\n    \"\"\"Create a mock PagedSandboxInfos with pagination metadata.\"\"\"\n    result = MagicMock()\n    result.sandbox_infos = [_make_sandbox_info(sid) for sid in sandbox_ids]\n    result.pagination = MagicMock()\n    result.pagination.has_next_page = has_next_page\n    return result\n\n\ndef _make_client_context(\n    sandbox_ids: list[str],\n    *,\n    pages: list[list[str]] | None = None,\n) -> ClientContext:\n    \"\"\"Create a ClientContext with a mocked manager listing the given IDs.\n\n    If *pages* is provided, each element is a separate page of sandbox IDs\n    (useful for testing pagination).  Otherwise all IDs are in a single page.\n    \"\"\"\n    ctx = ClientContext(\n        resolved_config={\n            \"api_key\": \"test-key\",\n            \"domain\": \"localhost:8080\",\n            \"protocol\": \"http\",\n            \"request_timeout\": 30,\n            \"output_format\": \"json\",\n            \"color\": False,\n            \"default_image\": None,\n            \"default_timeout\": None,\n        },\n        output=OutputFormatter(\"json\", color=False),\n    )\n    # Mock the manager\n    mock_mgr = MagicMock()\n    if pages is not None:\n        side_effects = []\n        for i, page_ids in enumerate(pages):\n            has_next = i < len(pages) - 1\n            side_effects.append(_make_paged_result(page_ids, has_next_page=has_next))\n        mock_mgr.list_sandbox_infos.side_effect = side_effects\n    else:\n        mock_mgr.list_sandbox_infos.return_value = _make_paged_result(sandbox_ids)\n    ctx._manager = mock_mgr\n    return ctx\n\n\nclass TestResolveSandboxId:\n    \"\"\"Test Docker-style prefix matching for sandbox IDs.\"\"\"\n\n    def test_full_uuid_skips_listing(self) -> None:\n        \"\"\"A full UUID is returned directly without calling list.\"\"\"\n        ctx = _make_client_context([])\n        full_id = \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"\n        assert ctx.resolve_sandbox_id(full_id) == full_id\n        # Manager should NOT have been called\n        ctx._manager.list_sandbox_infos.assert_not_called()\n\n    def test_unique_prefix_resolves(self) -> None:\n        \"\"\"A unique prefix returns the full matching ID.\"\"\"\n        ctx = _make_client_context([\n            \"abc123-def456-7890-abcd-000000000001\",\n            \"xyz789-def456-7890-abcd-000000000002\",\n        ])\n        result = ctx.resolve_sandbox_id(\"abc\")\n        assert result == \"abc123-def456-7890-abcd-000000000001\"\n\n    def test_exact_match_among_multiple(self) -> None:\n        \"\"\"A prefix that uniquely matches one sandbox works.\"\"\"\n        ctx = _make_client_context([\n            \"sandbox-alpha-001\",\n            \"sandbox-beta-002\",\n            \"sandbox-gamma-003\",\n        ])\n        result = ctx.resolve_sandbox_id(\"sandbox-a\")\n        assert result == \"sandbox-alpha-001\"\n\n    def test_ambiguous_prefix_raises(self) -> None:\n        \"\"\"Multiple matches raises ClickException with helpful message.\"\"\"\n        ctx = _make_client_context([\n            \"abc-111\",\n            \"abc-222\",\n            \"abc-333\",\n        ])\n        with pytest.raises(click.ClickException, match=\"Ambiguous ID prefix\"):\n            ctx.resolve_sandbox_id(\"abc\")\n\n    def test_ambiguous_error_shows_ids(self) -> None:\n        \"\"\"The ambiguous error lists the conflicting IDs.\"\"\"\n        ctx = _make_client_context([\"abc-111\", \"abc-222\"])\n        with pytest.raises(click.ClickException) as exc_info:\n            ctx.resolve_sandbox_id(\"abc\")\n        assert \"abc-111\" in str(exc_info.value)\n        assert \"abc-222\" in str(exc_info.value)\n\n    def test_no_match_raises(self) -> None:\n        \"\"\"No matches raises ClickException.\"\"\"\n        ctx = _make_client_context([\"xyz-001\", \"xyz-002\"])\n        with pytest.raises(click.ClickException, match=\"No sandbox found\"):\n            ctx.resolve_sandbox_id(\"abc\")\n\n    def test_empty_sandbox_list_raises(self) -> None:\n        \"\"\"Empty sandbox list raises ClickException.\"\"\"\n        ctx = _make_client_context([])\n        with pytest.raises(click.ClickException, match=\"No sandbox found\"):\n            ctx.resolve_sandbox_id(\"abc\")\n\n    def test_single_char_prefix(self) -> None:\n        \"\"\"Even a single character can match if unique.\"\"\"\n        ctx = _make_client_context([\n            \"a-sandbox-001\",\n            \"b-sandbox-002\",\n        ])\n        result = ctx.resolve_sandbox_id(\"a\")\n        assert result == \"a-sandbox-001\"\n\n    def test_full_id_matches_exactly(self) -> None:\n        \"\"\"A non-UUID full ID still matches via prefix logic.\"\"\"\n        ctx = _make_client_context([\"my-sandbox-123\"])\n        result = ctx.resolve_sandbox_id(\"my-sandbox-123\")\n        assert result == \"my-sandbox-123\"\n\n    def test_more_than_five_ambiguous_shows_ellipsis(self) -> None:\n        \"\"\"When >5 matches, the error shows '...'.\"\"\"\n        ids = [f\"sb-{i:03d}\" for i in range(10)]\n        ctx = _make_client_context(ids)\n        with pytest.raises(click.ClickException) as exc_info:\n            ctx.resolve_sandbox_id(\"sb-\")\n        assert \"...\" in str(exc_info.value)\n        assert \"10 sandboxes\" in str(exc_info.value)\n\n    # -- Pagination tests --\n\n    def test_match_on_second_page(self) -> None:\n        \"\"\"A prefix that only appears on page 2 is still found.\"\"\"\n        ctx = _make_client_context(\n            [],\n            pages=[\n                [\"xyz-001\", \"xyz-002\"],\n                [\"abc-999\"],\n            ],\n        )\n        result = ctx.resolve_sandbox_id(\"abc\")\n        assert result == \"abc-999\"\n\n    def test_collision_across_pages(self) -> None:\n        \"\"\"Matches on different pages are detected as ambiguous.\"\"\"\n        ctx = _make_client_context(\n            [],\n            pages=[\n                [\"abc-001\"],\n                [\"abc-002\"],\n            ],\n        )\n        with pytest.raises(click.ClickException, match=\"Ambiguous ID prefix\"):\n            ctx.resolve_sandbox_id(\"abc\")\n\n    def test_no_match_across_all_pages(self) -> None:\n        \"\"\"No match after exhausting all pages raises ClickException.\"\"\"\n        ctx = _make_client_context(\n            [],\n            pages=[\n                [\"xyz-001\"],\n                [\"xyz-002\"],\n            ],\n        )\n        with pytest.raises(click.ClickException, match=\"No sandbox found\"):\n            ctx.resolve_sandbox_id(\"abc\")\n"
  },
  {
    "path": "cli/tests/test_utils.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for opensandbox_cli.utils — duration parsing, key-value type, error handling.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import timedelta\n\nimport click\nimport pytest\n\nfrom opensandbox_cli.utils import DURATION, KEY_VALUE, parse_duration\n\n\n# ---------------------------------------------------------------------------\n# parse_duration\n# ---------------------------------------------------------------------------\n\n\nclass TestParseDuration:\n    @pytest.mark.parametrize(\n        \"input_str, expected\",\n        [\n            (\"10\", timedelta(seconds=10)),\n            (\"0\", timedelta(seconds=0)),\n            (\"10s\", timedelta(seconds=10)),\n            (\"5m\", timedelta(minutes=5)),\n            (\"2h\", timedelta(hours=2)),\n            (\"1h30m\", timedelta(hours=1, minutes=30)),\n            (\"1h30m45s\", timedelta(hours=1, minutes=30, seconds=45)),\n            (\"90s\", timedelta(seconds=90)),\n        ],\n    )\n    def test_valid_durations(self, input_str: str, expected: timedelta) -> None:\n        assert parse_duration(input_str) == expected\n\n    @pytest.mark.parametrize(\n        \"input_str\",\n        [\n            \"\",\n            \"abc\",\n            \"10x\",\n            \"m10\",\n            \"-5m\",\n        ],\n    )\n    def test_invalid_durations(self, input_str: str) -> None:\n        with pytest.raises(click.BadParameter):\n            parse_duration(input_str)\n\n    def test_strips_whitespace(self) -> None:\n        assert parse_duration(\"  10m  \") == timedelta(minutes=10)\n\n\n# ---------------------------------------------------------------------------\n# DurationType (Click param type)\n# ---------------------------------------------------------------------------\n\n\nclass TestDurationType:\n    def test_converts_string(self) -> None:\n        result = DURATION.convert(\"5m\", None, None)\n        assert result == timedelta(minutes=5)\n\n    def test_passes_through_timedelta(self) -> None:\n        td = timedelta(hours=1)\n        result = DURATION.convert(td, None, None)  # type: ignore[arg-type]\n        assert result is td\n\n    def test_invalid_raises_bad_parameter(self) -> None:\n        with pytest.raises(click.exceptions.BadParameter):\n            DURATION.convert(\"invalid\", None, None)\n\n\n# ---------------------------------------------------------------------------\n# KeyValueType (Click param type)\n# ---------------------------------------------------------------------------\n\n\nclass TestKeyValueType:\n    def test_parses_simple_kv(self) -> None:\n        assert KEY_VALUE.convert(\"FOO=bar\", None, None) == (\"FOO\", \"bar\")\n\n    def test_value_can_contain_equals(self) -> None:\n        assert KEY_VALUE.convert(\"key=a=b=c\", None, None) == (\"key\", \"a=b=c\")\n\n    def test_empty_value(self) -> None:\n        assert KEY_VALUE.convert(\"key=\", None, None) == (\"key\", \"\")\n\n    def test_missing_equals_fails(self) -> None:\n        with pytest.raises(click.exceptions.BadParameter):\n            KEY_VALUE.convert(\"no-equals\", None, None)\n\n    def test_passes_through_tuple(self) -> None:\n        t = (\"key\", \"val\")\n        result = KEY_VALUE.convert(t, None, None)  # type: ignore[arg-type]\n        assert result is t\n"
  },
  {
    "path": "components/egress/Dockerfile",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM golang:1.24-bookworm AS builder\n\nWORKDIR /workspace\n\nARG VERSION=dev\nARG GIT_COMMIT=unknown\nARG BUILD_TIME=unknown\n\n# Copy only go mod/sum first for better caching\nCOPY components/egress/go.mod components/egress/go.sum ./components/egress/\n# Bring internal module so replace ../internal works during download/build\nCOPY components/internal ./components/internal\n\nWORKDIR /workspace/components/egress\n\n# Static-ish build (no cgo) to simplify runtime deps\nENV CGO_ENABLED=0\nRUN go mod download\n\n# Copy the rest of the egress sources\nCOPY components/egress ./\nRUN CGO_ENABLED=0 go build \\\n    -ldflags \"-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'\" \\\n    -o /out/egress .\n\nFROM debian:bookworm-slim\n\n# iptables is needed for DNS REDIRECT; ca-certificates for TLS to upstream resolvers\nRUN apt-get update \\\n    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\\n        iptables \\\n        iproute2 \\\n        nftables \\\n        ca-certificates \\\n        sudo \\\n        curl \\\n        wget \\\n        net-tools \\\n        dnsutils \\\n        netcat-openbsd \\\n        iputils-ping \\\n        traceroute \\\n        telnet \\\n        tcpdump \\\n        nmap \\\n        htop \\\n        procps \\\n        strace \\\n        lsof \\\n    && rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /out/egress /egress\n\n# Default entrypoint; expects OPENSANDBOX_NETWORK_POLICY env at runtime.\nENTRYPOINT [\"/egress\"]"
  },
  {
    "path": "components/egress/README.md",
    "content": "# OpenSandbox Egress Sidecar\n\nThe **Egress Sidecar** is a core component of OpenSandbox that provides **FQDN-based egress control**. It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies.\n\n## Features\n\n- **FQDN-based Allowlist**: Control outbound traffic by domain name (e.g., `api.github.com`).\n- **Wildcard Support**: Allow subdomains using wildcards (e.g., `*.pypi.org`).\n- **Transparent Interception**: Uses transparent DNS proxying; no application configuration required.\n- **Dynamic DNS (dns+nft mode)**: When a domain is allowed and the proxy resolves it, the resolved A/AAAA IPs are added to nftables with TTL so that default-deny + domain-allow is enforced at the network layer.\n- **Privilege Isolation**: Requires `CAP_NET_ADMIN` only for the sidecar; the application container runs unprivileged.\n- **Graceful Degradation**: If `CAP_NET_ADMIN` is missing, it warns and disables enforcement instead of crashing.\n\n## Architecture\n\nThe egress control is implemented as a **Sidecar** that shares the network namespace with the sandbox application.\n\n1.  **DNS Proxy (Layer 1)**:\n    - Runs on `127.0.0.1:15353`.\n    - `iptables` rules redirect all port 53 (DNS) traffic to this proxy.\n    - Filters queries based on the allowlist.\n    - Returns `NXDOMAIN` for denied domains.\n\n2.  **Network Filter (Layer 2)** (when `OPENSANDBOX_EGRESS_MODE=dns+nft`):\n    - Uses `nftables` to enforce IP-level allow/deny. Resolved IPs for allowed domains are added to dynamic allow sets with TTL (dynamic DNS).\n    - At startup, the sidecar whitelists **127.0.0.1** (redirect target for the proxy) and **nameserver IPs** from `/etc/resolv.conf` so DNS resolution and proxy upstream work (including private DNS). Nameserver count is capped and invalid IPs are filtered; see [Configuration](#configuration).\n\n## Requirements\n\n- **Runtime**: Docker or Kubernetes.\n- **Capabilities**: `CAP_NET_ADMIN` (for the sidecar container only).\n- **Kernel**: Linux kernel with `iptables` support.\n\n## Configuration\n\n- Policy bootstrap & runtime:\n  - Default deny-all. Seed initial policy via `OPENSANDBOX_EGRESS_RULES` (JSON, same shape as `/policy`); empty/`{}`/`null` stays deny-all.\n  - `/policy` at runtime; empty body resets to default deny-all.\n- HTTP service:\n  - Listen address: `OPENSANDBOX_EGRESS_HTTP_ADDR` (default `:18080`).\n  - Auth: `OPENSANDBOX_EGRESS_TOKEN` with header `OPENSANDBOX-EGRESS-AUTH: <token>`; if unset, endpoint is open.\n- Mode (`OPENSANDBOX_EGRESS_MODE`, default `dns`):\n  - `dns`: DNS proxy only, no nftables (IP/CIDR rules have no effect at L2).\n  - `dns+nft`: enable nftables; if nft apply fails, fallback to `dns`. IP/CIDR enforcement and DoH/DoT blocking require this mode.\n- **Nameserver exempt**  \n  Set `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` to a comma-separated list of **nameserver IPs** (e.g. `26.26.26.26` or `26.26.26.26,100.100.2.116`). Only single IPs are supported; CIDR entries are ignored. Traffic to these IPs on port 53 is not redirected to the proxy (iptables RETURN). In `dns+nft` mode, these IPs are also merged into the nft allow set so proxy upstream traffic to them (sent without SO_MARK) is accepted. Use when the upstream is reachable only via a specific route (e.g. tunnel) and SO_MARK would send proxy traffic elsewhere.\n- **DNS and nft mode (nameserver whitelist)**  \n  In `dns+nft` mode, the sidecar automatically allows:\n  - **127.0.0.1** — so packets redirected by iptables to the proxy (127.0.0.1:15353) are accepted by nft.\n  - **Nameserver IPs** from `/etc/resolv.conf` — so client DNS and proxy upstream work (e.g. private DNS).  \n  Nameserver IPs are validated (unspecified and loopback are skipped) and capped. Use `OPENSANDBOX_EGRESS_MAX_NS` (default `3`; `0` = no cap, `1`–`10` = cap). See [SECURITY-RISKS.md](SECURITY-RISKS.md) for trust and scope of this whitelist.\n- **Blocked hostname webhook**  \n  - `OPENSANDBOX_EGRESS_DENY_WEBHOOK`: HTTP endpoint URL. When set, egress asynchronously POSTs JSON **only when a hostname is denied**: `{\"hostname\": \"<original query>\", \"timestamp\": \"<RFC3339>\", \"source\": \"opensandbox-egress\", \"sandboxId\": \"<id-or-empty>\"}`. Default timeout 5s, up to 3 retries with exponential backoff starting at 1s; 4xx is not retried, 5xx/network errors are retried.\n  - `OPENSANDBOX_EGRESS_SANDBOX_ID`: optional sandbox identifier injected into the webhook payload as `sandboxId`. The value is read once at startup (unset → empty string).\n  - **Allow requirement**: you must allow the webhook host (or its IP/CIDR) in the policy; with default deny, if you don’t explicitly allow it, the webhook traffic will be blocked by egress itself. Example: `{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"webhook.example.com\"}]}`. If a broader deny CIDR covers the resolved IP, it will still be blocked—adjust your policy accordingly.\n- DoH/DoT blocking:\n  - DoT (tcp/udp 853) blocked by default.\n  - Optional DoH over 443: `OPENSANDBOX_EGRESS_BLOCK_DOH_443=true`. If enabled without blocklist, all 443 is dropped.\n  - DoH blocklist (IP/CIDR, comma-separated): `OPENSANDBOX_EGRESS_DOH_BLOCKLIST=\"9.9.9.9,1.1.1.1/32,2001:db8::/32\"`.\n\n### Runtime HTTP API\n\n- Default listen address: `:18080` (override with `OPENSANDBOX_EGRESS_HTTP_ADDR`).\n- Endpoints:\n- `GET /policy` — returns the current policy.\n- `POST /policy` — replaces the policy. Empty/whitespace/`{}`/`null` resets to default deny-all.\n  - `PATCH /policy` — merge/append rules at runtime. Body **must** be a JSON array of egress rules (not wrapped in an object). New rules are placed before existing ones (same target overrides), so a later PATCH can override prior wildcard denies with a more specific allow, and vice versa.\n\nExamples:\n\n- DNS allowlist (default deny):\n  ```bash\n  curl -XPOST http://127.0.0.1:18080/policy \\\n    -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"*.bing.com\"}]}'\n  ```\n- DNS blocklist (default allow):\n  ```bash\n  curl -XPOST http://127.0.0.1:18080/policy \\\n    -d '{\"defaultAction\":\"allow\",\"egress\":[{\"action\":\"deny\",\"target\":\"*.bing.com\"}]}'\n  ```\n- IP/CIDR only:\n  ```bash\n  curl -XPOST http://127.0.0.1:18080/policy \\\n    -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"1.1.1.1\"},{\"action\":\"deny\",\"target\":\"10.0.0.0/8\"}]}'\n  ```\n- Mixed DNS + IP/CIDR:\n  ```bash\n  curl -XPOST http://127.0.0.1:18080/policy \\\n    -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"*.example.com\"},{\"action\":\"allow\",\"target\":\"203.0.113.0/24\"},{\"action\":\"deny\",\"target\":\"*.bad.com\"}]}'\n  ```\n- Merge-only PATCH (override wildcard deny with a specific allow):\n  ```bash\n  # baseline: deny *.cloudflare.com\n  curl -XPOST http://127.0.0.1:18080/policy \\\n    -d '{\"defaultAction\":\"allow\",\"egress\":[{\"action\":\"deny\",\"target\":\"*.cloudflare.com\"}]}'\n\n  # allow a specific host; PATCH rules are prepended, so this wins\n  curl -XPATCH http://127.0.0.1:18080/policy \\\n    -d '[{\"action\":\"allow\",\"target\":\"www.cloudflare.com\"}]'\n  ```\n\n## Build & Run\n\n### 1. Build Docker Image\n\n```bash\n# Build locally\ndocker build -t opensandbox/egress:local .\n\n# Or use the build script (multi-arch)\n./build.sh\n```\n\n### 2. Run Locally (Docker)\n\nTo test the sidecar with a sandbox application:\n\n1.  **Start the Sidecar** (creates the network namespace):\n\n    ```bash\n    docker run -d --name sandbox-egress \\\n      --cap-add=NET_ADMIN \\\n      opensandbox/egress:local\n    ```\n\n    *Note: `CAP_NET_ADMIN` is required for `iptables` redirection.*\n\n    After start, push policy via HTTP (empty body resets to deny-all):\n\n    ```bash\n    curl -XPOST http://11.167.84.130:18080/policy \\\n      -H \"OPENSANDBOX-EGRESS-AUTH: $OPENSANDBOX_EGRESS_TOKEN\" \\\n      -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"*.bing.com\"}]}'\n    ```\n\n2.  **Start Application** (shares sidecar's network):\n\n    ```bash\n    docker run --rm -it \\\n      --network container:sandbox-egress \\\n      curlimages/curl \\\n      sh\n    ```\n\n3.  **Verify**:\n\n    Inside the application container:\n\n    ```bash\n    # Allowed domain\n    curl -I https://google.com  # Should succeed\n\n    # Denied domain\n    curl -I https://github.com  # Should fail (resolve error)\n    ```\n\n## Development\n\n- **Language**: Go 1.24+\n- **Key Packages**:\n    - `pkg/dnsproxy`: DNS server and policy matching logic.\n    - `pkg/iptables`: `iptables` rule management.\n    - `pkg/nftables`: nftables static/dynamic rules and DNS-resolved IP sets.\n    - `pkg/policy`: Policy parsing and definition.\n- **Main (egress)**:\n    - `nameserver.go`: Builds the list of IPs to whitelist for DNS in nft mode (127.0.0.1 + validated/capped nameservers from resolv.conf).\n\n```bash\n# Run tests\ngo test ./...\n```\n\n### E2E benchmark: dns vs dns+nft (sync dynamic IP write)\n\nAn end-to-end benchmark compares **dns** (pass-through, no nft write) and **dns+nft** (sync `AddResolvedIPs` before each DNS reply) under real conditions: sidecar in Docker, iptables redirect, real DNS + HTTPS from a client container.\n\n```bash\n./tests/bench-dns-nft.sh\n```\n\nMore details in [docs/benchmark.md](docs/benchmark.md).\n\n## Troubleshooting\n\n- **\"iptables setup failed\"**: Ensure the sidecar container has `--cap-add=NET_ADMIN`.\n- **DNS resolution fails for all domains**:  \n  Check upstream reachability from the sidecar (`ip route`, `dig @<upstream> . NS +timeout=3`). In `dns+nft` mode, check logs for `[dns] whitelisting proxy listen + N nameserver(s)`.\n- **Traffic not blocked**: If nftables apply fails, the sidecar falls back to dns; check logs, `nft list table inet opensandbox`, and `CAP_NET_ADMIN`.\n"
  },
  {
    "path": "components/egress/TODO.md",
    "content": "# Egress Sidecar TODO (Linux MVP → Full OSEP-0001)\n\n- Layer 2 still partial: static IP/CIDR now pushed to nftables, DoH/DoT blocking added (853 + optional 443 blocklist). DNS-learned IPs/dynamic isolation planned (see Short-term priorities).\n- Policy surface: IP/CIDR parsing/validation done; `require_full_isolation` and richer validation messages are out of scope (see No goals).\n- Observability missing: no violation logs.\n- Capability probing missing: no CAP_NET_ADMIN/nftables detection; hostNetwork 已由 server 侧阻断。 Capability detection + mode exposure moved to No goals.\n- Platform integration completed: specs/SDK/server wiring done; NET_ADMIN only on sidecar.\n- No IPv6; startup ordering not enforced (relies on container start order).\n\n## Short-term priorities (suggested order)\n1) Layer 2 via nftables  \n   - Tune DoH/DoT rules (ordering, allow-list exceptions, counters).\n4) Observability & logging  \n   - Violation logs (domain/action/upstream IP); expose current enforcement mode.  \n   - Optional lightweight health/status endpoint.\n6) Security hardening  \n   - Whitelist/validate upstream DNS to avoid arbitrary 53 egress abuse.  \n   - Document bypass/limits (dns-only can be bypassed via direct IP/DoH).\n7) IPv6 & tests  \n   - Handle IPv6 support or explicit non-support.  \n   - Unit/integration tests: interception, graceful degrade, nftables, DoH blocking, hostNetwork rejection.\n\n## No goals (explicitly excluded)\n- Capability probing & mode exposure (CAP_NET_ADMIN/nft detection, mode surfacing).\n- Policy expansion: `require_full_isolation` and richer validation errors.\n\n## Dev notes\n- Current behavior: default deny-all baseline even when no policy is provided; POST /policy empty resets to deny-all; env bootstrap defaults to deny-all.  \n- DNS proxy always runs; SO_MARK=0x1 bypass for proxy’s own upstream DNS; iptables only redirects port 53, no other DROP rules.  \n- nftables: static IP/CIDR applied on start and policy update; retry without delete-table if table absent; failures fall back to DNS-only.  \n- Runtime deps: Linux, `CAP_NET_ADMIN`, `iptables`/`nft` binaries; upstream DNS must be reachable and recursive.\n\n"
  },
  {
    "path": "components/egress/build.sh",
    "content": "#!/bin/bash\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\nVERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo \"dev\")}\nGIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo \"unknown\")}\nBUILD_TIME=${BUILD_TIME:-$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")}\nREPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath \"$(dirname \"$0\")/../..\")\ncd \"${REPO_ROOT}\"\n\ndocker buildx rm egress-builder || true\ndocker buildx create --use --name egress-builder\ndocker buildx inspect --bootstrap\ndocker buildx ls\n\nLATEST_TAGS=()\nif [[ \"${TAG}\" == v* ]]; then\n  LATEST_TAGS+=(-t opensandbox/egress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest)\nfi\n\ndocker buildx build \\\n  -t opensandbox/egress:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:${TAG} \\\n  \"${LATEST_TAGS[@]}\" \\\n  -f components/egress/Dockerfile \\\n  --build-arg VERSION=\"${VERSION}\" \\\n  --build-arg GIT_COMMIT=\"${GIT_COMMIT}\" \\\n  --build-arg BUILD_TIME=\"${BUILD_TIME}\" \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "components/egress/docs/benchmark.md",
    "content": "# Egress Benchmark\n\nThis document describes the **Egress Sidecar** end-to-end benchmark: it compares **dns** and **dns+nft** modes under real conditions for latency and throughput.\n\n## Purpose\n\n- **dns**: DNS proxy only (pass-through), no nftables writes; used as the baseline.\n- **dns+nft**: DNS proxy plus synchronous `AddResolvedIPs` before each DNS reply, writing resolved IPs into nftables for\n  L2 egress enforcement.\n\nThe benchmark runs the same workload in both modes and reports end-to-end latency (P50, P99) and throughput (Req/s) to\nmeasure the overhead of the synchronous nft write path.\n\n## Environment and Flow\n\n- **Environment**: The Egress sidecar runs in a Docker container on the host. The container includes the sidecar (DNS\n  proxy and optional nft), iptables redirect of port 53 to the proxy, and the policy server on port 18080. The workload\n  runs **inside the same container**: DNS and HTTPS traffic go through the proxy.\n- **Flow** (per phase):\n    1. Start the sidecar with the chosen mode (`dns` or `dns+nft`).\n    2. Wait for health checks, then push the allow list to `/policy` (see domain list below).\n    3. Write the domain list into the container as `/tmp/bench-domains.txt` (one `https://<domain>` per line).\n    4. **Warm-up**: One request to each of the first 10 domains (10 concurrent), 1 round.\n    5. **Timed run**: One request per domain for all domains (N concurrent per round), for 10 rounds; each request\n       records `time_namelookup` and `time_total`.\n    6. Copy results from the container and compute P50, P99, average latency, and Req/s.\n- **Execution order**: **dns+nft** runs first, then **dns**; the comparison table is printed at the end.\n\n## Workload\n\n- **Domain list**: Read from `components/egress/tests/hostname.txt`, one domain per line (lines starting with `#` and\n  empty lines are ignored). Default is about 100 resolvable domains.\n- **Rounds and concurrency**: The script uses `ROUNDS=10`. Each round issues one HTTPS request per domain in\n  `hostname.txt`, with all requests in that round concurrent; 10 rounds total.\n- **Total requests**: `TOTAL_REQUESTS = ROUNDS × NUM_DOMAINS` (e.g. 10 × 100 = 1000).\n- **Per request**: Inside the container, `curl -o /dev/null -s -w \"%{time_namelookup}\\t%{time_total}\\n\"` is used against\n  `https://<domain>`, with a 10s timeout per request; the whole benchmark run has a 300s wall-clock timeout.\n\n## Policy\n\n- Policy is default-deny with explicit allow rules: one `{\"action\":\"allow\",\"target\":\"<domain>\"}` per domain in\n  `hostname.txt` is sent via `POST /policy`, so every domain used in the benchmark is allowed.\n\n## How to Run\n\n**Script**: `components/egress/tests/bench-e2e-dns-nft.sh`\n\n**Requirements**: Docker and `curl` on the host (for pushing policy); the Egress image includes `curl` for the workload.\n\n**Commands** (from repo root or from `components/egress`):\n\n```bash\n./tests/bench-dns-nft.sh\n```\n\nThe script resolves `tests/hostname.txt` relative to its own path, so the working directory does not need to be changed.\n\n## Configuration\n\n| Item                | Location / variable                    | Default / notes                                |\n|---------------------|----------------------------------------|------------------------------------------------|\n| Domain list         | `components/egress/tests/hostname.txt` | One domain per line; `#` comments allowed      |\n| Rounds              | `ROUNDS` in script                     | 10                                             |\n| Per-request timeout | `CURL_TIMEOUT` in script               | 10 seconds                                     |\n| Benchmark timeout   | `BENCH_EXEC_TIMEOUT` in script         | 300 seconds (max wall time for the timed run)  |\n| Image               | `IMG` in script                        | See script; override for a locally built image |\n\nChanging the number of domains or rounds updates the total request count; the report shows “N rounds × M domains” for\nthe current config.\n\n## Output and Metrics\n\n- **Terminal**: A table with **Req/s**, **Avg(s)**, **P50(s)**, **P99(s)** for both modes, plus short notes (dns vs\n  dns+nft, warm-up, first-resolution cost).\n- **Artifacts** (on the host under `/tmp`): `bench-e2e-dns-total.txt`, `bench-e2e-dns+nft-total.txt` (one\n  `time_total` per line), and `-namelookup.txt`, `-wall.txt`, etc., for further analysis or plotting.\n\n## Notes\n\n- The first resolution of a domain in dns+nft triggers a DNS lookup and an nft write, so cost is higher; later requests\n  for the same domain hit the set and are cheaper. The multi-round, multi-domain design mixes cold and warm resolution.\n- In CI (e.g. GitHub Actions), the script wraps the timed-run `docker exec` with `timeout` inside the shell function so\n  `timeout` runs a real command, not a function name, avoiding “No such file or directory” errors.\n"
  },
  {
    "path": "components/egress/go.mod",
    "content": "module github.com/alibaba/opensandbox/egress\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/alibaba/opensandbox/internal v0.0.0\n\tgithub.com/miekg/dns v1.1.61\n\tgithub.com/stretchr/testify v1.11.1\n\tgolang.org/x/sys v0.31.0\n)\n\nrequire (\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgo.uber.org/multierr v1.10.0 // indirect\n\tgo.uber.org/zap v1.27.0 // indirect\n\tgolang.org/x/mod v0.18.0 // indirect\n\tgolang.org/x/net v0.38.0 // indirect\n\tgolang.org/x/sync v0.7.0 // indirect\n\tgolang.org/x/tools v0.22.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n\nreplace github.com/alibaba/opensandbox/internal => ../internal\n"
  },
  {
    "path": "components/egress/go.sum",
    "content": "github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=\ngithub.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=\ngo.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngolang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=\ngolang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=\ngolang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=\ngolang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=\ngolang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "components/egress/main.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"net/netip\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/dnsproxy\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/events\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/iptables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/alibaba/opensandbox/internal/version\"\n)\n\nfunc main() {\n\tversion.EchoVersion(\"OpenSandbox Egress\")\n\n\tctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)\n\tdefer cancel()\n\n\tctx = withLogger(ctx)\n\tdefer log.Logger.Sync()\n\n\tinitialRules, err := dnsproxy.LoadPolicyFromEnvVar(constants.EnvEgressRules)\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to parse %s: %v\", constants.EnvEgressRules, err)\n\t}\n\n\tallowIPs := AllowIPsForNft(\"/etc/resolv.conf\")\n\t// Merge nameserver exempt IPs into nft allow set so proxy traffic to them (no SO_MARK) is allowed in dns+nft mode.\n\tfor _, addr := range dnsproxy.ParseNameserverExemptList() {\n\t\tif !containsAddr(allowIPs, addr) {\n\t\t\tallowIPs = append(allowIPs, addr)\n\t\t}\n\t}\n\n\tmode := parseMode()\n\tlog.Infof(\"enforcement mode: %s\", mode)\n\tnftMgr := createNftManager(mode)\n\tproxy, err := dnsproxy.New(initialRules, \"\")\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to init dns proxy: %v\", err)\n\t}\n\tif err := proxy.Start(ctx); err != nil {\n\t\tlog.Fatalf(\"failed to start dns proxy: %v\", err)\n\t}\n\tlog.Infof(\"dns proxy started on 127.0.0.1:15353\")\n\n\tif blockWebhookURL := strings.TrimSpace(os.Getenv(constants.EnvBlockedWebhook)); blockWebhookURL != \"\" {\n\t\tblockedBroadcaster := events.NewBroadcaster(ctx, events.BroadcasterConfig{QueueSize: 256})\n\t\tblockedBroadcaster.AddSubscriber(events.NewWebhookSubscriber(blockWebhookURL))\n\t\tproxy.SetBlockedBroadcaster(blockedBroadcaster)\n\t\tdefer blockedBroadcaster.Close()\n\t\tlog.Infof(\"denied hostname webhook enabled\")\n\t}\n\n\texemptDst := dnsproxy.ParseNameserverExemptList()\n\tif len(exemptDst) > 0 {\n\t\tlog.Infof(\"nameserver exempt list: %v (proxy upstream in this list will not set SO_MARK)\", exemptDst)\n\t}\n\tif err := iptables.SetupRedirect(15353, exemptDst); err != nil {\n\t\tlog.Fatalf(\"failed to install iptables redirect: %v\", err)\n\t}\n\tlog.Infof(\"iptables redirect configured (OUTPUT 53 -> 15353) with SO_MARK bypass for proxy upstream traffic\")\n\n\tsetupNft(ctx, nftMgr, initialRules, proxy, allowIPs)\n\n\t// start policy server\n\thttpAddr := envOrDefault(constants.EnvEgressHTTPAddr, constants.DefaultEgressServerAddr)\n\tif err = startPolicyServer(ctx, proxy, nftMgr, mode, httpAddr, os.Getenv(constants.EnvEgressToken), allowIPs); err != nil {\n\t\tlog.Fatalf(\"failed to start policy server: %v\", err)\n\t}\n\tlog.Infof(\"policy server listening on %s (POST /policy)\", httpAddr)\n\n\t<-ctx.Done()\n\tlog.Infof(\"received shutdown signal; exiting\")\n\t_ = os.Stderr.Sync()\n}\n\nfunc withLogger(ctx context.Context) context.Context {\n\tlevel := envOrDefault(constants.EnvEgressLogLevel, \"info\")\n\tlogger := slogger.MustNew(slogger.Config{Level: level}).Named(\"opensandbox.egress\")\n\treturn log.WithLogger(ctx, logger)\n}\n\nfunc envOrDefault(key, defaultVal string) string {\n\tif v := strings.TrimSpace(os.Getenv(key)); v != \"\" {\n\t\treturn v\n\t}\n\treturn defaultVal\n}\n\nfunc isTruthy(v string) bool {\n\tswitch strings.ToLower(strings.TrimSpace(v)) {\n\tcase \"1\", \"true\", \"yes\", \"y\", \"on\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc containsAddr(addrs []netip.Addr, a netip.Addr) bool {\n\tfor _, x := range addrs {\n\t\tif x == a {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc parseMode() string {\n\tmode := strings.ToLower(strings.TrimSpace(os.Getenv(constants.EnvEgressMode)))\n\tswitch mode {\n\tcase \"\", constants.PolicyDnsOnly:\n\t\treturn constants.PolicyDnsOnly\n\tcase constants.PolicyDnsNft:\n\t\treturn constants.PolicyDnsNft\n\tdefault:\n\t\tlog.Warnf(\"invalid %s=%s, falling back to dns\", constants.EnvEgressMode, mode)\n\t\treturn constants.PolicyDnsOnly\n\t}\n}\n"
  },
  {
    "path": "components/egress/nameserver.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"net/netip\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/dnsproxy\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n)\n\n// AllowIPsForNft returns the list of IPs to merge into the nft allow set for DNS in dns+nft mode:\n// 127.0.0.1 (proxy listen / iptables redirect target) plus validated, capped nameserver IPs from resolvPath.\n// Validation: skips unspecified (0.0.0.0, ::) and loopback (127.x, ::1).\n// Cap: at most max nameservers (default 3; set EGRESS_MAX_NAMESERVERS=0 for no cap, or 1–10).\nfunc AllowIPsForNft(resolvPath string) []netip.Addr {\n\traw, _ := dnsproxy.ResolvNameserverIPs(resolvPath)\n\tmaxNsCount := maxNameserversFromEnv()\n\n\tvar validated []netip.Addr\n\tfor _, ip := range raw {\n\t\tif maxNsCount > 0 && len(validated) >= maxNsCount {\n\t\t\tbreak\n\t\t}\n\t\tif !isValidNameserverIP(ip) {\n\t\t\tcontinue\n\t\t}\n\t\tvalidated = append(validated, ip)\n\t}\n\n\t// 127.0.0.1 first so packets redirected to proxy are accepted by nft.\n\tout := make([]netip.Addr, 0, 1+len(validated))\n\tout = append(out, netip.MustParseAddr(\"127.0.0.1\"))\n\tout = append(out, validated...)\n\n\tif len(out) > 1 {\n\t\tlog.Infof(\"[dns] whitelisting proxy listen + %d nameserver(s) for nft: %v\", len(validated), formatIPs(out))\n\t} else {\n\t\tlog.Infof(\"[dns] whitelisting proxy listen (127.0.0.1); no valid nameserver IPs from %s\", resolvPath)\n\t}\n\treturn out\n}\n\nfunc maxNameserversFromEnv() int {\n\ts := os.Getenv(constants.EnvMaxNameservers)\n\tif s == \"\" {\n\t\treturn constants.DefaultMaxNameservers\n\t}\n\tn, err := strconv.Atoi(s)\n\tif err != nil || n < 0 {\n\t\treturn constants.DefaultMaxNameservers\n\t}\n\tif n > 10 {\n\t\treturn 10\n\t}\n\t// 0 = no cap\n\treturn n\n}\n\nfunc isValidNameserverIP(ip netip.Addr) bool {\n\tif ip.IsUnspecified() {\n\t\treturn false\n\t}\n\tif ip.IsLoopback() {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc formatIPs(ips []netip.Addr) []string {\n\tout := make([]string, len(ips))\n\tfor i, ip := range ips {\n\t\tout[i] = ip.String()\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "components/egress/nameserver_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"net/netip\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestAllowIPsForNft_EmptyResolv(t *testing.T) {\n\tdir := t.TempDir()\n\tresolv := filepath.Join(dir, \"resolv.conf\")\n\trequire.NoError(t, os.WriteFile(resolv, []byte(\"# empty\\n\"), 0644))\n\tips := AllowIPsForNft(resolv)\n\trequire.Len(t, ips, 1, \"expected 1 IP (127.0.0.1)\")\n\trequire.Equal(t, netip.MustParseAddr(\"127.0.0.1\"), ips[0])\n}\n\nfunc TestAllowIPsForNft_ValidNameservers(t *testing.T) {\n\tdir := t.TempDir()\n\tresolv := filepath.Join(dir, \"resolv.conf\")\n\t// Standard resolv.conf with two nameservers\n\tcontent := \"nameserver 192.168.65.7\\nnameserver 10.0.0.1\\n\"\n\trequire.NoError(t, os.WriteFile(resolv, []byte(content), 0644))\n\tips := AllowIPsForNft(resolv)\n\trequire.Len(t, ips, 3, \"expected 3 IPs (127.0.0.1 + 2 nameservers)\")\n\trequire.Equal(t, netip.MustParseAddr(\"127.0.0.1\"), ips[0], \"expected first 127.0.0.1\")\n\trequire.Equal(t, netip.MustParseAddr(\"192.168.65.7\"), ips[1], \"expected 192.168.65.7\")\n\trequire.Equal(t, netip.MustParseAddr(\"10.0.0.1\"), ips[2], \"expected 10.0.0.1\")\n}\n\nfunc TestAllowIPsForNft_FiltersInvalid(t *testing.T) {\n\tdir := t.TempDir()\n\tresolv := filepath.Join(dir, \"resolv.conf\")\n\t// 0.0.0.0 and 127.0.0.11 should be filtered; 192.168.1.1 kept\n\tcontent := \"nameserver 0.0.0.0\\nnameserver 192.168.1.1\\nnameserver 127.0.0.11\\n\"\n\trequire.NoError(t, os.WriteFile(resolv, []byte(content), 0644))\n\tips := AllowIPsForNft(resolv)\n\trequire.Len(t, ips, 2, \"expected 2 IPs (127.0.0.1 + 192.168.1.1)\")\n\trequire.Equal(t, netip.MustParseAddr(\"127.0.0.1\"), ips[0], \"expected first 127.0.0.1\")\n\trequire.Equal(t, netip.MustParseAddr(\"192.168.1.1\"), ips[1], \"expected 192.168.1.1\")\n}\n\nfunc TestAllowIPsForNft_Cap(t *testing.T) {\n\tdir := t.TempDir()\n\tresolv := filepath.Join(dir, \"resolv.conf\")\n\tcontent := \"nameserver 10.0.0.1\\nnameserver 10.0.0.2\\nnameserver 10.0.0.3\\nnameserver 10.0.0.4\\n\"\n\trequire.NoError(t, os.WriteFile(resolv, []byte(content), 0644))\n\told := os.Getenv(constants.EnvMaxNameservers)\n\tdefer os.Setenv(constants.EnvMaxNameservers, old)\n\tos.Setenv(constants.EnvMaxNameservers, \"2\")\n\n\tips := AllowIPsForNft(resolv)\n\t// 127.0.0.1 + 2 nameservers (cap)\n\trequire.Len(t, ips, 3, \"expected 3 IPs (127.0.0.1 + 2 capped)\")\n\trequire.Equal(t, netip.MustParseAddr(\"10.0.0.1\"), ips[1], \"expected first nameserver to be 10.0.0.1\")\n\trequire.Equal(t, netip.MustParseAddr(\"10.0.0.2\"), ips[2], \"expected second nameserver to be 10.0.0.2\")\n}\n\nfunc TestIsValidNameserverIP(t *testing.T) {\n\ttests := []struct {\n\t\tip   string\n\t\twant bool\n\t}{\n\t\t{\"0.0.0.0\", false},\n\t\t{\"::\", false},\n\t\t{\"127.0.0.1\", false},\n\t\t{\"127.0.0.11\", false},\n\t\t{\"::1\", false},\n\t\t{\"192.168.65.7\", true},\n\t\t{\"10.0.0.1\", true},\n\t\t{\"8.8.8.8\", true},\n\t}\n\tfor _, tt := range tests {\n\t\tip := netip.MustParseAddr(tt.ip)\n\t\tgot := isValidNameserverIP(ip)\n\t\tif got != tt.want {\n\t\t\tt.Errorf(\"isValidNameserverIP(%s) = %v, want %v\", tt.ip, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestMaxNameserversFromEnv(t *testing.T) {\n\told := os.Getenv(constants.EnvMaxNameservers)\n\tdefer os.Setenv(constants.EnvMaxNameservers, old)\n\n\tfor _, s := range []string{\"\", \"x\", \"-1\"} {\n\t\tos.Setenv(constants.EnvMaxNameservers, s)\n\t\tif got := maxNameserversFromEnv(); got != constants.DefaultMaxNameservers {\n\t\t\tt.Errorf(\"maxNameserversFromEnv(%q) = %d, want default %d\", s, got, constants.DefaultMaxNameservers)\n\t\t}\n\t}\n\tos.Setenv(constants.EnvMaxNameservers, \"0\")\n\tif got := maxNameserversFromEnv(); got != 0 {\n\t\tt.Errorf(\"maxNameserversFromEnv(0) = %d, want 0\", got)\n\t}\n\tos.Setenv(constants.EnvMaxNameservers, \"5\")\n\tif got := maxNameserversFromEnv(); got != 5 {\n\t\tt.Errorf(\"maxNameserversFromEnv(5) = %d, want 5\", got)\n\t}\n\tos.Setenv(constants.EnvMaxNameservers, \"99\")\n\tif got := maxNameserversFromEnv(); got != 10 {\n\t\tt.Errorf(\"maxNameserversFromEnv(99) = %d, want 10 (capped)\", got)\n\t}\n}\n"
  },
  {
    "path": "components/egress/nft.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"net/netip\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/dnsproxy\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/nftables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n)\n\n// createNftManager returns an nft manager for dns+nft mode, or nil for dns-only.\nfunc createNftManager(mode string) nftApplier {\n\tif mode != constants.PolicyDnsNft {\n\t\treturn nil\n\t}\n\treturn nftables.NewManagerWithOptions(parseNftOptions())\n}\n\n// setupNft applies static policy to nft and wires DNS-resolved IPs into the proxy when nft is enabled.\n// nameserverIPs are merged into the allow set at startup so system DNS works (client + proxy upstream, e.g. private DNS).\nfunc setupNft(ctx context.Context, nftMgr nftApplier, initialPolicy *policy.NetworkPolicy, proxy *dnsproxy.Proxy, nameserverIPs []netip.Addr) {\n\tif nftMgr == nil {\n\t\tlog.Warnf(\"nftables disabled (dns-only mode)\")\n\t\treturn\n\t}\n\tlog.Infof(\"applying nftables static policy (dns+nft mode) with %d nameserver IP(s) merged into allow set\", len(nameserverIPs))\n\tpolicyWithNS := initialPolicy.WithExtraAllowIPs(nameserverIPs)\n\tif err := nftMgr.ApplyStatic(ctx, policyWithNS); err != nil {\n\t\tlog.Fatalf(\"nftables static apply failed: %v\", err)\n\t}\n\tlog.Infof(\"nftables static policy applied (table inet opensandbox); DNS-resolved IPs will be added to dynamic allow sets\")\n\tproxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) {\n\t\tif err := nftMgr.AddResolvedIPs(ctx, ips); err != nil {\n\t\t\tlog.Warnf(\"[dns] add resolved IPs to nft failed for domain %q: %v\", domain, err)\n\t\t}\n\t})\n}\n\nfunc parseNftOptions() nftables.Options {\n\topts := nftables.Options{BlockDoT: true}\n\tif isTruthy(os.Getenv(constants.EnvBlockDoH443)) {\n\t\topts.BlockDoH443 = true\n\t}\n\tif raw := os.Getenv(constants.EnvDoHBlocklist); strings.TrimSpace(raw) != \"\" {\n\t\tparts := strings.Split(raw, \",\")\n\t\tfor _, p := range parts {\n\t\t\ttarget := strings.TrimSpace(p)\n\t\t\tif target == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif addr, err := netip.ParseAddr(target); err == nil {\n\t\t\t\tif addr.Is4() {\n\t\t\t\t\topts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target)\n\t\t\t\t} else if addr.Is6() {\n\t\t\t\t\topts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif prefix, err := netip.ParsePrefix(target); err == nil {\n\t\t\t\tif prefix.Addr().Is4() {\n\t\t\t\t\topts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target)\n\t\t\t\t} else if prefix.Addr().Is6() {\n\t\t\t\t\topts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Warnf(\"ignoring invalid DoH blocklist entry: %s\", target)\n\t\t}\n\t}\n\treturn opts\n}\n"
  },
  {
    "path": "components/egress/pkg/constants/configuration.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constants\n\nconst (\n\tEnvBlockDoH443    = \"OPENSANDBOX_EGRESS_BLOCK_DOH_443\"\n\tEnvDoHBlocklist   = \"OPENSANDBOX_EGRESS_DOH_BLOCKLIST\" // comma-separated IP/CIDR\n\tEnvEgressMode     = \"OPENSANDBOX_EGRESS_MODE\"          // dns | dns+nft\n\tEnvEgressHTTPAddr = \"OPENSANDBOX_EGRESS_HTTP_ADDR\"\n\tEnvEgressToken    = \"OPENSANDBOX_EGRESS_TOKEN\"\n\tEnvEgressRules    = \"OPENSANDBOX_EGRESS_RULES\"\n\tEnvEgressLogLevel = \"OPENSANDBOX_EGRESS_LOG_LEVEL\"\n\tEnvMaxNameservers = \"OPENSANDBOX_EGRESS_MAX_NS\"\n\tEnvBlockedWebhook = \"OPENSANDBOX_EGRESS_DENY_WEBHOOK\"\n\tENVSandboxID      = \"OPENSANDBOX_EGRESS_SANDBOX_ID\"\n\n\t// EnvNameserverExempt comma-separated IPs; proxy upstream to these is not marked and is allowed in nft allow set\n\tEnvNameserverExempt = \"OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT\"\n)\n\nconst (\n\tPolicyDnsOnly = \"dns\"\n\tPolicyDnsNft  = \"dns+nft\"\n)\n\nconst (\n\tDefaultEgressServerAddr = \":18080\"\n\tDefaultMaxNameservers   = 3\n)\n"
  },
  {
    "path": "components/egress/pkg/constants/constants.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage constants\n\nconst (\n\tMarkValue = 0x1\n\tMarkHex   = \"0x1\"\n)\n\nconst (\n\tEgressAuthTokenHeader = \"OPENSANDBOX-EGRESS-AUTH\"\n)\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/exempt.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage dnsproxy\n\nimport (\n\t\"net/netip\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n)\n\nvar (\n\texemptListOnce sync.Once\n\texemptAddrs    []netip.Addr\n\texemptSet      map[netip.Addr]struct{}\n)\n\n// ParseNameserverExemptList returns IPs from OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT (comma-separated).\n// Only single IPs are accepted; invalid or CIDR entries are skipped. Result is cached. Used for nft allow set, iptables, and UpstreamInExemptList.\nfunc ParseNameserverExemptList() []netip.Addr {\n\texemptListOnce.Do(func() { parseNameserverExemptListUncached() })\n\treturn exemptAddrs\n}\n\nfunc parseNameserverExemptListUncached() {\n\traw := strings.TrimSpace(os.Getenv(constants.EnvNameserverExempt))\n\tif raw == \"\" {\n\t\texemptAddrs = nil\n\t\texemptSet = nil\n\t\treturn\n\t}\n\tset := make(map[netip.Addr]struct{})\n\tvar out []netip.Addr\n\tfor _, s := range strings.Split(raw, \",\") {\n\t\ts = strings.TrimSpace(s)\n\t\tif s == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif addr, err := netip.ParseAddr(s); err == nil {\n\t\t\tif _, exists := set[addr]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[addr] = struct{}{}\n\t\t\tout = append(out, addr)\n\t\t}\n\t}\n\texemptAddrs = out\n\texemptSet = set\n}\n\n// UpstreamInExemptList returns true when upstreamHost is in the nameserver exempt list (exact IP match).\n// When true, the proxy should not set SO_MARK so upstream traffic follows normal routing (e.g. via tun).\nfunc UpstreamInExemptList(upstreamHost string) bool {\n\taddr, err := netip.ParseAddr(upstreamHost)\n\tif err != nil {\n\t\treturn false\n\t}\n\tParseNameserverExemptList() // ensure cache is initialized\n\t_, ok := exemptSet[addr]\n\treturn ok\n}\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/exempt_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage dnsproxy\n\nimport (\n\t\"net/netip\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc resetNameserverExemptCache(t *testing.T) {\n\tt.Helper()\n\texemptAddrs = nil\n\texemptSet = nil\n\texemptListOnce = sync.Once{}\n}\n\nfunc TestParseNameserverExemptList_IPOnly(t *testing.T) {\n\tt.Setenv(constants.EnvNameserverExempt, \"1.1.1.1, 2001:db8::1 ,invalid, 10.0.0.0/8, ,\")\n\tresetNameserverExemptCache(t)\n\n\tgot := ParseNameserverExemptList()\n\twant := []netip.Addr{netip.MustParseAddr(\"1.1.1.1\"), netip.MustParseAddr(\"2001:db8::1\")}\n\trequire.Equal(t, want, got, \"ParseNameserverExemptList() mismatch\")\n\n\t// Cached result should stay the same on subsequent calls.\n\trequire.Equal(t, want, ParseNameserverExemptList(), \"cached ParseNameserverExemptList() mismatch\")\n}\n\nfunc TestUpstreamInExemptList_IPOnly(t *testing.T) {\n\tt.Setenv(constants.EnvNameserverExempt, \"1.1.1.1,2001:db8::1\")\n\tresetNameserverExemptCache(t)\n\n\trequire.True(t, UpstreamInExemptList(\"1.1.1.1\"), \"expected IPv4 upstream to be exempt\")\n\trequire.True(t, UpstreamInExemptList(\"2001:db8::1\"), \"expected IPv6 upstream to be exempt\")\n\trequire.False(t, UpstreamInExemptList(\"10.0.0.2\"), \"unexpected exempt match for non-listed IP\")\n\trequire.False(t, UpstreamInExemptList(\"not-an-ip\"), \"invalid IP string should not match\")\n}\n\nfunc TestUpstreamInExemptList_CIDRIgnored(t *testing.T) {\n\tt.Setenv(constants.EnvNameserverExempt, \"10.0.0.0/24\")\n\tresetNameserverExemptCache(t)\n\n\trequire.Empty(t, ParseNameserverExemptList(), \"CIDR should be ignored in exempt list\")\n\trequire.False(t, UpstreamInExemptList(\"10.0.0.5\"), \"CIDR should not make upstream exempt\")\n}\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/proxy.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage dnsproxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/netip\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/miekg/dns\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/events\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/nftables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n)\n\nconst defaultListenAddr = \"127.0.0.1:15353\"\n\ntype Proxy struct {\n\tpolicyMu   sync.RWMutex\n\tpolicy     *policy.NetworkPolicy\n\tlistenAddr string\n\tupstream   string // single upstream for MVP\n\tservers    []*dns.Server\n\n\t// optional; called in goroutine when A/AAAA are present\n\tonResolved func(domain string, ips []nftables.ResolvedIP)\n\n\t// optional broadcaster to notify blocked hostnames\n\tblockedBroadcaster *events.Broadcaster\n}\n\n// New builds a proxy with resolved upstream; listenAddr can be empty for default.\nfunc New(p *policy.NetworkPolicy, listenAddr string) (*Proxy, error) {\n\tif listenAddr == \"\" {\n\t\tlistenAddr = defaultListenAddr\n\t}\n\tif p == nil {\n\t\tp = policy.DefaultDenyPolicy()\n\t}\n\tupstream, err := discoverUpstream()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tproxy := &Proxy{\n\t\tlistenAddr: listenAddr,\n\t\tupstream:   upstream,\n\t\tpolicy:     ensurePolicyDefaults(p),\n\t}\n\treturn proxy, nil\n}\n\nfunc (p *Proxy) Start(ctx context.Context) error {\n\thandler := dns.HandlerFunc(p.serveDNS)\n\n\tudpServer := &dns.Server{Addr: p.listenAddr, Net: \"udp\", Handler: handler}\n\ttcpServer := &dns.Server{Addr: p.listenAddr, Net: \"tcp\", Handler: handler}\n\tp.servers = []*dns.Server{udpServer, tcpServer}\n\n\terrCh := make(chan error, len(p.servers))\n\tfor _, srv := range p.servers {\n\t\ts := srv\n\t\tgo func() {\n\t\t\tif err := s.ListenAndServe(); err != nil {\n\t\t\t\terrCh <- err\n\t\t\t}\n\t\t}()\n\t}\n\n\t// Shutdown on context done\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tfor _, srv := range p.servers {\n\t\t\t_ = srv.Shutdown()\n\t\t}\n\t}()\n\n\tselect {\n\tcase err := <-errCh:\n\t\treturn fmt.Errorf(\"dns proxy failed: %w\", err)\n\tcase <-time.After(200 * time.Millisecond):\n\t\t// small grace window; running fine\n\t\treturn nil\n\t}\n}\n\nfunc (p *Proxy) serveDNS(w dns.ResponseWriter, r *dns.Msg) {\n\tif len(r.Question) == 0 {\n\t\t_ = w.WriteMsg(new(dns.Msg)) // empty response\n\t\treturn\n\t}\n\tq := r.Question[0]\n\tdomain := q.Name\n\n\tp.policyMu.RLock()\n\tcurrentPolicy := p.policy\n\tp.policyMu.RUnlock()\n\tif currentPolicy != nil && currentPolicy.Evaluate(domain) == policy.ActionDeny {\n\t\tp.publishBlocked(domain)\n\t\tresp := new(dns.Msg)\n\t\tresp.SetRcode(r, dns.RcodeNameError)\n\t\t_ = w.WriteMsg(resp)\n\t\treturn\n\t}\n\n\tresp, err := p.forward(r)\n\tif err != nil {\n\t\tlog.Warnf(\"[dns] forward error for %s: %v\", domain, err)\n\t\tfail := new(dns.Msg)\n\t\tfail.SetRcode(r, dns.RcodeServerFailure)\n\t\t_ = w.WriteMsg(fail)\n\t\treturn\n\t}\n\tp.maybeNotifyResolved(domain, resp)\n\t_ = w.WriteMsg(resp)\n}\n\n// maybeNotifyResolved calls onResolved synchronously when resp contains A/AAAA,\n// so that IPs are in nft before the client receives the DNS response and connects.\nfunc (p *Proxy) maybeNotifyResolved(domain string, resp *dns.Msg) {\n\tif p.onResolved == nil {\n\t\treturn\n\t}\n\tips := extractResolvedIPs(resp)\n\tif len(ips) == 0 {\n\t\treturn\n\t}\n\tp.onResolved(domain, ips)\n}\n\nfunc (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) {\n\tc := &dns.Client{\n\t\tTimeout: 5 * time.Second,\n\t\tDialer:  p.dialerWithMark(),\n\t}\n\tresp, _, err := c.Exchange(r, p.upstream)\n\treturn resp, err\n}\n\n// UpstreamHost returns the host part of the upstream resolver, empty on parse error.\nfunc (p *Proxy) UpstreamHost() string {\n\thost, _, err := net.SplitHostPort(p.upstream)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn host\n}\n\n// UpdatePolicy swaps the in-memory policy used by the proxy.\n// Passing nil reverts to the default deny-all policy.\nfunc (p *Proxy) UpdatePolicy(newPolicy *policy.NetworkPolicy) {\n\tp.policyMu.Lock()\n\tp.policy = ensurePolicyDefaults(newPolicy)\n\tp.policyMu.Unlock()\n}\n\n// CurrentPolicy returns the policy currently enforced by the proxy.\nfunc (p *Proxy) CurrentPolicy() *policy.NetworkPolicy {\n\tp.policyMu.RLock()\n\tdefer p.policyMu.RUnlock()\n\treturn p.policy\n}\n\n// SetOnResolved sets the callback invoked when an allowed domain resolves to A/AAAA.\n// Called in a goroutine; pass nil to disable. Only used when L2 dynamic IP is enabled (e.g. dns+nft mode).\nfunc (p *Proxy) SetOnResolved(fn func(domain string, ips []nftables.ResolvedIP)) {\n\tp.onResolved = fn\n}\n\n// SetBlockedBroadcaster wires a broadcaster used to notify blocked hostnames.\nfunc (p *Proxy) SetBlockedBroadcaster(b *events.Broadcaster) {\n\tp.blockedBroadcaster = b\n}\n\nfunc (p *Proxy) publishBlocked(domain string) {\n\tif p.blockedBroadcaster == nil {\n\t\treturn\n\t}\n\tnormalized := strings.ToLower(strings.TrimSuffix(domain, \".\"))\n\tif normalized == \"\" {\n\t\treturn\n\t}\n\n\tp.blockedBroadcaster.Publish(events.BlockedEvent{\n\t\tHostname:  normalized,\n\t\tTimestamp: time.Now().UTC(),\n\t})\n}\n\n// extractResolvedIPs parses A and AAAA records from resp.Answer into ResolvedIP slice.\n//\n// Uses netip.ParseAddr(v.A.String()) which allocates a temporary string per record; typically\n// one or a few records per resolution, so the cost is small compared to DNS RTT and nft writes.\nfunc extractResolvedIPs(resp *dns.Msg) []nftables.ResolvedIP {\n\tif resp == nil || len(resp.Answer) == 0 {\n\t\treturn nil\n\t}\n\n\tvar out []nftables.ResolvedIP\n\tfor _, rr := range resp.Answer {\n\t\tswitch v := rr.(type) {\n\t\tcase *dns.A:\n\t\t\tif v.A == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\taddr, err := netip.ParseAddr(v.A.String())\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second})\n\t\tcase *dns.AAAA:\n\t\t\tif v.AAAA == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\taddr, err := netip.ParseAddr(v.AAAA.String())\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second})\n\t\t}\n\t}\n\treturn out\n}\n\nconst fallbackUpstream = \"8.8.8.8:53\"\n\nfunc discoverUpstream() (string, error) {\n\tcfg, err := dns.ClientConfigFromFile(\"/etc/resolv.conf\")\n\tif err != nil || len(cfg.Servers) == 0 {\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"[dns] fallback upstream resolver due to error: %v\", err)\n\t\t}\n\t\treturn fallbackUpstream, nil\n\t}\n\t// Prefer first non-loopback nameserver (e.g. K8s cluster DNS after 127.0.0.11).\n\t// If only loopback exists (e.g. Docker 127.0.0.11), use it: proxy upstream traffic\n\t// is marked and bypasses the redirect, so loopback is reachable from the sidecar.\n\tvar chosen string\n\tfor _, s := range cfg.Servers {\n\t\tif ip := net.ParseIP(s); ip != nil && ip.IsLoopback() {\n\t\t\tif chosen == \"\" {\n\t\t\t\tchosen = s\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tchosen = s\n\t\tbreak\n\t}\n\tif chosen == \"\" {\n\t\tchosen = cfg.Servers[0]\n\t}\n\treturn net.JoinHostPort(chosen, cfg.Port), nil\n}\n\n// ResolvNameserverIPs reads nameserver lines from resolvPath and returns parsed IPv4/IPv6 addresses.\n// Used at startup to whitelist the system DNS so client traffic to it is allowed and proxy can use it as upstream.\nfunc ResolvNameserverIPs(resolvPath string) ([]netip.Addr, error) {\n\tcfg, err := dns.ClientConfigFromFile(resolvPath)\n\tif err != nil || len(cfg.Servers) == 0 {\n\t\treturn nil, nil\n\t}\n\tvar out []netip.Addr\n\tfor _, s := range cfg.Servers {\n\t\tip, err := netip.ParseAddr(s)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, ip)\n\t}\n\treturn out, nil\n}\n\n// LoadPolicyFromEnvVar reads the given env var and parses a policy; empty falls back to default deny-all.\nfunc LoadPolicyFromEnvVar(envName string) (*policy.NetworkPolicy, error) {\n\traw := os.Getenv(envName)\n\tif raw == \"\" {\n\t\treturn policy.DefaultDenyPolicy(), nil\n\t}\n\treturn policy.ParsePolicy(raw)\n}\n\nfunc ensurePolicyDefaults(p *policy.NetworkPolicy) *policy.NetworkPolicy {\n\tif p == nil {\n\t\treturn policy.DefaultDenyPolicy()\n\t}\n\tif p.DefaultAction == \"\" {\n\t\tp.DefaultAction = policy.ActionDeny\n\t}\n\treturn p\n}\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/proxy_linux.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build linux\n\npackage dnsproxy\n\nimport (\n\t\"net\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"golang.org/x/sys/unix\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n)\n\nvar exemptDialerLogOnce sync.Once\n\n// dialerWithMark sets SO_MARK so iptables can RETURN marked packets (bypass\n// redirect for proxy's own upstream DNS queries). When upstream is in the nameserver\n// exempt list, returns a plain dialer (no mark) so upstream traffic follows normal\n// routing (e.g. via tun); iptables still does not redirect by destination exempt.\nfunc (p *Proxy) dialerWithMark() *net.Dialer {\n\tif UpstreamInExemptList(p.UpstreamHost()) {\n\t\texemptDialerLogOnce.Do(func() {\n\t\t\tlog.Infof(\"[dns] upstream %s in nameserver exempt list, not setting SO_MARK\", p.UpstreamHost())\n\t\t})\n\t\treturn &net.Dialer{Timeout: 5 * time.Second}\n\t}\n\n\treturn &net.Dialer{\n\t\tTimeout: 5 * time.Second,\n\t\tControl: func(network, address string, c syscall.RawConn) error {\n\t\t\tvar opErr error\n\t\t\tif err := c.Control(func(fd uintptr) {\n\t\t\t\topErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue)\n\t\t\t}); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn opErr\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/proxy_other.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !linux\n\npackage dnsproxy\n\nimport (\n\t\"net\"\n\t\"time\"\n)\n\n// Non-linux: no SO_MARK; return basic dialer.\nfunc (p *Proxy) dialerWithMark() *net.Dialer {\n\treturn &net.Dialer{Timeout: 5 * time.Second}\n}\n"
  },
  {
    "path": "components/egress/pkg/dnsproxy/proxy_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage dnsproxy\n\nimport (\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/miekg/dns\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/nftables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n)\n\nfunc TestProxyUpdatePolicy(t *testing.T) {\n\tproxy, err := New(nil, \"127.0.0.1:15353\")\n\trequire.NoError(t, err, \"init proxy\")\n\n\trequire.NotNil(t, proxy.CurrentPolicy(), \"expected default deny policy (non-nil)\")\n\trequire.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate(\"example.com.\"), \"expected default deny\")\n\n\tpol, err := policy.ParsePolicy(`{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"example.com\"}]}`)\n\trequire.NoError(t, err, \"parse policy\")\n\n\tproxy.UpdatePolicy(pol)\n\trequire.NotNil(t, proxy.CurrentPolicy(), \"expected policy after update\")\n\trequire.Equal(t, policy.ActionAllow, proxy.CurrentPolicy().Evaluate(\"example.com.\"), \"policy evaluation mismatch\")\n\n\tproxy.UpdatePolicy(nil)\n\trequire.NotNil(t, proxy.CurrentPolicy(), \"expected default deny policy after clearing\")\n\trequire.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate(\"example.com.\"), \"expected default deny after clearing\")\n}\n\nfunc TestLoadPolicyFromEnvVar(t *testing.T) {\n\tconst envName = \"TEST_EGRESS_POLICY\"\n\tt.Setenv(envName, `{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"example.com\"}]}`)\n\n\tpol, err := LoadPolicyFromEnvVar(envName)\n\trequire.NoError(t, err, \"unexpected error\")\n\trequire.NotNil(t, pol, \"expected parsed policy\")\n\trequire.Equal(t, policy.ActionAllow, pol.Evaluate(\"example.com.\"), \"expected parsed policy to allow example.com\")\n\n\tt.Setenv(envName, \"\")\n\tpol, err = LoadPolicyFromEnvVar(envName)\n\trequire.NoError(t, err, \"unexpected error on empty env\")\n\trequire.NotNil(t, pol, \"expected default deny policy when env is empty\")\n\trequire.Equal(t, policy.ActionDeny, pol.DefaultAction, \"expected default deny when env is empty\")\n}\n\nfunc TestExtractResolvedIPs(t *testing.T) {\n\tmsg := new(dns.Msg)\n\tmsg.Answer = []dns.RR{\n\t\t&dns.A{Hdr: dns.RR_Header{Name: \"example.com.\", Ttl: 120}, A: net.ParseIP(\"1.2.3.4\")},\n\t\t&dns.AAAA{Hdr: dns.RR_Header{Name: \"example.com.\", Ttl: 60}, AAAA: net.ParseIP(\"2001:db8::1\")},\n\t\t&dns.A{Hdr: dns.RR_Header{Name: \"example.com.\", Ttl: 90}, A: net.ParseIP(\"5.6.7.8\")},\n\t}\n\tips := extractResolvedIPs(msg)\n\trequire.Len(t, ips, 3, \"expected 3 IPs\")\n\t// Order follows Answer; check first A and AAAA\n\trequire.Equal(t, \"1.2.3.4\", ips[0].Addr.String(), \"first IP mismatch\")\n\trequire.Equal(t, 120*time.Second, ips[0].TTL, \"first IP TTL mismatch\")\n\trequire.Equal(t, \"2001:db8::1\", ips[1].Addr.String(), \"second IP mismatch\")\n\trequire.Equal(t, 60*time.Second, ips[1].TTL, \"second IP TTL mismatch\")\n\trequire.Equal(t, \"5.6.7.8\", ips[2].Addr.String(), \"third IP mismatch\")\n\trequire.Equal(t, 90*time.Second, ips[2].TTL, \"third IP TTL mismatch\")\n}\n\nfunc TestExtractResolvedIPs_EmptyOrNil(t *testing.T) {\n\trequire.Nil(t, extractResolvedIPs(nil), \"nil msg: expected nil\")\n\tmsg := new(dns.Msg)\n\trequire.Nil(t, extractResolvedIPs(msg), \"empty answer: expected nil\")\n\tmsg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: \"x.\"}, Target: \"y.\"}}\n\trequire.Nil(t, extractResolvedIPs(msg), \"CNAME only: expected nil\")\n}\n\nfunc TestSetOnResolved(t *testing.T) {\n\tproxy, err := New(policy.DefaultDenyPolicy(), \"\")\n\trequire.NoError(t, err)\n\tvar called bool\n\tvar capturedDomain string\n\tvar capturedIPs []nftables.ResolvedIP\n\tproxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) {\n\t\tcalled = true\n\t\tcapturedDomain = domain\n\t\tcapturedIPs = ips\n\t})\n\trequire.NotNil(t, proxy.onResolved, \"SetOnResolved did not set callback\")\n\tproxy.SetOnResolved(nil)\n\trequire.Nil(t, proxy.onResolved, \"SetOnResolved(nil) did not clear callback\")\n\t_ = called\n\t_ = capturedDomain\n\t_ = capturedIPs\n}\n\nfunc TestMaybeNotifyResolved_CallsCallbackWhenAOrAAAA(t *testing.T) {\n\tproxy, err := New(policy.DefaultDenyPolicy(), \"\")\n\trequire.NoError(t, err)\n\tch := make(chan struct {\n\t\tdomain string\n\t\tips    []nftables.ResolvedIP\n\t}, 1)\n\tproxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) {\n\t\tch <- struct {\n\t\t\tdomain string\n\t\t\tips    []nftables.ResolvedIP\n\t\t}{domain, ips}\n\t})\n\n\tmsg := new(dns.Msg)\n\tmsg.Answer = []dns.RR{\n\t\t&dns.A{Hdr: dns.RR_Header{Name: \"example.com.\", Ttl: 120}, A: net.ParseIP(\"1.2.3.4\")},\n\t}\n\tproxy.maybeNotifyResolved(\"example.com.\", msg)\n\n\tselect {\n\tcase got := <-ch:\n\t\trequire.Equal(t, \"example.com.\", got.domain, \"domain mismatch\")\n\t\trequire.Len(t, got.ips, 1, \"expected one resolved IP\")\n\t\trequire.Equal(t, \"1.2.3.4\", got.ips[0].Addr.String(), \"resolved IP mismatch\")\n\tcase <-time.After(2 * time.Second):\n\t\trequire.FailNow(t, \"callback was not invoked\")\n\t}\n}\n\nfunc TestMaybeNotifyResolved_NoCallWhenOnResolvedNil(t *testing.T) {\n\tproxy, err := New(policy.DefaultDenyPolicy(), \"\")\n\trequire.NoError(t, err)\n\tmsg := new(dns.Msg)\n\tmsg.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: \"x.\", Ttl: 60}, A: net.ParseIP(\"10.0.0.1\")}}\n\tproxy.maybeNotifyResolved(\"x.\", msg)\n\t// No callback set; should not panic. No assertion needed.\n}\n\nfunc TestMaybeNotifyResolved_NoCallWhenNoAOrAAAA(t *testing.T) {\n\tproxy, err := New(policy.DefaultDenyPolicy(), \"\")\n\trequire.NoError(t, err)\n\tch := make(chan struct {\n\t\tdomain string\n\t\tips    []nftables.ResolvedIP\n\t}, 1)\n\tproxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) {\n\t\tch <- struct {\n\t\t\tdomain string\n\t\t\tips    []nftables.ResolvedIP\n\t\t}{domain, ips}\n\t})\n\n\tmsg := new(dns.Msg)\n\tmsg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: \"x.\"}, Target: \"y.\"}}\n\tproxy.maybeNotifyResolved(\"x.\", msg)\n\n\tselect {\n\tcase <-ch:\n\t\trequire.FailNow(t, \"callback should not be invoked when resp has no A/AAAA\")\n\tcase <-time.After(200 * time.Millisecond):\n\t\t// Expected: no callback\n\t}\n}\n"
  },
  {
    "path": "components/egress/pkg/events/broadcaster.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage events\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n)\n\nconst defaultQueueSize = 128\n\n// BlockedEvent describes a blocked hostname notification.\ntype BlockedEvent struct {\n\tHostname  string    `json:\"hostname\"`\n\tTimestamp time.Time `json:\"timestamp\"`\n}\n\n// Subscriber consumes blocked events.\ntype Subscriber interface {\n\tHandleBlocked(ctx context.Context, ev BlockedEvent)\n}\n\n// BroadcasterConfig defines queue sizing for the broadcaster.\ntype BroadcasterConfig struct {\n\tQueueSize int\n}\n\n// Broadcaster fans out blocked events to one or more subscribers via channels.\ntype Broadcaster struct {\n\tctx    context.Context\n\tcancel context.CancelFunc\n\n\tmu          sync.RWMutex\n\tsubscribers []chan BlockedEvent\n\tqueueSize   int\n\tclosed      atomic.Bool\n}\n\n// NewBroadcaster builds a broadcaster with the given queue size (defaults to 128).\nfunc NewBroadcaster(ctx context.Context, cfg BroadcasterConfig) *Broadcaster {\n\tif cfg.QueueSize <= 0 {\n\t\tcfg.QueueSize = defaultQueueSize\n\t}\n\tcctx, cancel := context.WithCancel(ctx)\n\treturn &Broadcaster{\n\t\tctx:       cctx,\n\t\tcancel:    cancel,\n\t\tqueueSize: cfg.QueueSize,\n\t}\n}\n\n// AddSubscriber registers a new subscriber with its own buffered queue and worker.\nfunc (b *Broadcaster) AddSubscriber(sub Subscriber) {\n\tif sub == nil {\n\t\treturn\n\t}\n\tch := make(chan BlockedEvent, b.queueSize)\n\n\tb.mu.Lock()\n\tb.subscribers = append(b.subscribers, ch)\n\tb.mu.Unlock()\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-b.ctx.Done():\n\t\t\t\treturn\n\t\t\tcase ev, ok := <-ch:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tsub.HandleBlocked(b.ctx, ev)\n\t\t\t}\n\t\t}\n\t}()\n}\n\n// Publish sends an event to all subscribers; drops and logs when a subscriber queue is full.\nfunc (b *Broadcaster) Publish(event BlockedEvent) {\n\tif b.closed.Load() {\n\t\treturn\n\t}\n\n\tb.mu.RLock()\n\tdefer b.mu.RUnlock()\n\n\tfor _, ch := range b.subscribers {\n\t\tselect {\n\t\tcase ch <- event:\n\t\tdefault:\n\t\t\tlog.Warnf(\"[events] blocked-event queue full; dropping hostname %s\", event.Hostname)\n\t\t}\n\t}\n}\n\n// Close stops all workers and closes subscriber queues.\nfunc (b *Broadcaster) Close() {\n\tif b.closed.Load() {\n\t\treturn\n\t}\n\n\tb.cancel()\n\n\tb.mu.Lock()\n\tdefer b.mu.Unlock()\n\tsubs := b.subscribers\n\tb.subscribers = nil\n\n\tfor _, ch := range subs {\n\t\tclose(ch)\n\t}\n\tb.closed.Store(true)\n}\n"
  },
  {
    "path": "components/egress/pkg/events/events_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage events\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype captureSubscriber struct {\n\trecv chan BlockedEvent\n}\n\nfunc (c *captureSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) {\n\tc.recv <- ev\n}\n\ntype blockingSubscriber struct {\n\tblock chan struct{}\n}\n\nfunc (b *blockingSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) {\n\t// Block until the channel is closed to simulate a slow consumer and trigger backpressure.\n\t<-b.block\n\t_ = ev\n}\n\nfunc TestBroadcasterFanout(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tb := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 2})\n\n\tsub1 := &captureSubscriber{recv: make(chan BlockedEvent, 1)}\n\tsub2 := &captureSubscriber{recv: make(chan BlockedEvent, 1)}\n\tb.AddSubscriber(sub1)\n\tb.AddSubscriber(sub2)\n\n\tev := BlockedEvent{Hostname: \"example.com.\", Timestamp: time.Now()}\n\tb.Publish(ev)\n\n\tselect {\n\tcase got := <-sub1.recv:\n\t\trequire.Equal(t, ev.Hostname, got.Hostname, \"sub1 expected hostname\")\n\tcase <-time.After(2 * time.Second):\n\t\trequire.FailNow(t, \"sub1 did not receive event\")\n\t}\n\n\tselect {\n\tcase got := <-sub2.recv:\n\t\trequire.Equal(t, ev.Hostname, got.Hostname, \"sub2 expected hostname\")\n\tcase <-time.After(2 * time.Second):\n\t\trequire.FailNow(t, \"sub2 did not receive event\")\n\t}\n\n\tb.Close()\n}\n\nfunc TestBroadcasterDropsWhenSubscriberBackedUp(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\t// Small queue; blocking subscriber will hold the first event.\n\tb := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 1})\n\tblock := make(chan struct{})\n\tsub := &blockingSubscriber{block: block}\n\tb.AddSubscriber(sub)\n\n\tev1 := BlockedEvent{Hostname: \"first.example\", Timestamp: time.Now()}\n\tev2 := BlockedEvent{Hostname: \"second.example\", Timestamp: time.Now()}\n\n\tb.Publish(ev1)\n\t// This publish should drop because subscriber is blocked and queue size is 1.\n\tb.Publish(ev2)\n\n\t// Allow subscriber to drain and exit.\n\tclose(block)\n\n\tb.Close()\n}\n\nfunc TestWebhookSubscriberSendsPayload(t *testing.T) {\n\tvar (\n\t\tgotMethod  string\n\t\tgotPayload webhookPayload\n\t)\n\tconst (\n\t\tsandboxIDInitial = \"sandbox-test\"\n\t\tsandboxIDLater   = \"sandbox-updated\"\n\t)\n\tt.Setenv(constants.ENVSandboxID, sandboxIDInitial)\n\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tgotMethod = r.Method\n\t\tbody, _ := io.ReadAll(r.Body)\n\t\t_ = r.Body.Close()\n\t\t_ = json.Unmarshal(body, &gotPayload)\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsub := NewWebhookSubscriber(server.URL)\n\trequire.NotNil(t, sub, \"webhook subscriber should not be nil\")\n\tt.Setenv(constants.ENVSandboxID, sandboxIDLater)\n\n\tts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)\n\tev := BlockedEvent{Hostname: \"Example.com.\", Timestamp: ts}\n\tsub.HandleBlocked(context.Background(), ev)\n\n\trequire.Equal(t, http.MethodPost, gotMethod, \"expected POST\")\n\trequire.Equal(t, ev.Hostname, gotPayload.Hostname, \"expected hostname\")\n\trequire.Equal(t, webhookSource, gotPayload.Source, \"expected source\")\n\trequire.Equal(t, sandboxIDInitial, gotPayload.SandboxID, \"expected sandboxId captured at init\")\n\trequire.NotEmpty(t, gotPayload.Timestamp, \"expected timestamp to be set\")\n}\n"
  },
  {
    "path": "components/egress/pkg/events/webhook.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage events\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n)\n\nconst (\n\twebhookSource         = \"opensandbox-egress\"\n\tdefaultWebhookTimeout = 5 * time.Second\n\tdefaultWebhookRetries = 3\n\tdefaultWebhookBackoff = 1 * time.Second\n)\n\n// WebhookSubscriber delivers blocked events to an HTTP endpoint.\ntype WebhookSubscriber struct {\n\turl        string\n\tclient     *http.Client\n\ttimeout    time.Duration\n\tmaxRetries int\n\tbackoff    time.Duration\n\tsandboxID  string\n}\n\ntype webhookPayload struct {\n\tHostname  string `json:\"hostname\"`\n\tTimestamp string `json:\"timestamp\"`\n\tSource    string `json:\"source\"`\n\tSandboxID string `json:\"sandboxId\"`\n}\n\n// NewWebhookSubscriber builds a webhook subscriber with hardcoded timeout/retry settings.\nfunc NewWebhookSubscriber(url string) *WebhookSubscriber {\n\tif url == \"\" {\n\t\treturn nil\n\t}\n\treturn &WebhookSubscriber{\n\t\turl:        url,\n\t\tclient:     &http.Client{},\n\t\ttimeout:    defaultWebhookTimeout,\n\t\tmaxRetries: defaultWebhookRetries,\n\t\tbackoff:    defaultWebhookBackoff,\n\t\tsandboxID:  os.Getenv(constants.ENVSandboxID),\n\t}\n}\n\n// HandleBlocked sends the blocked event to the configured webhook with retries.\nfunc (w *WebhookSubscriber) HandleBlocked(ctx context.Context, ev BlockedEvent) {\n\tpayload := webhookPayload{\n\t\tHostname:  ev.Hostname,\n\t\tTimestamp: ev.Timestamp.UTC().Format(time.RFC3339),\n\t\tSource:    webhookSource,\n\t\tSandboxID: w.sandboxID,\n\t}\n\tbody, err := json.Marshal(payload)\n\tif err != nil {\n\t\tlog.Warnf(\"[webhook] failed to marshal payload for hostname %s: %v\", ev.Hostname, err)\n\t\treturn\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt <= w.maxRetries; attempt++ {\n\t\treqCtx := ctx\n\t\tcancel := func() {}\n\t\tif w.timeout > 0 {\n\t\t\treqCtx, cancel = context.WithTimeout(ctx, w.timeout)\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, w.url, bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\tcancel()\n\t\t\tlastErr = err\n\t\t\tbreak\n\t\t}\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tresp, err := w.client.Do(req)\n\t\tif err == nil {\n\t\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\t\t_ = resp.Body.Close()\n\t\t\tif resp.StatusCode < 300 {\n\t\t\t\tcancel()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif resp.StatusCode < 500 {\n\t\t\t\tcancel()\n\t\t\t\tlog.Warnf(\"[webhook] non-retriable status %d for hostname %s\", resp.StatusCode, payload.Hostname)\n\t\t\t\treturn\n\t\t\t}\n\t\t\terr = fmt.Errorf(\"status %d\", resp.StatusCode)\n\t\t}\n\n\t\tcancel()\n\t\tlastErr = err\n\t\tif attempt < w.maxRetries {\n\t\t\ttime.Sleep(w.backoff * time.Duration(1<<attempt))\n\t\t}\n\t}\n\n\tif lastErr != nil {\n\t\tlog.Warnf(\"[webhook] failed to notify hostname %s after retries: %v\", payload.Hostname, lastErr)\n\t}\n}\n"
  },
  {
    "path": "components/egress/pkg/iptables/redirect.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage iptables\n\nimport (\n\t\"fmt\"\n\t\"net/netip\"\n\t\"os/exec\"\n\t\"strconv\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n)\n\n// SetupRedirect installs OUTPUT nat redirect for DNS (udp/tcp 53 -> port).\n//\n// exemptDst: optional list of destination IPs; traffic to these is not redirected. Packets carrying mark are also RETURNed (proxy's own upstream). Requires CAP_NET_ADMIN.\nfunc SetupRedirect(port int, exemptDst []netip.Addr) error {\n\tlog.Infof(\"installing iptables DNS redirect: OUTPUT port 53 -> %d (mark %s bypass)\", port, constants.MarkHex)\n\ttargetPort := strconv.Itoa(port)\n\n\tvar rules [][]string\n\tfor _, d := range exemptDst {\n\t\taddr := d\n\t\tdStr := d.String()\n\t\tif addr.Is4() {\n\t\t\trules = append(rules,\n\t\t\t\t[]string{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-d\", dStr, \"-j\", \"RETURN\"},\n\t\t\t\t[]string{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-d\", dStr, \"-j\", \"RETURN\"},\n\t\t\t)\n\t\t} else {\n\t\t\trules = append(rules,\n\t\t\t\t[]string{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-d\", dStr, \"-j\", \"RETURN\"},\n\t\t\t\t[]string{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-d\", dStr, \"-j\", \"RETURN\"},\n\t\t\t)\n\t\t}\n\t}\n\t// Bypass packets marked by the proxy itself (see dnsproxy dialer).\n\tmarkAndRedirect := [][]string{\n\t\t{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-m\", \"mark\", \"--mark\", constants.MarkHex, \"-j\", \"RETURN\"},\n\t\t{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-m\", \"mark\", \"--mark\", constants.MarkHex, \"-j\", \"RETURN\"},\n\t\t// Redirect all other DNS traffic to local proxy port.\n\t\t{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", targetPort},\n\t\t{\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", targetPort},\n\t\t// IPv6 equivalents (ip6tables)\n\t\t{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-m\", \"mark\", \"--mark\", constants.MarkHex, \"-j\", \"RETURN\"},\n\t\t{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-m\", \"mark\", \"--mark\", constants.MarkHex, \"-j\", \"RETURN\"},\n\t\t{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\", \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", targetPort},\n\t\t{\"ip6tables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\", \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", targetPort},\n\t}\n\trules = append(rules, markAndRedirect...)\n\n\tfor _, args := range rules {\n\t\tif output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {\n\t\t\treturn fmt.Errorf(\"iptables command failed: %v (output: %s)\", err, output)\n\t\t}\n\t}\n\tlog.Infof(\"iptables DNS redirect installed successfully\")\n\treturn nil\n}\n"
  },
  {
    "path": "components/egress/pkg/log/logger.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n)\n\n// Logger is the shared logger instance for egress.\nvar Logger slogger.Logger = slogger.MustNew(slogger.Config{Level: \"info\"}).Named(\"opensandbox.egress\")\n\n// WithLogger replaces the global logger used by egress components.\nfunc WithLogger(ctx context.Context, logger slogger.Logger) context.Context {\n\tif logger != nil {\n\t\tLogger = logger\n\t}\n\treturn ctx\n}\n\nfunc Debugf(template string, args ...any) {\n\tLogger.Debugf(template, args...)\n}\n\nfunc Infof(template string, args ...any) {\n\tLogger.Infof(template, args...)\n}\n\nfunc Warnf(template string, args ...any) {\n\tLogger.Warnf(template, args...)\n}\n\nfunc Errorf(template string, args ...any) {\n\tLogger.Errorf(template, args...)\n}\n\nfunc Fatalf(template string, args ...any) {\n\tLogger.Errorf(template, args...)\n\t_ = Logger.Sync()\n\tos.Exit(1)\n}\n"
  },
  {
    "path": "components/egress/pkg/nftables/dynamic.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nftables\n\nimport (\n\t\"fmt\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tdynAllowV4Set  = \"dyn_allow_v4\"\n\tdynAllowV6Set  = \"dyn_allow_v6\"\n\tdynSetTimeoutS = 300\n\tminTTLSec      = 60\n\tmaxTTLSec      = 300\n)\n\n// ResolvedIP is a single IP learned from DNS with TTL for dynamic nft set.\ntype ResolvedIP struct {\n\tAddr netip.Addr\n\tTTL  time.Duration\n}\n\n// buildAddResolvedIPsScript returns a nft script fragment that\n// adds resolved IPs to dyn_allow_v4/v6 with timeout.\nfunc buildAddResolvedIPsScript(table string, ips []ResolvedIP) string {\n\tvar v4, v6 []string\n\tfor _, r := range ips {\n\t\tsec := clampTTL(r.TTL)\n\t\tif r.Addr.Is4() {\n\t\t\tv4 = append(v4, fmt.Sprintf(\"%s timeout %ds\", r.Addr.String(), sec))\n\t\t} else if r.Addr.Is6() {\n\t\t\tv6 = append(v6, fmt.Sprintf(\"%s timeout %ds\", r.Addr.String(), sec))\n\t\t}\n\t}\n\tvar b strings.Builder\n\tif len(v4) > 0 {\n\t\tfmt.Fprintf(&b, \"add element inet %s %s { %s }\\n\", table, dynAllowV4Set, strings.Join(v4, \", \"))\n\t}\n\tif len(v6) > 0 {\n\t\tfmt.Fprintf(&b, \"add element inet %s %s { %s }\\n\", table, dynAllowV6Set, strings.Join(v6, \", \"))\n\t}\n\treturn b.String()\n}\n\nfunc clampTTL(d time.Duration) int {\n\tsec := int(d.Seconds())\n\tif sec < minTTLSec {\n\t\treturn minTTLSec\n\t}\n\tif sec > maxTTLSec {\n\t\treturn maxTTLSec\n\t}\n\treturn sec\n}\n"
  },
  {
    "path": "components/egress/pkg/nftables/manager.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nftables\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n)\n\nconst (\n\ttableName     = \"opensandbox\"\n\tchainName     = \"egress\"\n\tallowV4Set    = \"allow_v4\"\n\tallowV6Set    = \"allow_v6\"\n\tdenyV4Set     = \"deny_v4\"\n\tdenyV6Set     = \"deny_v6\"\n\tdohBlockV4Set = \"doh_block_v4\"\n\tdohBlockV6Set = \"doh_block_v6\"\n)\n\ntype runner func(ctx context.Context, script string) ([]byte, error)\n\n// Options controls nftables enforcement extras.\ntype Options struct {\n\t// BlockDoT drops tcp/udp 853 to prevent DNS-over-TLS bypass.\n\tBlockDoT bool\n\t// BlockDoH443 drops HTTPS DoH endpoints; when blocklist is empty and enabled, 443 is dropped.\n\tBlockDoH443    bool\n\tDoHBlocklistV4 []string\n\tDoHBlocklistV6 []string\n}\n\n// Manager applies static IP/CIDR policy into nftables and dynamic DNS-learned IPs.\ntype Manager struct {\n\trun  runner\n\topts Options\n\tmu   sync.Mutex\n}\n\n// NewManager builds an nftables manager that shells out to `nft -f -` with defaults.\nfunc NewManager() *Manager {\n\treturn &Manager{run: defaultRunner, opts: Options{BlockDoT: true}}\n}\n\n// NewManagerWithRunner is for tests; allows capturing the rendered ruleset (defaults to BlockDoT=true).\nfunc NewManagerWithRunner(r runner) *Manager {\n\treturn &Manager{run: r, opts: Options{BlockDoT: true}}\n}\n\n// NewManagerWithRunnerAndOptions is for tests needing custom options.\nfunc NewManagerWithRunnerAndOptions(r runner, opts Options) *Manager {\n\treturn &Manager{run: r, opts: opts}\n}\n\n// NewManagerWithOptions allows customizing behavior (used by main()).\nfunc NewManagerWithOptions(opts Options) *Manager {\n\treturn &Manager{run: defaultRunner, opts: opts}\n}\n\n// ApplyStatic reconciles static allow/deny IP and CIDR entries into nftables.\n//\n// It creates a dedicated table/chain and overwrites previous state.\n// Uses the same mutex as AddResolvedIPs so a /policy update never overlaps a DNS\n// callback: without this, add-element could run while the table is being deleted/recreated\n// and fail, causing a transient deny for a client that already got an allowed DNS answer.\nfunc (m *Manager) ApplyStatic(ctx context.Context, p *policy.NetworkPolicy) error {\n\tif p == nil {\n\t\tp = policy.DefaultDenyPolicy()\n\t}\n\tallowV4, allowV6, denyV4, denyV6 := p.StaticIPSets()\n\tlog.Infof(\"nftables: applying static policy: default=%s, allow_v4=%d, allow_v6=%d, deny_v4=%d, deny_v6=%d\",\n\t\tp.DefaultAction, len(allowV4), len(allowV6), len(denyV4), len(denyV6))\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tscript := buildRuleset(p, m.opts)\n\tif _, err := m.run(ctx, script); err != nil {\n\t\t// On a fresh host the delete-table may fail; retry once without the delete line.\n\t\tif isMissingTableError(err) {\n\t\t\tfallback := removeDeleteTableLine(script)\n\t\t\tif fallback != script {\n\t\t\t\tif _, retryErr := m.run(ctx, fallback); retryErr == nil {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n\tlog.Infof(\"nftables: static policy applied successfully\")\n\treturn nil\n}\n\n// AddResolvedIPs adds DNS-learned IPs to dynamic allow sets with TTL-based timeout.\n// TTL is clamped to minTTLSec–maxTTLSec. Call only when table exists (dns+nft mode).\nfunc (m *Manager) AddResolvedIPs(ctx context.Context, ips []ResolvedIP) error {\n\tif len(ips) == 0 {\n\t\treturn nil\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tscript := buildAddResolvedIPsScript(tableName, ips)\n\tif script == \"\" {\n\t\treturn nil\n\t}\n\tlog.Infof(\"nftables: adding %d resolved IP(s) to dynamic allow sets with script statement %s\", len(ips), script)\n\t_, err := m.run(ctx, script)\n\treturn err\n}\n\nfunc buildRuleset(p *policy.NetworkPolicy, opts Options) string {\n\tallowV4, allowV6, denyV4, denyV6 := p.StaticIPSets()\n\n\tvar b strings.Builder\n\t// Reset and re-create table, sets, and chain.\n\tfmt.Fprintf(&b, \"delete table inet %s\\n\", tableName)\n\tfmt.Fprintf(&b, \"add table inet %s\\n\", tableName)\n\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv4_addr; flags interval; }\\n\", tableName, allowV4Set)\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv4_addr; flags interval; }\\n\", tableName, denyV4Set)\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv6_addr; flags interval; }\\n\", tableName, allowV6Set)\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv6_addr; flags interval; }\\n\", tableName, denyV6Set)\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv4_addr; timeout %ds; }\\n\", tableName, dynAllowV4Set, dynSetTimeoutS)\n\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv6_addr; timeout %ds; }\\n\", tableName, dynAllowV6Set, dynSetTimeoutS)\n\n\tif len(opts.DoHBlocklistV4) > 0 {\n\t\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv4_addr; flags interval; }\\n\", tableName, dohBlockV4Set)\n\t}\n\tif len(opts.DoHBlocklistV6) > 0 {\n\t\tfmt.Fprintf(&b, \"add set inet %s %s { type ipv6_addr; flags interval; }\\n\", tableName, dohBlockV6Set)\n\t}\n\n\twriteElements(&b, allowV4Set, allowV4)\n\twriteElements(&b, denyV4Set, denyV4)\n\twriteElements(&b, allowV6Set, allowV6)\n\twriteElements(&b, denyV6Set, denyV6)\n\twriteElements(&b, dohBlockV4Set, opts.DoHBlocklistV4)\n\twriteElements(&b, dohBlockV6Set, opts.DoHBlocklistV6)\n\n\tchainPolicy := \"drop\"\n\tif p.DefaultAction == policy.ActionAllow {\n\t\tchainPolicy = \"accept\"\n\t}\n\tfmt.Fprintf(&b, \"add chain inet %s %s { type filter hook output priority 0; policy %s; }\\n\", tableName, chainName, chainPolicy)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ct state established,related accept\\n\", tableName, chainName)\n\tfmt.Fprintf(&b, \"add rule inet %s %s meta mark %s accept\\n\", tableName, chainName, constants.MarkHex)\n\tfmt.Fprintf(&b, \"add rule inet %s %s oifname \\\"lo\\\" accept\\n\", tableName, chainName)\n\tif opts.BlockDoT {\n\t\tfmt.Fprintf(&b, \"add rule inet %s %s tcp dport 853 drop\\n\", tableName, chainName)\n\t\tfmt.Fprintf(&b, \"add rule inet %s %s udp dport 853 drop\\n\", tableName, chainName)\n\t}\n\tif opts.BlockDoH443 {\n\t\tif len(opts.DoHBlocklistV4) == 0 && len(opts.DoHBlocklistV6) == 0 {\n\t\t\t// strict: drop all 443 when enabled but no blocklist provided\n\t\t\tfmt.Fprintf(&b, \"add rule inet %s %s tcp dport 443 drop\\n\", tableName, chainName)\n\t\t} else {\n\t\t\tif len(opts.DoHBlocklistV4) > 0 {\n\t\t\t\tfmt.Fprintf(&b, \"add rule inet %s %s ip daddr @%s tcp dport 443 drop\\n\", tableName, chainName, dohBlockV4Set)\n\t\t\t}\n\t\t\tif len(opts.DoHBlocklistV6) > 0 {\n\t\t\t\tfmt.Fprintf(&b, \"add rule inet %s %s ip6 daddr @%s tcp dport 443 drop\\n\", tableName, chainName, dohBlockV6Set)\n\t\t\t}\n\t\t}\n\t}\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip daddr @%s drop\\n\", tableName, chainName, denyV4Set)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip6 daddr @%s drop\\n\", tableName, chainName, denyV6Set)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip daddr @%s accept\\n\", tableName, chainName, dynAllowV4Set)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip6 daddr @%s accept\\n\", tableName, chainName, dynAllowV6Set)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip daddr @%s accept\\n\", tableName, chainName, allowV4Set)\n\tfmt.Fprintf(&b, \"add rule inet %s %s ip6 daddr @%s accept\\n\", tableName, chainName, allowV6Set)\n\tif chainPolicy == \"drop\" {\n\t\tfmt.Fprintf(&b, \"add rule inet %s %s counter drop\\n\", tableName, chainName)\n\t}\n\n\treturn b.String()\n}\n\nfunc writeElements(b *strings.Builder, setName string, elems []string) {\n\tif len(elems) == 0 {\n\t\treturn\n\t}\n\tfmt.Fprintf(b, \"add element inet %s %s { %s }\\n\", tableName, setName, strings.Join(elems, \", \"))\n}\n\nfunc defaultRunner(ctx context.Context, script string) ([]byte, error) {\n\tcmd := exec.CommandContext(ctx, \"nft\", \"-f\", \"-\")\n\tcmd.Stdin = strings.NewReader(script)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn output, fmt.Errorf(\"nft apply failed: %w (output: %s)\", err, strings.TrimSpace(string(output)))\n\t}\n\treturn output, nil\n}\n\nfunc isMissingTableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(err.Error())\n\treturn strings.Contains(msg, \"no such file or directory\") && strings.Contains(msg, \"delete table inet \"+tableName)\n}\n\nfunc removeDeleteTableLine(script string) string {\n\tlines := strings.Split(script, \"\\n\")\n\tvar filtered []string\n\tfor _, l := range lines {\n\t\tif strings.HasPrefix(l, \"delete table inet \"+tableName) {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.TrimSpace(l) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfiltered = append(filtered, l)\n\t}\n\treturn strings.Join(filtered, \"\\n\")\n}\n"
  },
  {
    "path": "components/egress/pkg/nftables/manager_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage nftables\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/netip\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestApplyStatic_BuildsRuleset_DefaultDeny(t *testing.T) {\n\tvar rendered string\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\trendered = script\n\t\treturn nil, nil\n\t})\n\n\tp, err := policy.ParsePolicy(`{\n\t\t\"defaultAction\":\"deny\",\n\t\t\"egress\":[\n\t\t\t{\"action\":\"allow\",\"target\":\"1.1.1.1\"},\n\t\t\t{\"action\":\"allow\",\"target\":\"2.2.0.0/16\"},\n\t\t\t{\"action\":\"deny\",\"target\":\"2001:db8::/32\"}\n\t\t]\n\t}`)\n\trequire.NoError(t, err, \"unexpected parse error\")\n\n\trequire.NoError(t, m.ApplyStatic(context.Background(), p), \"ApplyStatic returned error\")\n\n\texpectContains(t, rendered, \"add chain inet opensandbox egress { type filter hook output priority 0; policy drop; }\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress ct state established,related accept\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress meta mark 0x1 accept\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress oifname \\\"lo\\\" accept\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress tcp dport 853 drop\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress udp dport 853 drop\")\n\texpectContains(t, rendered, \"add set inet opensandbox dyn_allow_v4 { type ipv4_addr; timeout 300s; }\")\n\texpectContains(t, rendered, \"add set inet opensandbox dyn_allow_v6 { type ipv6_addr; timeout 300s; }\")\n\texpectContains(t, rendered, \"add element inet opensandbox allow_v4 { 1.1.1.1, 2.2.0.0/16 }\")\n\texpectContains(t, rendered, \"add element inet opensandbox deny_v6 { 2001:db8::/32 }\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress ip daddr @dyn_allow_v4 accept\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress ip6 daddr @dyn_allow_v6 accept\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress counter drop\")\n}\n\nfunc TestApplyStatic_DefaultAllowUsesAcceptPolicy(t *testing.T) {\n\tvar rendered string\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\trendered = script\n\t\treturn nil, nil\n\t})\n\n\tp, err := policy.ParsePolicy(`{\n\t\t\"defaultAction\":\"allow\",\n\t\t\"egress\":[{\"action\":\"deny\",\"target\":\"10.0.0.0/8\"}]\n\t}`)\n\trequire.NoError(t, err, \"unexpected parse error\")\n\n\trequire.NoError(t, m.ApplyStatic(context.Background(), p), \"ApplyStatic returned error\")\n\n\texpectContains(t, rendered, \"policy accept;\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress tcp dport 853 drop\")\n\trequire.NotContains(t, rendered, \"counter drop\", \"did not expect drop counter when defaultAction is allow:\\n%s\", rendered)\n\texpectContains(t, rendered, \"add element inet opensandbox deny_v4 { 10.0.0.0/8 }\")\n}\n\nfunc expectContains(t *testing.T, s, substr string) {\n\tt.Helper()\n\trequire.Contains(t, s, substr, \"expected rendered ruleset to contain %q\\nrendered:\\n%s\", substr, s)\n}\n\nfunc TestApplyStatic_RetryWhenTableMissing(t *testing.T) {\n\tvar calls int\n\tvar scripts []string\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\tcalls++\n\t\tscripts = append(scripts, script)\n\t\tif calls == 1 {\n\t\t\treturn nil, fmt.Errorf(\"nft apply failed: exit status 1 (output: /dev/stdin:1:19-29: Error: No such file or directory; did you mean table ‘opensandbox’ in family inet?\\ndelete table inet opensandbox\\n                  ^^^^^^^^^^^)\")\n\t\t}\n\t\treturn nil, nil\n\t})\n\n\tp, _ := policy.ParsePolicy(`{\"egress\":[]}`)\n\trequire.NoError(t, m.ApplyStatic(context.Background(), p), \"expected retry to succeed\")\n\trequire.Equal(t, 2, calls, \"expected 2 calls (fail then retry)\")\n\trequire.GreaterOrEqual(t, len(scripts), 2, \"expected second attempt script to be recorded\")\n\trequire.NotContains(t, scripts[1], \"delete table inet opensandbox\", \"expected second attempt to drop delete-table line\")\n}\n\nfunc TestApplyStatic_DoHBlocklist(t *testing.T) {\n\tvar rendered string\n\topts := Options{\n\t\tBlockDoT:       true,\n\t\tBlockDoH443:    true,\n\t\tDoHBlocklistV4: []string{\"9.9.9.9\"},\n\t\tDoHBlocklistV6: []string{\"2001:db8::/32\"},\n\t}\n\tm := NewManagerWithRunnerAndOptions(func(_ context.Context, script string) ([]byte, error) {\n\t\trendered = script\n\t\treturn nil, nil\n\t}, opts)\n\n\tp, _ := policy.ParsePolicy(`{\"defaultAction\":\"allow\",\"egress\":[]}`)\n\trequire.NoError(t, m.ApplyStatic(context.Background(), p), \"ApplyStatic returned error\")\n\n\texpectContains(t, rendered, \"add set inet opensandbox doh_block_v4 { type ipv4_addr; flags interval; }\")\n\texpectContains(t, rendered, \"add element inet opensandbox doh_block_v4 { 9.9.9.9 }\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress ip daddr @doh_block_v4 tcp dport 443 drop\")\n\texpectContains(t, rendered, \"add rule inet opensandbox egress ip6 daddr @doh_block_v6 tcp dport 443 drop\")\n}\n\nfunc TestAddResolvedIPs_BuildsDynamicElements(t *testing.T) {\n\tvar rendered string\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\trendered = script\n\t\treturn nil, nil\n\t})\n\tips := []ResolvedIP{\n\t\t{Addr: netip.MustParseAddr(\"1.1.1.1\"), TTL: 120 * time.Second},\n\t\t{Addr: netip.MustParseAddr(\"2001:db8::1\"), TTL: 60 * time.Second},\n\t}\n\trequire.NoError(t, m.AddResolvedIPs(context.Background(), ips), \"AddResolvedIPs returned error\")\n\texpectContains(t, rendered, \"add element inet opensandbox dyn_allow_v4 { 1.1.1.1 timeout 120s }\")\n\texpectContains(t, rendered, \"add element inet opensandbox dyn_allow_v6 { 2001:db8::1 timeout 60s }\")\n}\n\nfunc TestAddResolvedIPs_ClampsTTL(t *testing.T) {\n\tvar rendered string\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\trendered = script\n\t\treturn nil, nil\n\t})\n\tips := []ResolvedIP{\n\t\t{Addr: netip.MustParseAddr(\"10.0.0.1\"), TTL: 10 * time.Second},\n\t\t{Addr: netip.MustParseAddr(\"10.0.0.2\"), TTL: 9999 * time.Second},\n\t}\n\trequire.NoError(t, m.AddResolvedIPs(context.Background(), ips), \"AddResolvedIPs returned error\")\n\texpectContains(t, rendered, \"10.0.0.1 timeout 60s\")\n\texpectContains(t, rendered, \"10.0.0.2 timeout 300s\")\n}\n\nfunc TestAddResolvedIPs_EmptyNoOp(t *testing.T) {\n\tm := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) {\n\t\trequire.FailNow(t, \"runner should not be called for empty ips\")\n\t\treturn nil, nil\n\t})\n\trequire.NoError(t, m.AddResolvedIPs(context.Background(), nil), \"AddResolvedIPs returned error\")\n\trequire.NoError(t, m.AddResolvedIPs(context.Background(), []ResolvedIP{}), \"AddResolvedIPs returned error\")\n}\n"
  },
  {
    "path": "components/egress/pkg/policy/policy.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage policy\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/netip\"\n\t\"strings\"\n)\n\nconst (\n\tActionAllow = \"allow\"\n\tActionDeny  = \"deny\"\n)\n\ntype targetKind int\n\nconst (\n\ttargetUnknown targetKind = iota\n\ttargetDomain\n\ttargetIP\n\ttargetCIDR\n)\n\n// DefaultDenyPolicy returns a new policy that denies all traffic.\nfunc DefaultDenyPolicy() *NetworkPolicy {\n\treturn &NetworkPolicy{DefaultAction: ActionDeny}\n}\n\n// NetworkPolicy is the minimal MVP shape for egress control.\n// Only domain/wildcard targets are honored in this MVP.\ntype NetworkPolicy struct {\n\tEgress        []EgressRule `json:\"egress\"`\n\tDefaultAction string       `json:\"defaultAction\"`\n}\n\ntype EgressRule struct {\n\tAction string `json:\"action\"`\n\tTarget string `json:\"target\"`\n\n\ttargetKind targetKind\n\tip         netip.Addr\n\tprefix     netip.Prefix\n}\n\n// ParsePolicy parses JSON from env/config into a NetworkPolicy.\n// Default action falls back to \"deny\" to align with proposal.\nfunc ParsePolicy(raw string) (*NetworkPolicy, error) {\n\ttrimmed := strings.TrimSpace(raw)\n\tif trimmed == \"\" || trimmed == \"null\" || trimmed == \"{}\" {\n\t\treturn DefaultDenyPolicy(), nil\n\t}\n\n\tvar p NetworkPolicy\n\tif err := json.Unmarshal([]byte(trimmed), &p); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := normalizePolicy(&p); err != nil {\n\t\treturn nil, err\n\t}\n\treturn ensureDefaults(&p), nil\n}\n\n// Evaluate returns allow/deny for a given domain (lowercased).\nfunc (p *NetworkPolicy) Evaluate(domain string) string {\n\tif p == nil {\n\t\treturn ActionDeny\n\t}\n\tdomain = strings.ToLower(strings.TrimSuffix(domain, \".\"))\n\tfor _, r := range p.Egress {\n\t\tif r.targetKind != targetDomain {\n\t\t\tcontinue\n\t\t}\n\t\tif r.matchesDomain(domain) {\n\t\t\tif r.Action == \"\" {\n\t\t\t\treturn ActionDeny\n\t\t\t}\n\t\t\treturn r.Action\n\t\t}\n\t}\n\tif p.DefaultAction == \"\" {\n\t\treturn ActionDeny\n\t}\n\treturn p.DefaultAction\n}\n\n// ensureDefaults guarantees a policy always has a default action.\nfunc ensureDefaults(p *NetworkPolicy) *NetworkPolicy {\n\tif p == nil {\n\t\treturn DefaultDenyPolicy()\n\t}\n\tif p.DefaultAction == \"\" {\n\t\tp.DefaultAction = ActionDeny\n\t}\n\treturn p\n}\n\nfunc normalizePolicy(p *NetworkPolicy) error {\n\tp.DefaultAction = strings.ToLower(strings.TrimSpace(p.DefaultAction))\n\tif p.DefaultAction == \"\" {\n\t\tp.DefaultAction = ActionDeny\n\t}\n\n\tfor i := range p.Egress {\n\t\tr := &p.Egress[i]\n\t\tr.Action = strings.ToLower(strings.TrimSpace(r.Action))\n\t\tif r.Action == \"\" {\n\t\t\tr.Action = ActionDeny\n\t\t}\n\t\tif r.Action != ActionAllow && r.Action != ActionDeny {\n\t\t\treturn fmt.Errorf(\"unsupported action %q\", r.Action)\n\t\t}\n\n\t\tr.Target = strings.TrimSpace(r.Target)\n\t\tif r.Target == \"\" {\n\t\t\treturn fmt.Errorf(\"egress target cannot be empty\")\n\t\t}\n\t\tif ip, err := netip.ParseAddr(r.Target); err == nil {\n\t\t\tr.targetKind = targetIP\n\t\t\tr.ip = ip\n\t\t\tcontinue\n\t\t}\n\t\tif prefix, err := netip.ParsePrefix(r.Target); err == nil {\n\t\t\tr.targetKind = targetCIDR\n\t\t\tr.prefix = prefix\n\t\t\tcontinue\n\t\t}\n\t\tr.targetKind = targetDomain\n\t}\n\treturn nil\n}\n\n// WithExtraAllowIPs returns a copy of the policy with additional allow rules for each IP.\n// Used at startup to whitelist system nameservers so client DNS and proxy upstream work with private DNS.\nfunc (p *NetworkPolicy) WithExtraAllowIPs(ips []netip.Addr) *NetworkPolicy {\n\tif p == nil || len(ips) == 0 {\n\t\treturn p\n\t}\n\tout := *p\n\tout.Egress = make([]EgressRule, len(p.Egress), len(p.Egress)+len(ips))\n\tcopy(out.Egress, p.Egress)\n\tfor _, ip := range ips {\n\t\tout.Egress = append(out.Egress, EgressRule{\n\t\t\tAction:     ActionAllow,\n\t\t\tTarget:     ip.String(),\n\t\t\ttargetKind: targetIP,\n\t\t\tip:         ip,\n\t\t})\n\t}\n\treturn &out\n}\n\n// StaticIPSets splits static IP/CIDR rules into allow/deny IPv4/IPv6 buckets.\n// Empty or nil policy returns empty slices.\nfunc (p *NetworkPolicy) StaticIPSets() (allowV4, allowV6, denyV4, denyV6 []string) {\n\tif p == nil {\n\t\treturn\n\t}\n\tfor _, r := range p.Egress {\n\t\tswitch r.targetKind {\n\t\tcase targetIP:\n\t\t\taddr := r.ip\n\t\t\ttarget := addr.String()\n\t\t\tif r.Action == ActionAllow {\n\t\t\t\tif addr.Is4() {\n\t\t\t\t\tallowV4 = append(allowV4, target)\n\t\t\t\t} else if addr.Is6() {\n\t\t\t\t\tallowV6 = append(allowV6, target)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif addr.Is4() {\n\t\t\t\t\tdenyV4 = append(denyV4, target)\n\t\t\t\t} else if addr.Is6() {\n\t\t\t\t\tdenyV6 = append(denyV6, target)\n\t\t\t\t}\n\t\t\t}\n\t\tcase targetCIDR:\n\t\t\tpfx := r.prefix\n\t\t\ttarget := pfx.String()\n\t\t\tif r.Action == ActionAllow {\n\t\t\t\tif pfx.Addr().Is4() {\n\t\t\t\t\tallowV4 = append(allowV4, target)\n\t\t\t\t} else if pfx.Addr().Is6() {\n\t\t\t\t\tallowV6 = append(allowV6, target)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif pfx.Addr().Is4() {\n\t\t\t\t\tdenyV4 = append(denyV4, target)\n\t\t\t\t} else if pfx.Addr().Is6() {\n\t\t\t\t\tdenyV6 = append(denyV6, target)\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\t}\n\treturn\n}\n\nfunc (r *EgressRule) matchesDomain(domain string) bool {\n\tpattern := strings.ToLower(strings.TrimSpace(r.Target))\n\tdomain = strings.ToLower(domain)\n\n\tif pattern == \"\" {\n\t\treturn false\n\t}\n\tif pattern == domain {\n\t\treturn true\n\t}\n\tif strings.HasPrefix(pattern, \"*.\") {\n\t\t// \"*.example.com\" matches \"a.example.com\" but not \"example.com\"\n\t\tsuffix := strings.TrimPrefix(pattern, \"*\")\n\t\treturn strings.HasSuffix(domain, suffix) && domain != strings.TrimPrefix(pattern, \"*.\")\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "components/egress/pkg/policy/policy_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage policy\n\nimport (\n\t\"net/netip\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestParsePolicy_EmptyOrNullDefaultsDeny(t *testing.T) {\n\tcases := []string{\n\t\t\"\",\n\t\t\"   \",\n\t\t\"null\",\n\t\t\"{}\\n\",\n\t}\n\tfor _, raw := range cases {\n\t\tp, err := ParsePolicy(raw)\n\t\trequire.NoErrorf(t, err, \"raw %q returned error\", raw)\n\t\trequire.NotNilf(t, p, \"raw %q expected default deny policy, got nil\", raw)\n\t\trequire.Equalf(t, ActionDeny, p.DefaultAction, \"raw %q expected defaultAction deny\", raw)\n\t\trequire.Equalf(t, ActionDeny, p.Evaluate(\"example.com.\"), \"raw %q expected deny evaluation\", raw)\n\t}\n}\n\nfunc TestParsePolicy_DefaultActionFallback(t *testing.T) {\n\tp, err := ParsePolicy(`{\"egress\":[{\"action\":\"allow\",\"target\":\"example.com\"}]}`)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, p, \"expected policy object, got nil\")\n\trequire.Equal(t, ActionDeny, p.DefaultAction, \"expected defaultAction fallback to deny\")\n}\n\nfunc TestParsePolicy_EmptyEgressDefaultsDeny(t *testing.T) {\n\tp, err := ParsePolicy(`{\"defaultAction\":\"\"}`)\n\trequire.NoError(t, err)\n\trequire.Equal(t, ActionDeny, p.DefaultAction, \"expected default deny when defaultAction missing\")\n\trequire.Equal(t, ActionDeny, p.Evaluate(\"anything.com.\"), \"expected evaluation deny for empty egress\")\n}\n\nfunc TestParsePolicy_IPAndCIDRSupported(t *testing.T) {\n\traw := `{\n\t\t\"defaultAction\":\"deny\",\n\t\t\"egress\":[\n\t\t\t{\"action\":\"allow\",\"target\":\"1.1.1.1\"},\n\t\t\t{\"action\":\"allow\",\"target\":\"2.2.0.0/16\"},\n\t\t\t{\"action\":\"deny\",\"target\":\"2001:db8::/32\"},\n\t\t\t{\"action\":\"deny\",\"target\":\"2001:db8::1\"}\n\t\t]\n\t}`\n\tp, err := ParsePolicy(raw)\n\trequire.NoError(t, err)\n\tallowV4, allowV6, denyV4, denyV6 := p.StaticIPSets()\n\trequire.Len(t, allowV4, 2, \"allowV4 length mismatch\")\n\trequire.Equal(t, \"1.1.1.1\", allowV4[0])\n\trequire.Equal(t, \"2.2.0.0/16\", allowV4[1])\n\trequire.Len(t, denyV6, 2, \"expected 2 denyV6 entries\")\n\trequire.Empty(t, allowV6, \"allowV6 should be empty\")\n\trequire.Empty(t, denyV4, \"denyV4 should be empty\")\n}\n\nfunc TestParsePolicy_InvalidAction(t *testing.T) {\n\t_, err := ParsePolicy(`{\"egress\":[{\"action\":\"foo\",\"target\":\"example.com\"}]}`)\n\trequire.Error(t, err, \"expected error for invalid action\")\n}\n\nfunc TestParsePolicy_EmptyTargetError(t *testing.T) {\n\t_, err := ParsePolicy(`{\"egress\":[{\"action\":\"allow\",\"target\":\"\"}]}`)\n\trequire.Error(t, err, \"expected error for empty target\")\n}\n\nfunc TestWithExtraAllowIPs(t *testing.T) {\n\tp, err := ParsePolicy(`{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"example.com\"}]}`)\n\trequire.NoError(t, err)\n\tallowV4, allowV6, _, _ := p.StaticIPSets()\n\trequire.Empty(t, allowV4, \"domain-only policy should have no static allowV4 IPs\")\n\trequire.Empty(t, allowV6, \"domain-only policy should have no static allowV6 IPs\")\n\n\tips := []netip.Addr{\n\t\tnetip.MustParseAddr(\"192.168.65.7\"),\n\t\tnetip.MustParseAddr(\"2001:db8::1\"),\n\t}\n\tmerged := p.WithExtraAllowIPs(ips)\n\trequire.NotSame(t, p, merged, \"expected new policy instance\")\n\tallowV4, allowV6, _, _ = merged.StaticIPSets()\n\trequire.Len(t, allowV4, 1, \"allowV4 length mismatch\")\n\trequire.Equal(t, \"192.168.65.7\", allowV4[0])\n\trequire.Len(t, allowV6, 1, \"allowV6 length mismatch\")\n\trequire.Equal(t, \"2001:db8::1\", allowV6[0])\n\n\t// nil/empty ips returns same policy\n\trequire.Same(t, p, p.WithExtraAllowIPs(nil), \"WithExtraAllowIPs(nil) should return same policy\")\n\trequire.Same(t, p, p.WithExtraAllowIPs([]netip.Addr{}), \"WithExtraAllowIPs([]) should return same policy\")\n}\n"
  },
  {
    "path": "components/egress/policy_server.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"crypto/subtle\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/netip\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/constants\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/log\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/nftables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n)\n\ntype policyUpdater interface {\n\tCurrentPolicy() *policy.NetworkPolicy\n\tUpdatePolicy(*policy.NetworkPolicy)\n}\n\n// enforcementReporter reports the current enforcement mode (dns | dns+nft).\ntype enforcementReporter interface {\n\tEnforcementMode() string\n}\n\n// nftApplier applies static policy and optional dynamic DNS-learned IPs to nftables.\ntype nftApplier interface {\n\tApplyStatic(context.Context, *policy.NetworkPolicy) error\n\tAddResolvedIPs(context.Context, []nftables.ResolvedIP) error\n}\n\n// startPolicyServer launches a lightweight HTTP API for updating the egress policy at runtime.\n// Supported endpoints:\n//   - GET  /policy : returns the currently enforced policy.\n//   - POST /policy : replace the policy; empty body resets to default deny-all.\n//\n// nameserverIPs are merged into every applied policy so system DNS stays allowed (e.g. private DNS).\nfunc startPolicyServer(ctx context.Context, proxy policyUpdater, nft nftApplier, enforcementMode string, addr string, token string, nameserverIPs []netip.Addr) error {\n\tmux := http.NewServeMux()\n\thandler := &policyServer{proxy: proxy, nft: nft, token: token, enforcementMode: enforcementMode, nameserverIPs: nameserverIPs}\n\tmux.HandleFunc(\"/policy\", handler.handlePolicy)\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"ok\"))\n\t})\n\n\tsrv := &http.Server{Addr: addr, Handler: mux}\n\thandler.server = srv\n\n\t// Shutdown listener when context ends.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\t\tif err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\tlog.Warnf(\"policy server shutdown error: %v\", err)\n\t\t}\n\t}()\n\n\terrCh := make(chan error, 1)\n\tgo func() {\n\t\tif err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\terrCh <- err\n\t\t}\n\t}()\n\n\tselect {\n\tcase err := <-errCh:\n\t\treturn err\n\tcase <-time.After(200 * time.Millisecond):\n\t\t// assume healthy start; keep logging future errors\n\t\tgo func() {\n\t\t\tif err := <-errCh; err != nil {\n\t\t\t\tlog.Errorf(\"policy server error: %v\", err)\n\t\t\t}\n\t\t}()\n\t\treturn nil\n\t}\n}\n\ntype policyServer struct {\n\tproxy           policyUpdater\n\tnft             nftApplier\n\tserver          *http.Server\n\ttoken           string\n\tenforcementMode string\n\tnameserverIPs   []netip.Addr\n\tmu              sync.Mutex // serializes read-merge-apply to avoid lost updates across POST/PATCH\n}\n\ntype policyStatusResponse struct {\n\tStatus          string `json:\"status,omitempty\"`\n\tMode            string `json:\"mode,omitempty\"`\n\tEnforcementMode string `json:\"enforcementMode,omitempty\"`\n\tReason          string `json:\"reason,omitempty\"`\n\tPolicy          any    `json:\"policy,omitempty\"`\n}\n\nfunc (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) {\n\tif !s.authorize(r) {\n\t\thttp.Error(w, \"unauthorized\", http.StatusUnauthorized)\n\t\treturn\n\t}\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\ts.handleGet(w)\n\tcase http.MethodPost, http.MethodPut:\n\t\ts.handlePost(w, r)\n\tcase http.MethodPatch:\n\t\ts.handlePatch(w, r)\n\tdefault:\n\t\tw.Header().Set(\"Allow\", \"GET, POST, PUT, PATCH\")\n\t\thttp.Error(w, \"method not allowed\", http.StatusMethodNotAllowed)\n\t}\n}\n\nfunc (s *policyServer) handleGet(w http.ResponseWriter) {\n\tcurrent := s.proxy.CurrentPolicy()\n\tmode := modeFromPolicy(current)\n\twriteJSON(w, http.StatusOK, policyStatusResponse{\n\t\tStatus:          \"ok\",\n\t\tMode:            mode,\n\t\tEnforcementMode: s.enforcementMode,\n\t\tPolicy:          current,\n\t})\n}\n\nfunc (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) {\n\tdefer r.Body.Close()\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\traw := strings.TrimSpace(string(body))\n\tif raw == \"\" {\n\t\tlog.Infof(\"policy API: reset to default deny-all\")\n\t\tdef := policy.DefaultDenyPolicy()\n\t\tif s.nft != nil {\n\t\t\tdefWithNS := def.WithExtraAllowIPs(s.nameserverIPs)\n\t\t\tif err := s.nft.ApplyStatic(r.Context(), defWithNS); err != nil {\n\t\t\t\tlog.Errorf(\"policy API: nftables apply failed on reset: %v\", err)\n\t\t\t\thttp.Error(w, fmt.Sprintf(\"failed to apply nftables: %v\", err), http.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\ts.proxy.UpdatePolicy(def)\n\t\tlog.Infof(\"policy API: proxy and nftables updated to deny_all\")\n\t\twriteJSON(w, http.StatusOK, policyStatusResponse{\n\t\t\tStatus: \"ok\",\n\t\t\tMode:   \"deny_all\",\n\t\t\tReason: \"policy reset to default deny-all\",\n\t\t})\n\t\treturn\n\t}\n\n\tpol, err := policy.ParsePolicy(raw)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid policy: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tmode := modeFromPolicy(pol)\n\tlog.Infof(\"policy API: updating policy to mode=%s, enforcement=%s\", mode, s.enforcementMode)\n\tif s.nft != nil {\n\t\tpolWithNS := pol.WithExtraAllowIPs(s.nameserverIPs)\n\t\tif err := s.nft.ApplyStatic(r.Context(), polWithNS); err != nil {\n\t\t\tlog.Errorf(\"policy API: nftables apply failed: %v\", err)\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to apply nftables policy: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t}\n\ts.proxy.UpdatePolicy(pol)\n\tlog.Infof(\"policy API: proxy and nftables updated successfully\")\n\twriteJSON(w, http.StatusOK, policyStatusResponse{\n\t\tStatus:          \"ok\",\n\t\tMode:            mode,\n\t\tEnforcementMode: s.enforcementMode,\n\t})\n}\n\n// handlePatch adds or replaces egress rules by merging with the current policy.\n// It is a convenience wrapper over the full replace flow: we still read -> merge -> apply.\n// Request body supports {\"egress\":[{\"action\":\"allow\",\"target\":\"example.com\"}, ...]}.\nfunc (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) {\n\tdefer r.Body.Close()\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tbody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"failed to read body: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\traw := strings.TrimSpace(string(body))\n\tif raw == \"\" {\n\t\thttp.Error(w, \"patch body cannot be empty\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar patchRules []policy.EgressRule\n\tif err = json.Unmarshal([]byte(raw), &patchRules); err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid patch rules: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\tif len(patchRules) == 0 {\n\t\thttp.Error(w, \"patch must include at least one egress rule\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tbase := s.proxy.CurrentPolicy()\n\tif base == nil {\n\t\tbase = policy.DefaultDenyPolicy()\n\t}\n\tbaseCopy := *base\n\tbaseCopy.Egress = append([]policy.EgressRule(nil), base.Egress...)\n\n\tmerged := mergeEgressRules(baseCopy.Egress, patchRules)\n\n\t// Reuse parser to normalize targets/actions.\n\trawMerged, _ := json.Marshal(policy.NetworkPolicy{\n\t\tDefaultAction: baseCopy.DefaultAction,\n\t\tEgress:        merged,\n\t})\n\tnewPolicy, err := policy.ParsePolicy(string(rawMerged))\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"invalid merged policy: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tmode := modeFromPolicy(newPolicy)\n\tlog.Infof(\"policy API: patching policy with %d new rule(s), mode=%s, enforcement=%s\", len(patchRules), mode, s.enforcementMode)\n\tif s.nft != nil {\n\t\tpolWithNS := newPolicy.WithExtraAllowIPs(s.nameserverIPs)\n\t\tif err := s.nft.ApplyStatic(r.Context(), polWithNS); err != nil {\n\t\t\tlog.Errorf(\"policy API: nftables apply failed on patch: %v\", err)\n\t\t\thttp.Error(w, fmt.Sprintf(\"failed to apply nftables policy: %v\", err), http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t}\n\ts.proxy.UpdatePolicy(newPolicy)\n\tlog.Infof(\"policy API: patch applied successfully\")\n\twriteJSON(w, http.StatusOK, policyStatusResponse{\n\t\tStatus:          \"ok\",\n\t\tMode:            mode,\n\t\tEnforcementMode: s.enforcementMode,\n\t})\n}\n\nfunc (s *policyServer) authorize(r *http.Request) bool {\n\tif s.token == \"\" {\n\t\treturn true\n\t}\n\tprovided := r.Header.Get(constants.EgressAuthTokenHeader)\n\tif provided == \"\" {\n\t\treturn false\n\t}\n\tif len(provided) != len(s.token) {\n\t\treturn false\n\t}\n\treturn subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) == 1\n}\n\nfunc writeJSON(w http.ResponseWriter, status int, payload any) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(status)\n\t_ = json.NewEncoder(w).Encode(payload)\n}\n\nfunc modeFromPolicy(p *policy.NetworkPolicy) string {\n\tif p == nil {\n\t\treturn \"deny_all\"\n\t}\n\tif p.DefaultAction == policy.ActionAllow && len(p.Egress) == 0 {\n\t\treturn \"allow_all\"\n\t} else if p.DefaultAction == policy.ActionDeny && len(p.Egress) == 0 {\n\t\treturn \"deny_all\"\n\t}\n\n\treturn \"enforcing\"\n}\n\n// mergeEgressRules joins base rules and additions, deduping by target (last writer wins).\nfunc mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule {\n\tif len(additions) == 0 {\n\t\treturn base\n\t}\n\tout := make([]policy.EgressRule, 0, len(base)+len(additions))\n\tseen := make(map[string]struct{})\n\n\t// Priority: additions first; base rules only if target not overridden.\n\tfor _, r := range additions {\n\t\tkey := mergeKey(r)\n\t\tif _, ok := seen[key]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tout = append(out, r)\n\t}\n\tfor _, r := range base {\n\t\tkey := mergeKey(r)\n\t\tif _, ok := seen[key]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[key] = struct{}{}\n\t\tout = append(out, r)\n\t}\n\treturn out\n}\n\n// mergeKey normalizes domain targets to lowercase for dedupe;\n// IP/CIDR targets are kept as-is.\nfunc mergeKey(r policy.EgressRule) string {\n\tif r.Target == \"\" {\n\t\treturn r.Target\n\t}\n\treturn strings.ToLower(r.Target)\n}\n"
  },
  {
    "path": "components/egress/policy_server_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/egress/pkg/nftables\"\n\t\"github.com/alibaba/opensandbox/egress/pkg/policy\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype stubProxy struct {\n\tupdated *policy.NetworkPolicy\n}\n\nfunc (s *stubProxy) CurrentPolicy() *policy.NetworkPolicy {\n\treturn s.updated\n}\n\nfunc (s *stubProxy) UpdatePolicy(p *policy.NetworkPolicy) {\n\ts.updated = p\n}\n\ntype stubNft struct {\n\terr     error\n\tcalls   int\n\tapplied *policy.NetworkPolicy\n}\n\nfunc (s *stubNft) ApplyStatic(_ context.Context, p *policy.NetworkPolicy) error {\n\ts.calls++\n\ts.applied = p\n\treturn s.err\n}\n\nfunc (s *stubNft) AddResolvedIPs(_ context.Context, _ []nftables.ResolvedIP) error {\n\treturn nil\n}\n\nfunc TestHandlePolicy_AppliesNftAndUpdatesProxy(t *testing.T) {\n\tproxy := &stubProxy{}\n\tnft := &stubNft{}\n\tsrv := &policyServer{proxy: proxy, nft: nft, enforcementMode: \"dns+nft\"}\n\n\tbody := `{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"1.1.1.1\"}]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/policy\", strings.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\tsrv.handlePolicy(w, req)\n\n\tresp := w.Result()\n\trequire.Equal(t, http.StatusOK, resp.StatusCode, \"expected 200 OK\")\n\trequire.Contains(t, resp.Header.Get(\"Content-Type\"), \"application/json\", \"expected json response\")\n\trequire.Equal(t, 1, nft.calls, \"expected nft ApplyStatic called once\")\n\trequire.NotNil(t, proxy.updated, \"expected proxy policy to be updated\")\n\trequire.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, \"unexpected defaultAction\")\n}\n\nfunc TestHandlePolicy_NftFailureReturns500(t *testing.T) {\n\tproxy := &stubProxy{}\n\tnft := &stubNft{err: errors.New(\"boom\")}\n\tsrv := &policyServer{proxy: proxy, nft: nft, enforcementMode: \"dns+nft\"}\n\n\tbody := `{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"1.1.1.1\"}]}`\n\treq := httptest.NewRequest(http.MethodPost, \"/policy\", strings.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\tsrv.handlePolicy(w, req)\n\n\tresp := w.Result()\n\trequire.Equal(t, http.StatusInternalServerError, resp.StatusCode, \"expected 500\")\n\trequire.Equal(t, 1, nft.calls, \"expected nft ApplyStatic called once\")\n\trequire.Nil(t, proxy.updated, \"expected proxy policy not updated on nft failure\")\n}\n\nfunc TestHandleGet_ReturnsEnforcementMode(t *testing.T) {\n\tproxy := &stubProxy{updated: policy.DefaultDenyPolicy()}\n\tsrv := &policyServer{proxy: proxy, nft: nil, enforcementMode: \"dns\"}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/policy\", nil)\n\tw := httptest.NewRecorder()\n\n\tsrv.handlePolicy(w, req)\n\n\tresp := w.Result()\n\trequire.Equal(t, http.StatusOK, resp.StatusCode, \"expected 200\")\n\tbody, err := io.ReadAll(resp.Body)\n\trequire.NoError(t, err)\n\trequire.Contains(t, string(body), `\"enforcementMode\":\"dns\"`, \"expected enforcementMode dns in response\")\n}\n\nfunc TestHandlePatch_MergesAndApplies(t *testing.T) {\n\tinitial := &policy.NetworkPolicy{\n\t\tDefaultAction: policy.ActionDeny,\n\t\tEgress: []policy.EgressRule{\n\t\t\t{Action: policy.ActionAllow, Target: \"example.com\"},\n\t\t\t{Action: policy.ActionDeny, Target: \"*.example.com\"},\n\t\t},\n\t}\n\tproxy := &stubProxy{updated: initial}\n\tnft := &stubNft{}\n\tsrv := &policyServer{proxy: proxy, nft: nft, enforcementMode: \"dns+nft\"}\n\n\tbody := `[{\"action\":\"deny\",\"target\":\"blocked.com\"},{\"action\":\"allow\",\"target\":\"example.com\"}]`\n\treq := httptest.NewRequest(http.MethodPatch, \"/policy\", strings.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\tsrv.handlePolicy(w, req)\n\n\tresp := w.Result()\n\trequire.Equal(t, http.StatusOK, resp.StatusCode, \"expected 200\")\n\trequire.Equal(t, 1, nft.calls, \"expected nft ApplyStatic called once\")\n\trequire.NotNil(t, proxy.updated, \"expected proxy policy to be updated\")\n\trequire.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, \"default action should be preserved\")\n\trequire.Len(t, proxy.updated.Egress, 3, \"expected 3 egress rules\")\n\trequire.Equal(t, policy.ActionDeny, proxy.updated.Egress[0].Action, \"first rule action mismatch\")\n\trequire.Equal(t, \"blocked.com\", proxy.updated.Egress[0].Target, \"first rule target mismatch\")\n\trequire.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action, \"second rule action mismatch\")\n\trequire.Equal(t, \"example.com\", proxy.updated.Egress[1].Target, \"second rule target mismatch\")\n\trequire.Equal(t, policy.ActionDeny, proxy.updated.Egress[2].Action, \"base wildcard rule action mismatch\")\n\trequire.Equal(t, \"*.example.com\", proxy.updated.Egress[2].Target, \"base wildcard rule target mismatch\")\n}\n\nfunc TestHandlePatch_DomainCaseOverride(t *testing.T) {\n\tinitial := &policy.NetworkPolicy{\n\t\tDefaultAction: policy.ActionDeny,\n\t\tEgress: []policy.EgressRule{\n\t\t\t{Action: policy.ActionDeny, Target: \"Example.COM\"},\n\t\t},\n\t}\n\tproxy := &stubProxy{updated: initial}\n\tnft := &stubNft{}\n\tsrv := &policyServer{proxy: proxy, nft: nft, enforcementMode: \"dns+nft\"}\n\n\tbody := `[{\"action\":\"allow\",\"target\":\"example.com\"}]`\n\treq := httptest.NewRequest(http.MethodPatch, \"/policy\", strings.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\tsrv.handlePolicy(w, req)\n\n\tresp := w.Result()\n\trequire.Equal(t, http.StatusOK, resp.StatusCode, \"expected 200\")\n\trequire.NotNil(t, proxy.updated, \"expected proxy policy to be updated\")\n\trequire.Len(t, proxy.updated.Egress, 1, \"expected deduped rule count 1\")\n\trequire.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action, \"expected allow action\")\n\trequire.Equal(t, \"example.com\", proxy.updated.Egress[0].Target, \"expected allow example.com to override\")\n}\n"
  },
  {
    "path": "components/egress/tests/bench-dns-nft.sh",
    "content": "#!/bin/bash\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# E2E benchmark: baseline (no egress) vs dns (pass-through) vs dns+nft (sync dynamic IP write).\n# Baseline: plain curl container, same workload, no container. Then egress dns and dns+nft.\n# Metrics: E2E latency (p50, p99), throughput (req/s).\n#\n# Usage: ./tests/bench-dns-nft.sh\n# Optional: BENCH_SAMPLE_SIZE=n to randomly use n domains from hostname.txt (default: use all).\n# Requires: Docker, curl in PATH (for policy push). Egress image and baseline image (default curlimages/curl:latest) must have curl.\n# Domain list: tests/hostname.txt (one domain per line).\n\nset -euo pipefail\n\ninfo() { echo \"[$(date +%H:%M:%S)] $*\"; }\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nHOSTNAME_FILE=\"${SCRIPT_DIR}/hostname.txt\"\n# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels.\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/../../..\" && pwd)\"\n\nIMG=\"opensandbox/egress:local\"\nBASELINE_IMG=\"${BASELINE_IMG:-curlimages/curl:latest}\"\nCONTAINER_NAME=\"egress-bench-e2e\"\nPOLICY_PORT=18080\nROUNDS=10\n# Optional: where to write egress logs on host. Override via LOG_HOST_DIR / LOG_FILE.\nLOG_HOST_DIR=\"${LOG_HOST_DIR:-/tmp/egress-logs}\"\nLOG_FILE=\"${LOG_FILE:-egress.log}\"\nLOG_CONTAINER_DIR=\"/var/log/opensandbox\"\nLOG_CONTAINER_FILE=\"${LOG_CONTAINER_DIR}/${LOG_FILE}\"\n\n# Load benchmark domains from hostname.txt (one domain per line).\nif [[ ! -f \"${HOSTNAME_FILE}\" ]] || [[ ! -s \"${HOSTNAME_FILE}\" ]]; then\n  echo \"Error: domain file not found or empty: ${HOSTNAME_FILE}\" >&2\n  exit 1\nfi\nBENCH_DOMAINS=()\nwhile IFS= read -r line; do\n  line=\"${line%%#*}\"\n  line=\"${line#\"${line%%[![:space:]]*}\"}\"\n  line=\"${line%\"${line##*[![:space:]]}\"}\"\n  [[ -n \"$line\" ]] && BENCH_DOMAINS+=( \"$line\" )\ndone < \"${HOSTNAME_FILE}\"\ntotal_in_file=${#BENCH_DOMAINS[@]}\nif [[ \"$total_in_file\" -eq 0 ]]; then\n  echo \"Error: no domains in ${HOSTNAME_FILE}\" >&2\n  exit 1\nfi\n\n# Optionally randomly sample n domains (BENCH_SAMPLE_SIZE); if unset or 0, use all.\nif [[ -n \"${BENCH_SAMPLE_SIZE:-}\" ]] && [[ \"${BENCH_SAMPLE_SIZE}\" -gt 0 ]]; then\n  if [[ \"${BENCH_SAMPLE_SIZE}\" -ge \"$total_in_file\" ]]; then\n    NUM_DOMAINS=$total_in_file\n  else\n    # Portable shuffle: shuf (Linux), gshuf (macOS coreutils), else awk\n    if command -v shuf >/dev/null 2>&1; then\n      BENCH_DOMAINS=( $(printf '%s\\n' \"${BENCH_DOMAINS[@]}\" | shuf -n \"${BENCH_SAMPLE_SIZE}\") )\n    elif command -v gshuf >/dev/null 2>&1; then\n      BENCH_DOMAINS=( $(printf '%s\\n' \"${BENCH_DOMAINS[@]}\" | gshuf -n \"${BENCH_SAMPLE_SIZE}\") )\n    else\n      BENCH_DOMAINS=( $(printf '%s\\n' \"${BENCH_DOMAINS[@]}\" | awk 'BEGIN{srand()} {printf \"%s\\t%s\\n\", rand(), $0}' | sort -n | cut -f2- | head -n \"${BENCH_SAMPLE_SIZE}\") )\n    fi\n    NUM_DOMAINS=${#BENCH_DOMAINS[@]}\n    info \"Using ${NUM_DOMAINS} randomly sampled domains (of ${total_in_file}) from ${HOSTNAME_FILE}\"\n  fi\nelse\n  NUM_DOMAINS=$total_in_file\nfi\nTOTAL_REQUESTS=$((ROUNDS * NUM_DOMAINS))\nCURL_TIMEOUT=10\n# Max wall time for the benchmark loop (docker exec); avoid hanging forever.\nBENCH_EXEC_TIMEOUT=300\n\ncleanup() {\n  docker rm -f \"${CONTAINER_NAME}\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\n# Compute stats from a file with one numeric value per line (e.g. time_total in seconds).\n# Output: count avg_s p50_s p99_s\nstats() {\n  local file=\"$1\"\n  if [[ ! -f \"$file\" ]] || [[ ! -s \"$file\" ]]; then\n    echo \"0 0 0 0\"\n    return\n  fi\n  sort -n \"$file\" > \"${file}.sorted\"\n  local n\n  n=$(wc -l < \"${file}.sorted\")\n  if [[ \"$n\" -eq 0 ]]; then\n    echo \"0 0 0 0\"\n    return\n  fi\n  local avg p50 p99\n  avg=$(awk '{s+=$1; c++} END { if(c>0) print s/c; else print 0 }' \"$file\")\n  p50=$(awk -v n=\"$n\" 'NR==int(n*0.5+0.5){print $1; exit}' \"${file}.sorted\")\n  p99=$(awk -v n=\"$n\" 'NR==int(n*0.99+0.5){print $1; exit}' \"${file}.sorted\")\n  echo \"$n $avg $p50 $p99\"\n}\n\n# Run workload inside CONTAINER_NAME; /tmp/bench-domains.txt must already exist in container.\n# Usage: run_bench_to <outfile> [limit] [rounds] [timeout]\nrun_bench_to() {\n  local outfile=\"$1\"\n  local limit=\"${2:-9999}\"\n  local rounds=\"${3:-1}\"\n  local use_timeout=\"${4:-}\"\n  local cmd=(\n    docker exec -e BENCH_TIMEOUT=\"${CURL_TIMEOUT}\" -e BENCH_OUTFILE=\"${outfile}\" -e BENCH_LIMIT=\"${limit}\" -e BENCH_ROUNDS=\"${rounds}\" \\\n      \"${CONTAINER_NAME}\" sh -c '\n    : > \"$BENCH_OUTFILE\"\n    r=1\n    while [ \"$r\" -le \"$BENCH_ROUNDS\" ]; do\n      n=0\n      while IFS= read -r url && [ \"$n\" -lt \"$BENCH_LIMIT\" ]; do\n        ( curl -o /dev/null -s -I -w \"%{time_namelookup}\\t%{time_total}\\n\" --max-time \"$BENCH_TIMEOUT\" \"$url\" >> \"$BENCH_OUTFILE\" ) &\n        n=$((n+1))\n      done < /tmp/bench-domains.txt\n      wait\n      r=$((r+1))\n    done\n    '\n  )\n  if [[ \"$use_timeout\" == \"timeout\" ]] && command -v timeout >/dev/null 2>&1; then\n    timeout \"${BENCH_EXEC_TIMEOUT}\" \"${cmd[@]}\"\n  else\n    \"${cmd[@]}\"\n  fi\n}\n\n# Copy URL file into container (create temp file, docker cp, rm). Uses BENCH_DOMAINS.\ncopy_url_file_to_container() {\n  local url_file=\"/tmp/bench-e2e-domains-$$.txt\"\n  : > \"${url_file}\"\n  for d in \"${BENCH_DOMAINS[@]}\"; do\n    echo \"https://${d}\" >> \"${url_file}\"\n  done\n  docker cp \"${url_file}\" \"${CONTAINER_NAME}:/tmp/bench-domains.txt\"\n  rm -f \"${url_file}\"\n}\n\n# Run warm-up + timed benchmark, collect timings. Writes /tmp/bench-e2e-{mode}-total.txt, -namelookup.txt, -wall.txt.\n# Requires: CONTAINER_NAME running, /tmp/bench-domains.txt inside container.\nrun_workload() {\n  local mode=\"$1\"\n  local out_total=\"/tmp/bench-e2e-${mode}-total.txt\"\n  local out_namelookup=\"/tmp/bench-e2e-${mode}-namelookup.txt\"\n  : > \"$out_total\"\n  : > \"$out_namelookup\"\n\n  local first_url=\"https://${BENCH_DOMAINS[0]}\"\n  sleep 1\n  # HEAD request: no response body, only check DNS + TCP + TLS + HTTP response.\n  if ! docker exec \"${CONTAINER_NAME}\" curl -o /dev/null -s -I --max-time \"${CURL_TIMEOUT}\" \"${first_url}\"; then\n    info \"Warm-up curl failed; stderr from one attempt:\"\n    docker exec \"${CONTAINER_NAME}\" curl -o /dev/null -s -I --max-time 5 \"${first_url}\" 2>&1 || true\n    return 1\n  fi\n\n  info \"Warm-up: first 10 domains, 1 round...\"\n  bench_ret=0\n  run_bench_to /tmp/bench-warmup.txt 10 1 2>/tmp/bench-e2e-stderr.txt || bench_ret=$?\n  if [[ \"$bench_ret\" -ne 0 ]]; then\n    info \"Warm-up run failed (exit $bench_ret); continuing with timed run anyway.\"\n  fi\n\n  info \"Running ${TOTAL_REQUESTS} E2E requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains) inside container (max ${BENCH_EXEC_TIMEOUT}s)...\"\n  local start_ts\n  start_ts=$(date +%s.%N)\n  bench_ret=0\n  run_bench_to /tmp/bench-raw.txt 9999 \"${ROUNDS}\" timeout 2>/tmp/bench-e2e-stderr.txt || bench_ret=$?\n  if [[ \"$bench_ret\" -ne 0 ]]; then\n    info \"Benchmark run failed (exit $bench_ret) or hit timeout; using partial results if any.\"\n  fi\n  docker cp \"${CONTAINER_NAME}:/tmp/bench-raw.txt\" /tmp/bench-e2e-raw.txt 2>/dev/null || true\n  local end_ts\n  end_ts=$(date +%s.%N)\n\n  if [[ -s /tmp/bench-e2e-stderr.txt ]]; then\n    info \"docker exec stderr (first 10 lines):\"\n    head -10 /tmp/bench-e2e-stderr.txt >&2\n  fi\n  if [[ ! -f /tmp/bench-e2e-raw.txt ]]; then\n    : > /tmp/bench-e2e-raw.txt\n  fi\n  local lines\n  lines=$(wc -l < /tmp/bench-e2e-raw.txt 2>/dev/null || echo 0)\n  if [[ \"$lines\" -lt $((TOTAL_REQUESTS / 2)) ]]; then\n    info \"WARN: only ${lines}/${TOTAL_REQUESTS} responses captured; curl may be failing inside container.\"\n  fi\n\n  awk -F'\\t' '{print $2}' /tmp/bench-e2e-raw.txt 2>/dev/null > \"$out_total\"\n  awk -F'\\t' '{print $1}' /tmp/bench-e2e-raw.txt 2>/dev/null > \"$out_namelookup\"\n  local wall_s\n  wall_s=$(awk -v s=\"$start_ts\" -v e=\"$end_ts\" 'BEGIN { print e - s }')\n  echo \"$wall_s\" > \"/tmp/bench-e2e-${mode}-wall.txt\"\n}\n\n# Run one benchmark phase: start container with given mode, push policy, run client workload, collect timings.\n# Usage: run_phase \"dns\" | \"dns+nft\"\nrun_phase() {\n  local mode=\"$1\"\n  info \"Phase: ${mode}\"\n  cleanup\n  mkdir -p \"${LOG_HOST_DIR}\"\n  docker run -d --name \"${CONTAINER_NAME}\" \\\n    --cap-add=NET_ADMIN \\\n    --sysctl net.ipv6.conf.all.disable_ipv6=1 \\\n    --sysctl net.ipv6.conf.default.disable_ipv6=1 \\\n    -e OPENSANDBOX_EGRESS_MODE=\"${mode}\" \\\n    -e OPENSANDBOX_LOG_OUTPUT=\"${LOG_CONTAINER_FILE}\" \\\n    -v \"${LOG_HOST_DIR}:${LOG_CONTAINER_DIR}\" \\\n    -p \"${POLICY_PORT}:18080\" \\\n    \"${IMG}\"\n\n  for i in $(seq 1 30); do\n    if curl -sf \"http://127.0.0.1:${POLICY_PORT}/healthz\" >/dev/null 2>&1; then\n      break\n    fi\n    sleep 0.5\n  done\n\n  local policy_egress=\"\"\n  for d in \"${BENCH_DOMAINS[@]}\"; do\n    policy_egress=\"${policy_egress}{\\\"action\\\":\\\"allow\\\",\\\"target\\\":\\\"${d}\\\"},\"\n  done\n  policy_egress=\"${policy_egress%,}\"\n  local policy_json=\"{\\\"defaultAction\\\":\\\"deny\\\",\\\"egress\\\":[${policy_egress}]}\"\n  curl -sf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" -d \"${policy_json}\" >/dev/null\n\n  copy_url_file_to_container\n  run_workload \"${mode}\"\n}\n\n# Run baseline phase: plain curl container, no egress container. Same workload for comparison.\nrun_phase_baseline() {\n  info \"Phase: baseline (no egress)\"\n  cleanup\n  docker pull \"${BASELINE_IMG}\" > /dev/null 2>&1\n  docker run -d --name \"${CONTAINER_NAME}\" \"${BASELINE_IMG}\" sleep 3600\n  sleep 2\n  copy_url_file_to_container\n  run_workload \"baseline\"\n}\n\n# Print comparison table (baseline, dns, dns+nft)\nreport() {\n  local nb n1 n2 avg0 avg1 avg2 p50_0 p50_1 p50_2 p99_0 p99_1 p99_2 wall0 wall1 wall2\n  read -r nb avg0 p50_0 p99_0 <<< \"$(stats /tmp/bench-e2e-baseline-total.txt)\"\n  read -r n1 avg1 p50_1 p99_1 <<< \"$(stats /tmp/bench-e2e-dns-total.txt)\"\n  read -r n2 avg2 p50_2 p99_2 <<< \"$(stats /tmp/bench-e2e-dns+nft-total.txt)\"\n  wall0=$(cat /tmp/bench-e2e-baseline-wall.txt 2>/dev/null || echo \"0\")\n  wall1=$(cat /tmp/bench-e2e-dns-wall.txt 2>/dev/null || echo \"0\")\n  wall2=$(cat /tmp/bench-e2e-dns+nft-wall.txt 2>/dev/null || echo \"0\")\n  if [[ \"${nb:-0}\" -eq 0 ]] || [[ \"${n1:-0}\" -eq 0 ]] || [[ \"${n2:-0}\" -eq 0 ]]; then\n    echo \"WARN: some phases had no successful requests; check container logs and network.\"\n  fi\n\n  local rps0 rps1 rps2\n  rps0=$(awk -v n=\"$nb\" -v w=\"$wall0\" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }')\n  rps1=$(awk -v n=\"$n1\" -v w=\"$wall1\" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }')\n  rps2=$(awk -v n=\"$n2\" -v w=\"$wall2\" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }')\n\n  echo \"\"\n  echo \"========== E2E benchmark: baseline vs dns vs dns+nft ==========\"\n  echo \"Workload: ${TOTAL_REQUESTS} requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains)\"\n  echo \"\"\n  local ov_avg1 ov_p50_1 ov_p99_1 ov_rps1 ov_avg2 ov_p50_2 ov_p99_2 ov_rps2\n  ov_avg1=$(awk -v a=\"$avg1\" -v b=\"$avg0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_p50_1=$(awk -v a=\"$p50_1\" -v b=\"$p50_0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_p99_1=$(awk -v a=\"$p99_1\" -v b=\"$p99_0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_rps1=$(awk -v a=\"$rps1\" -v b=\"$rps0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (b-a)/b*100 : 0 }')\n  ov_avg2=$(awk -v a=\"$avg2\" -v b=\"$avg0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_p50_2=$(awk -v a=\"$p50_2\" -v b=\"$p50_0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_p99_2=$(awk -v a=\"$p99_2\" -v b=\"$p99_0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (a-b)/b*100 : 0 }')\n  ov_rps2=$(awk -v a=\"$rps2\" -v b=\"$rps0\" 'BEGIN { printf \"%+.1f\", (b>0 && b!=\"\") ? (b-a)/b*100 : 0 }')\n\n  printf \"%-10s %14s %20s %20s %20s\\n\" \"Mode\" \"Req/s\" \"Avg(s)\" \"P50(s)\" \"P99(s)\"\n  printf \"%-10s %14s %20s %20s %20s\\n\" \"baseline\" \"$rps0\" \"$avg0\" \"$p50_0\" \"$p99_0\"\n  printf \"%-10s %14s %20s %20s %20s\\n\" \"dns\"      \"$(printf '%.2f(%s%%)' \"$rps1\" \"$ov_rps1\")\" \"$(printf '%.3f(%s%%)' \"$avg1\" \"$ov_avg1\")\" \"$(printf '%.3f(%s%%)' \"$p50_1\" \"$ov_p50_1\")\" \"$(printf '%.3f(%s%%)' \"$p99_1\" \"$ov_p99_1\")\"\n  printf \"%-10s %14s %20s %20s %20s\\n\" \"dns+nft\"  \"$(printf '%.2f(%s%%)' \"$rps2\" \"$ov_rps2\")\" \"$(printf '%.3f(%s%%)' \"$avg2\" \"$ov_avg2\")\" \"$(printf '%.3f(%s%%)' \"$p50_2\" \"$ov_p50_2\")\" \"$(printf '%.3f(%s%%)' \"$p99_2\" \"$ov_p99_2\")\"\n  echo \"\"\n  echo \"Overhead in parentheses vs baseline: latency +%% = slower, Req/s -%% = lower throughput.\"\n  echo \"baseline: Plain container (${BASELINE_IMG}), no egress container.\"\n  echo \"dns:      DNS proxy only, no nft write (pass-through).\"\n  echo \"dns+nft:  DNS proxy + sync AddResolvedIPs before each DNS reply (L2 enforcement).\"\n  echo \"\"\n  echo \"Note: Warm-up runs before each phase. Baseline gives no-proxy comparison.\"\n  echo \"==========\"\n}\n\ninfo \"Building image ${IMG}\"\ndocker build -t \"${IMG}\" -f \"${REPO_ROOT}/components/egress/Dockerfile\" \"${REPO_ROOT}\" > /dev/null 2>&1\n\nrun_phase_baseline\nrun_phase \"dns+nft\"\nrun_phase \"dns\"\nreport\ninfo \"Cleaning up\"\ncleanup\n"
  },
  {
    "path": "components/egress/tests/egress-in-webhook.sh",
    "content": "#!/bin/bash\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\ndocker run -d --name egress \\\n  --rm \\\n  --cap-add=NET_ADMIN \\\n  --sysctl net.ipv6.conf.all.disable_ipv6=1 \\\n  --sysctl net.ipv6.conf.default.disable_ipv6=1 \\\n  -e OPENSANDBOX_EGRESS_MODE=dns+nft \\\n  -e OPENSANDBOX_EGRESS_DENY_WEBHOOK=http://<webhook.svc>:8000 \\\n  -e OPENSANDBOX_EGRESS_SANDBOX_ID=mytest \\\n  -p 18080:18080 \\\n  \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest\"\n\n\nsleep 5\ncurl -sSf -XPOST \"http://127.0.0.1:18080/policy\" \\\n  -d '{\"defaultAction\":\"allow\",\"egress\":[{\"action\":\"deny\",\"target\":\"*.github.com\"},{\"action\":\"deny\",\"target\":\"10.0.0.0/8\"}]}'"
  },
  {
    "path": "components/egress/tests/hostname.txt",
    "content": "example.com\nexample.org\nexample.net\nexample.edu\nexample.io\ngithub.com\ngithub.io\ngoogle.com\ncloudflare.com\namazon.com\nwikipedia.org\nmozilla.org\napple.com\nmicrosoft.com\nyahoo.com\nfacebook.com\ntwitter.com\ninstagram.com\nlinkedin.com\nreddit.com\nstackoverflow.com\nnpmjs.com\npython.org\ngolang.org\nrust-lang.org\ndocker.com\nkubernetes.io\napache.org\ngnu.org\nkernel.org\nibm.com\noracle.com\nopenai.com\nanthropic.com\nstripe.com\nslack.com\ndropbox.com\nspotify.com\nnetflix.com\ntwitch.tv\ndiscord.com\nzoom.us\nmedium.com\nsubstack.com\nblogger.com\ntumblr.com\nimgur.com\nflickr.com\nvimeo.com\nsoundcloud.com\nbandcamp.com\npatreon.com\nkickstarter.com\netsy.com\nebay.com\ncraigslist.org\nalibaba.com\nbing.com\nduckduckgo.com\nbrave.com\nopera.com\nprotonmail.com\nfastmail.com\nzoho.com\nnotion.so\ntrello.com\nasana.com\natlassian.com\nbitbucket.org\ngitlab.com\nsourceforge.net\ncodepen.io\nvercel.com\nnetlify.com\nheroku.com\ndigitalocean.com\nlinode.com\nvultr.com\novh.com\nhetzner.com\nscaleway.com\narchlinux.org\ndebian.org\nubuntu.com\nfedoraproject.org\nopensuse.org\nfreebsd.org\nopenbsd.org\nmysql.com\nmongodb.com\nredis.io\nelastic.co\nnodejs.org\nreactjs.org\nvuejs.org\nsvelte.dev\nnextjs.org\nnuxtjs.org\njquery.com\nbootstrap.com\ntailwindcss.com\n"
  },
  {
    "path": "components/egress/tests/smoke-dns.sh",
    "content": "#!/bin/bash\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Simple smoke test using local image.\n# Requires Docker with --cap-add=NET_ADMIN available.\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels.\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/../../..\" && pwd)\"\n\nIMG=\"opensandbox/egress:local\"\ncontainerName=\"egress-smoke-dns\"\nPOLICY_PORT=18080\n\ninfo() { echo \"[$(date +%H:%M:%S)] $*\"; }\n\ncleanup() {\n  docker rm -f \"${containerName}\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\ninfo \"Building image ${IMG}\"\ndocker build -t \"${IMG}\" -f \"${REPO_ROOT}/components/egress/Dockerfile\" \"${REPO_ROOT}\"\n\ninfo \"Starting containerName\"\ndocker run -d --name \"${containerName}\" \\\n  --cap-add=NET_ADMIN \\\n  --sysctl net.ipv6.conf.all.disable_ipv6=1 \\\n  --sysctl net.ipv6.conf.default.disable_ipv6=1 \\\n  -e OPENSANDBOX_EGRESS_MODE=dns \\\n  -p ${POLICY_PORT}:18080 \\\n  \"${IMG}\"\n\ninfo \"Waiting for policy server...\"\nfor i in {1..50}; do\n  if curl -sf \"http://127.0.0.1:${POLICY_PORT}/healthz\" >/dev/null; then\n    break\n  fi\n  sleep 0.5\ndone\n\ninfo \"Pushing policy (allow by default; deny github.com & 10.0.0.0/8)\"\ncurl -sSf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"*.github.com\"}]}'\n\nrun_in_app() {\n  docker run --rm --network container:\"${containerName}\" curlimages/curl \"$@\"\n}\n\npass() { info \"PASS: $*\"; }\nfail() { echo \"FAIL: $*\" >&2; exit 1; }\n\ninfo \"Test: denied domain should fail (google.com)\"\nif run_in_app -I https://google.com --max-time 5 >/dev/null 2>&1; then\n  fail \"google.com should be blocked\"\nelse\n  pass \"google.com blocked\"\nfi\n\ninfo \"Test: allowed domain should succeed (api.github.com)\"\nrun_in_app -I https://api.github.com --max-time 10 >/dev/null 2>&1 || fail \"api.github.com should succeed\"\npass \"api.github.com allowed\"\n\ninfo \"All smoke tests passed.\""
  },
  {
    "path": "components/egress/tests/smoke-dynamic-ip.sh",
    "content": "#!/bin/bash\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Smoke test: default deny + domain allow in dns+nft mode.\n# Verifies that allowing a domain causes its resolved IP to be added to nft (dynamic IP),\n# so that curl to that domain succeeds without static IP/CIDR in policy.\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels.\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/../../..\" && pwd)\"\n\nIMG=\"opensandbox/egress:local\"\ncontainerName=\"egress-smoke-dynamic-ip\"\nPOLICY_PORT=18080\n\ninfo() { echo \"[$(date +%H:%M:%S)] $*\"; }\n\ncleanup() {\n  docker rm -f \"${containerName}\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\ninfo \"Building image ${IMG}\"\ndocker build -t \"${IMG}\" -f \"${REPO_ROOT}/components/egress/Dockerfile\" \"${REPO_ROOT}\"\n\ninfo \"Starting sidecar (dns+nft)\"\ndocker run -d --name \"${containerName}\" \\\n  --cap-add=NET_ADMIN \\\n  --sysctl net.ipv6.conf.all.disable_ipv6=1 \\\n  --sysctl net.ipv6.conf.default.disable_ipv6=1 \\\n  -e OPENSANDBOX_EGRESS_MODE=dns+nft \\\n  -p ${POLICY_PORT}:18080 \\\n  \"${IMG}\"\n\ninfo \"Waiting for policy server...\"\nfor i in $(seq 1 50); do\n  if curl -sf \"http://127.0.0.1:${POLICY_PORT}/healthz\" >/dev/null; then\n    break\n  fi\n  sleep 0.5\ndone\n\ninfo \"Pushing policy (default deny; allow google.com only)\"\ncurl -sSf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"google.com\"}]}'\n\nrun_in_app() {\n  docker run --rm --network container:\"${containerName}\" curlimages/curl \"$@\"\n}\n\npass() { info \"PASS: $*\"; }\nfail() { echo \"FAIL: $*\" >&2; exit 1; }\n\ninfo \"Test: allowed domain (google.com) should succeed via dynamic IP\"\nrun_in_app -I https://google.com --max-time 15 >/dev/null 2>&1 || fail \"google.com should succeed (DNS allow + dynamic IP in nft)\"\npass \"google.com allowed\"\n\ninfo \"Test: denied domain (api.github.com) should fail\"\nif run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then\n  fail \"api.github.com should be blocked\"\nelse\n  pass \"api.github.com blocked\"\nfi\n\ninfo \"Test: denied IP (1.1.1.1) should fail\"\nif run_in_app -I 1.1.1.1 --max-time 8 >/dev/null 2>&1; then\n  fail \"1.1.1.1 should be blocked\"\nelse\n  pass \"1.1.1.1 blocked\"\nfi\n\ninfo \"All smoke tests (dynamic IP) passed.\"\n"
  },
  {
    "path": "components/egress/tests/smoke-nft.sh",
    "content": "#!/bin/bash\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Simple smoke test using local image.\n# Requires Docker with --cap-add=NET_ADMIN available.\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels.\nREPO_ROOT=\"$(cd \"${SCRIPT_DIR}/../../..\" && pwd)\"\n\nIMG=\"opensandbox/egress:local\"\ncontainerName=\"egress-smoke-nft\"\nPOLICY_PORT=18080\n\ninfo() { echo \"[$(date +%H:%M:%S)] $*\"; }\n\ncleanup() {\n  docker rm -f \"${containerName}\" >/dev/null 2>&1 || true\n}\ntrap cleanup EXIT\n\ninfo \"Building image ${IMG}\"\ndocker build -t \"${IMG}\" -f \"${REPO_ROOT}/components/egress/Dockerfile\" \"${REPO_ROOT}\"\n\ninfo \"Starting containerName\"\ndocker run -d --name \"${containerName}\" \\\n  --cap-add=NET_ADMIN \\\n  --sysctl net.ipv6.conf.all.disable_ipv6=1 \\\n  --sysctl net.ipv6.conf.default.disable_ipv6=1 \\\n  -e OPENSANDBOX_EGRESS_MODE=dns+nft \\\n  -p ${POLICY_PORT}:18080 \\\n  \"${IMG}\"\n\ninfo \"Waiting for policy server...\"\nfor i in {1..50}; do\n  if curl -sf \"http://127.0.0.1:${POLICY_PORT}/healthz\" >/dev/null; then\n    break\n  fi\n  sleep 0.5\ndone\n\ninfo \"Pushing policy (allow by default; deny github.com & 10.0.0.0/8)\"\ncurl -sSf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '{\"defaultAction\":\"allow\",\"egress\":[{\"action\":\"deny\",\"target\":\"*.github.com\"},{\"action\":\"deny\",\"target\":\"10.0.0.0/8\"}]}'\n\nrun_in_app() {\n  docker run --rm --network container:\"${containerName}\" curlimages/curl \"$@\"\n}\n\npass() { info \"PASS: $*\"; }\nfail() { echo \"FAIL: $*\" >&2; exit 1; }\n\ninfo \"Test: allowed domain should succeed (google.com)\"\nrun_in_app -I https://google.com --max-time 10 >/dev/null 2>&1 || fail \"google.com should succeed\"\npass \"google.com allowed\"\n\ninfo \"Test: denied domain should fail (api.github.com)\"\nif run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then\n  fail \"api.github.com should be blocked\"\nelse\n  pass \"api.github.com blocked\"\nfi\n\ninfo \"Test: allowed IP should succeed (1.1.1.1)\"\nrun_in_app -I https://1.1.1.1 --max-time 10 >/dev/null 2>&1 || fail \"1.1.1.1 should succeed\"\npass \"1.1.1.1 allowed\"\n\ninfo \"Test: denied CIDR should fail (10.0.0.1)\"\nif run_in_app -I http://10.0.0.1 --max-time 5 >/dev/null 2>&1; then\n  fail \"10.0.0.1 should be blocked\"\nelse\n  pass \"10.0.0.1 blocked\"\nfi\n\ninfo \"Test: DoT (853) should be blocked\"\nif run_in_app -k https://1.1.1.1:853 --max-time 5 >/dev/null 2>&1; then\n  fail \"DoT 853 should be blocked\"\nelse\n  pass \"DoT 853 blocked\"\nfi\n\ninfo \"Rules update: wildcard deny -> patch allow specific (dns+nft)\"\ncurl -sSf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '{\"defaultAction\":\"allow\",\"egress\":[{\"action\":\"deny\",\"target\":\"*.cloudflare.com\"}]}'\n\ninfo \"Test: www.cloudflare.com should be blocked initially (deny via wildcard)\"\nif run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then\n  fail \"www.cloudflare.com should be blocked before patch\"\nelse\n  pass \"www.cloudflare.com blocked before patch\"\nfi\n\ninfo \"Patching allow for www.cloudflare.com (specific should override earlier deny)\"\ncurl -sSf -XPATCH \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '[{\"action\":\"allow\",\"target\":\"www.cloudflare.com\"}]'\n\ninfo \"Test: www.cloudflare.com should be allowed after patch\"\nrun_in_app -I https://www.cloudflare.com --max-time 10 >/dev/null 2>&1 || fail \"www.cloudflare.com should succeed after patch\"\npass \"www.cloudflare.com allowed after patch\"\n\ninfo \"Rules update: wildcard allow -> patch deny specific (dns+nft)\"\ncurl -sSf -XPOST \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '{\"defaultAction\":\"deny\",\"egress\":[{\"action\":\"allow\",\"target\":\"*.mozilla.org\"}]}'\n\ninfo \"Test: www.mozilla.org should be allowed initially (allow via wildcard)\"\nrun_in_app -I https://www.mozilla.org --max-time 10 >/dev/null 2>&1 || fail \"www.mozilla.org should succeed before patch\"\npass \"www.mozilla.org allowed before patch\"\n\ninfo \"Patching deny for www.mozilla.org (specific should override earlier allow)\"\ncurl -sSf -XPATCH \"http://127.0.0.1:${POLICY_PORT}/policy\" \\\n  -d '[{\"action\":\"deny\",\"target\":\"www.mozilla.org\"}]'\n\ninfo \"Test: www.mozilla.org should be blocked after patch\"\nif run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then\n  fail \"www.mozilla.org should be blocked after patch\"\nelse\n  pass \"www.mozilla.org blocked after patch\"\nfi\n\ninfo \"All smoke tests passed.\""
  },
  {
    "path": "components/egress/tests/webhook-server.py",
    "content": "#!/usr/bin/env python3\n\n# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nLightweight HTTP server to receive OPENSANDBOX_EGRESS_DENY_WEBHOOK callbacks.\n\nConfig:\n- WEBHOOK_HOST: listen address (default 0.0.0.0)\n- WEBHOOK_PORT: listen port (default 8000)\n- WEBHOOK_PATH: webhook path (default /)\n\nRun:\n  python webhook_server.py\nThen point OPENSANDBOX_EGRESS_DENY_WEBHOOK to http://<host>:<port><path>\n\"\"\"\n\nimport http.server\nimport json\nimport os\nimport socketserver\nfrom datetime import datetime\n\nHOST = os.getenv(\"WEBHOOK_HOST\", \"0.0.0.0\")\nPORT = int(os.getenv(\"WEBHOOK_PORT\", \"8000\"))\nPATH = os.getenv(\"WEBHOOK_PATH\", \"/\")\n\n\nclass WebhookHandler(http.server.BaseHTTPRequestHandler):\n    def _send(self, code: int = 200, body: str = \"ok\") -> None:\n        self.send_response(code)\n        self.send_header(\"Content-Type\", \"text/plain; charset=utf-8\")\n        self.end_headers()\n        self.wfile.write(body.encode(\"utf-8\"))\n\n    def do_POST(self) -> None:  # noqa: N802 (BaseHTTPRequestHandler API)\n        # Only allow the configured path\n        if self.path != PATH:\n            self._send(404, \"not found\")\n            return\n\n        length = int(self.headers.get(\"Content-Length\", 0))\n        raw = self.rfile.read(length) if length else b\"\"\n\n        payload = raw.decode(\"utf-8\", errors=\"replace\")\n        try:\n            parsed = json.loads(payload)\n        except json.JSONDecodeError:\n            parsed = None\n\n        # Log request info for debugging\n        print(f\"\\n[{datetime.utcnow().isoformat()}Z] Received webhook\")\n        print(f\"Path: {self.path}\")\n        print(f\"Headers: {dict(self.headers)}\")\n        print(f\"Raw body: {payload}\")\n        if parsed is not None:\n            print(\"Parsed JSON:\")\n            print(json.dumps(parsed, indent=2))\n\n        self._send(200, \"received\")\n\n    # Silence default logging to reduce noise\n    def log_message(self, *args) -> None:\n        return\n\n\ndef main() -> None:\n    with socketserver.TCPServer((HOST, PORT), WebhookHandler) as httpd:\n        print(f\"Listening on http://{HOST}:{PORT}{PATH} ...\")\n        httpd.serve_forever()\n\n\nif __name__ == \"__main__\":\n    main()"
  },
  {
    "path": "components/execd/.golangci.yml",
    "content": "run:\n  skip-dirs:\n    - vendor\n    - tests\n    - scripts\n  skip-files:\n    - .*/zz_generated.deepcopy.go\n    - .*/mock/*.go\n  tests: false\n  timeout: 10m\nlinters-settings:\n  funlen:\n    lines: 500\n    statements: 200\n  gocyclo:\n    min-complexity: 40\n  gosimple:\n    checks: [\"S1019\", \"S1002\"]\n  staticcheck:\n    checks: [\"SA4006\"]\n  govet:\n    enable:\n      - asmdecl\n      - assign\n      - atomic\n      - atomicalign\n      - bools\n      - buildtag\n      - cgocall\n      - copylocks\n      - deepequalerrors\n      - errorsas\n      - findcall\n      - framepointer\n      - httpresponse\n      - ifaceassert\n      - lostcancel\n      - nilfunc\n      - nilness\n      - reflectvaluecompare\n      - shift\n      - sigchanyzer\n      - sortslice\n      - stdmethods\n      - stringintconv\n      - testinggoroutine\n      - tests\n      - unmarshal\n      - unreachable\n      - unsafeptr\n      - unusedresult\n      - printf\n    disable:\n      - composites\n      - loopclosure\n      - fieldalignment\n      - shadow\n      - structtag\n      - unusedwrite\n  errcheck:\n    exclude-functions:\n    - flag.Set\n    - os.Setenv\n    - os.Unsetenv\n    - logger.Sync\n    - fmt.Fprintf\n    - fmt.Fprintln\n    - (io.Closer).Close\n    - (io.ReadCloser).Close\n    - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler\n  nestif:\n    min-complexity: 32\n  goconst:\n    # Minimal length of string constant.\n    # Default: 3\n    min-len: 3\n    # Minimum occurrences of constant string count to trigger issue.\n    # Default: 3\n    min-occurrences: 3\n    # Ignore test files.\n    # Default: false\n    ignore-tests: true\n    match-constant: false\n    numbers: true\n    min: 2\n    max: 10\n    ignore-calls: true\n  gosec:\n    includes:\n      - G101 # Look for hard coded credentials\n      - G102 # Bind to all interfaces\n      - G103 # Audit the use of unsafe block\n      - G104 # Audit errors not checked\n      - G106 # Audit the use of ssh.InsecureIgnoreHostKey\n      - G107 # Url provided to HTTP request as taint input\n      - G108 # Profiling endpoint automatically exposed on /debug/pprof\n      - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32\n      - G110 # Potential DoS vulnerability via decompression bomb\n      - G111 # Potential directory traversal\n      - G112 # Potential slowloris attack\n      - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772)\n      # - G114 # Use of net/http serve function that has no support for setting timeouts\n      - G201 # SQL query construction using format string\n      - G202 # SQL query construction using string concatenation\n      - G203 # Use of unescaped data in HTML templates\n      #- G204 # Audit use of command execution\n      - G301 # Poor file permissions used when creating a directory\n      - G302 # Poor file permissions used with chmod\n      - G303 # Creating tempfile using a predictable path\n      - G304 # File path provided as taint input\n      - G305 # File traversal when extracting zip/tar archive\n      - G306 # Poor file permissions used when writing to a new file\n      - G307 # Deferring a method which returns an error\n      #- G401 # Detect the usage of DES, RC4, MD5 or SHA1\n      - G402 # Look for bad TLS connection settings\n      - G403 # Ensure minimum RSA key length of 2048 bits\n      - G404 # Insecure random number source (rand)\n      #- G501 # Import blocklist: crypto/md5\n      - G502 # Import blocklist: crypto/des\n      - G503 # Import blocklist: crypto/rc4\n      - G504 # Import blocklist: net/http/cgi\n      - G505 # Import blocklist: crypto/sha1\n      - G601 # Implicit memory aliasing of items from a range statement\n    # Exclude generated files\n    # Default: false\n    exclude-generated: true\n    # Filter out the issues with a lower severity than the given value.\n    # Valid options are: low, medium, high.\n    # Default: low\n    severity: medium\n    # Filter out the issues with a lower confidence than the given value.\n    # Valid options are: low, medium, high.\n    # Default: low\n    confidence: medium\n    # Concurrency value.\n    # Default: the number of logical CPUs usable by the current process.\n    concurrency: 12\n    # To specify the configuration of rules.\n    config:\n      # Globals are applicable to all rules.\n      global:\n        nosec: true\n        show-ignored: true\n        audit: true\n      G101:\n        # Regexp pattern for variables and constants to find.\n        # Default: \"(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred\"\n        pattern: \"(?i)example\"\n        # If true, complain about all cases (even with low entropy).\n        # Default: false\n        ignore_entropy: false\n        # Maximum allowed entropy of the string.\n        # Default: \"80.0\"\n        entropy_threshold: \"80.0\"\n        per_char_threshold: \"3.0\"\n        truncate: \"32\"\n      G104:\n        fmt:\n          - Fscanf\n      G111:\n        # Regexp pattern to find potential directory traversal.\n        # Default: \"http\\\\.Dir\\\\(\\\"\\\\/\\\"\\\\)|http\\\\.Dir\\\\('\\\\/'\\\\)\"\n        pattern: \"custom\\\\.Dir\\\\(\\\\)\"\n      # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll\n      # Default: \"0750\"\n      G301: \"0750\"\n      # Maximum allowed permissions mode for os.OpenFile and os.Chmod\n      # Default: \"0600\"\n      G302: \"0600\"\n      # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile\n      # Default: \"0600\"\n      G306: \"0600\"\n  nilnil:\n    checked-types:\n      - ptr\n      - map\n      - chan\n  depguard:\n    rules:\n      prevent_unmaintained_packages:\n        list-mode: lax # allow unless explicitely denied\n        files:\n          - $all\n          - \"!$test\"\n        allow:\n          - $gostd\n          - path/filepath\n        deny:\n          - pkg: io/ioutil\n            desc: \"replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil\"\n          - pkg: path\n            desc: \"replaced by cross-platform package path/filepath\"\n  gci:\n    # Section configuration to compare against.\n    # Section names are case-insensitive and may contain parameters in ().\n    # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`,\n    # If `custom-order` is `true`, it follows the order of `sections` option.\n    # Default: [\"standard\", \"default\"]\n    sections:\n      - standard # Standard section: captures all standard packages.\n      - default # Default section: contains all imports that could not be matched to another section type.:\n      - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix.\n      - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled.\n      - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled.\n      - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled.\n    # Skip generated files.\n    # Default: true\n    skip-generated: true\n    # Enable custom order of sections.\n    # If `true`, make the section order the same as the order of `sections`.\n    # Default: false\n    custom-order: true\n    # Drops lexical ordering for custom sections.\n    # Default: false\n    no-lex-order: true\n  forbidigo:\n    forbid:\n      # Forbid spew Dump, whether it is called as function or method.\n      # Depends on analyze-types below.\n      - ^spew\\.(ConfigState\\.)?Dump$\n      # The package name might be ambiguous.\n      # The full import path can be used as additional criteria.\n      # Depends on analyze-types below.\n      - p: ^v1.Dump$\n        pkg: ^example.com/pkg/api/v1$\n\nlinters:\n  enable:\n    - asasalint\n    - asciicheck\n    - bidichk\n    - bodyclose\n    # - cyclop\n    - decorder\n    - depguard\n    - errcheck\n    # - errchkjson\n    - errorlint\n    - forbidigo\n    # - forcetypeassert\n    - funlen\n    - ineffassign\n    - gocognit\n    - gocyclo\n    - goheader\n    - gomodguard\n    - goprintffuncname\n    - gosimple\n    - gosec\n    - grouper\n    - importas\n    - maintidx\n    - misspell\n    - nakedret\n    - nilerr\n    - nilnil\n    # - noctx\n    - nosprintfhostport\n    - paralleltest\n    - predeclared\n    # - promlinter\n    - reassign\n    - sqlclosecheck\n    - staticcheck\n    - tenv\n    - testpackage\n    - tparallel\n    # del\n    # - typecheck\n    - usestdlibvars\n    - nestif\n    - unused\n    - makezero\n    - govet\n    - goconst\n    - gci\n    # - rowserrcheck\n    # 1.59 version no new lints\n    # 1.58 version new lints\n    # - fatcontext\n    - canonicalheader\n    # 1.57 version new lints\n    - copyloopvar\n    - intrange\n    # 1.56 version new lints\n    - spancheck\n    # 1.55 version new lints\n    - gochecksumtype\n    - perfsprint\n    - sloglint\n    - testifylint\n    - mirror\n    - zerologlint\n    # 1.51 version new lints\n    - gocheckcompilerdirectives\n    # 1.50 version new lints\n    - testableexamples\n\nissues:\n  # Note: path identifiers are regular expressions, hence the \\.go suffixes.\n  exclude-rules:\n    - path: main\\.go\n      linters:\n        - forbidigo\n    - path: _test\\.go\n      linters:\n        - dogsled\n        - errcheck\n        - goconst\n        - gosec\n        - ineffassign\n        - maintidx\n        - typecheck\n    - path: \\.go$\n      text: \"should have a package comment\"\n    - path: \\.go$\n      text: 'exported (.+) should have comment( \\(or a comment on this block\\))? or be unexported'\n    - path: \\.go$\n      text: \"fmt.Sprintf can be replaced with string concatenation\"\n"
  },
  {
    "path": "components/execd/DEVELOPMENT.md",
    "content": "# Development Guide - execd\n\nThis comprehensive guide explains how to work on `execd` as a contributor or maintainer. It covers environment setup,\ndevelopment workflows, testing strategies, architectural patterns, and subsystem-specific implementation details.\n\n## Table of Contents\n\n- [Getting Started](#getting-started)\n- [Project Structure](#project-structure)\n- [Coding Standards](#coding-standards)\n- [Testing Strategy](#testing-strategy)\n- [Subsystem Guides](#subsystem-guides)\n- [Common Development Tasks](#common-development-tasks)\n- [Debugging Techniques](#debugging-techniques)\n- [Performance Optimization](#performance-optimization)\n- [Contributing Guidelines](#contributing-guidelines)\n- [Additional Resources](#additional-resources)\n\n## Getting Started\n\n### Prerequisites\n\n#### Required Tools\n\n- **Go 1.24+** - Match the version declared in `go.mod`\n- **Git** - Version control\n- **Make** - Build automation (optional but recommended)\n\n#### Optional but Recommended\n\n- **golangci-lint** - For comprehensive linting\n- **Docker/Podman** - For containerized testing and deployment\n- **Jupyter Server** - Required for integration tests with real kernels\n- **VS Code/GoLand** - IDE with Go support\n\n### Initial Setup\n\n```bash\n# Clone the repository\ngit clone https://github.com/alibaba/OpenSandbox.git\ncd OpenSandbox/components/execd\n\n# Download dependencies\ngo mod download\n\n# Verify setup\ngo build -o bin/execd .\n```\n\n## Project Structure\n\n### Project Structure Deep Dive\n\n```\nexecd/\n├── main.go                 # Application entry point\n├── go.mod                  # Go module definition\n├── Makefile               # Build automation\n├── Dockerfile             # Container image definition\n│\n├── pkg/                   # Public packages\n│   ├── flag/              # CLI flag parsing\n│   ├── web/               # HTTP layer\n│   │   ├── router.go      # Route registration\n│   │   ├── controller/    # Request handlers\n│   │   └── model/         # API models\n│   ├── runtime/           # Execution engine\n│   │   ├── ctrl.go        # Main controller\n│   │   ├── jupyter.go     # Jupyter execution\n│   │   └── command.go     # Shell command execution\n│   ├── jupyter/           # Jupyter client\n│   │   ├── client.go      # HTTP/WebSocket client\n│   │   ├── session/       # Session management\n│   │   └── execute/       # Execution protocol\n│   └── util/              # Utilities\n│\n└── tests/                # Integration test scripts\n```\n\n### Key Design Patterns\n\n#### 1. Controller Pattern (pkg/web/controller)\n\nControllers are thin HTTP handlers that parse requests, validate, delegate to runtime, and stream responses via SSE.\n\n#### 2. Runtime Controller Pattern (pkg/runtime)\n\nThe runtime controller dispatches requests to appropriate executors (Jupyter, Command, SQL) and manages session\nlifecycle.\n\n#### 3. Hook Pattern for Streaming\n\nExecution results are streamed via hooks, allowing controllers to transform runtime events into SSE events without tight\ncoupling.\n\n## Coding Standards\n\n### Go Conventions\n\n#### Formatting\n\n**Always use `gofmt`** before committing:\n\n```bash\ngofmt -w .\n# or\nmake fmt\n```\n\n#### Import Organization\n\nThree groups separated by blank lines:\n\n```go\nimport (\n    // Standard library\n    \"context\"\n    \"fmt\"\n\n    // Third-party\n    \"github.com/beego/beego/v2/core/logs\"\n\n    // Internal\n    \"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n)\n```\n\n#### Error Handling\n\nAlways handle errors explicitly:\n\n```go\n// Good\nresult, err := someOperation()\nif err != nil {\n    logs.Error(\"operation failed: %v\", err)\n    return fmt.Errorf(\"failed to do something: %w\", err)\n}\n\n// Bad - silent failure\nresult, _ := someOperation()\n```\n\n#### Logging\n\nUse Beego's structured logger:\n\n```go\nlogs.Info(\"starting execution: sessionID=%s\", sessionID)\nlogs.Warning(\"session busy: sessionID=%s\", sessionID)\nlogs.Error(\"execution failed: error=%v\", err)\nlogs.Debug(\"received event: type=%s\", eventType)\n```\n\n### Concurrency Best Practices\n\n#### Use safego for goroutines\n\nAlways use `safego.Go` to prevent panics:\n\n```go\nimport \"github.com/alibaba/opensandbox/execd/pkg/util/safego\"\n\nsafego.Go(func() {\n    processInBackground()\n})\n```\n\n#### Context Propagation\n\nAlways respect context cancellation:\n\n```go\nfunc (c *Controller) runCommand(ctx context.Context, req *ExecuteCodeRequest) error {\n    cmd := exec.CommandContext(ctx, \"bash\", \"-c\", req.Code)\n\n    go func() {\n        <-ctx.Done()\n        if cmd.Process != nil {\n            cmd.Process.Kill()\n        }\n    }()\n\n    return cmd.Run()\n}\n```\n\n## Testing Strategy\n\n### Unit Tests\n\nLocated in `*_test.go` files alongside source code.\n\n**Example:**\n\n```go\nfunc TestController_Execute_Python(t *testing.T) {\n    ctrl := NewController(\"http://jupyter:8888\", \"test-token\")\n\n    req := &ExecuteCodeRequest{\n        Language: Python,\n        Code:     \"print('hello')\",\n    }\n\n    err := ctrl.Execute(req)\n    assert.NoError(t, err)\n}\n```\n\n**Running Unit Tests:**\n\n```bash\ngo test ./pkg/...\n# with coverage\ngo test -v -cover ./pkg/...\n```\n\n### Integration Tests\n\nLocated in `*_integration_test.go`, require real dependencies.\n\n**Running Integration Tests:**\n\n```bash\nexport JUPYTER_URL=http://localhost:8888\nexport JUPYTER_TOKEN=your-token\ngo test -v ./pkg/jupyter/...\n```\n\n### Test Coverage\n\nCheck coverage:\n\n```bash\ngo test -coverprofile=coverage.out ./pkg/...\ngo tool cover -html=coverage.out -o coverage.html\n```\n\n**Coverage Goals:**\n\n- Core packages (`pkg/runtime`, `pkg/jupyter`): > 80%\n- Controllers (`pkg/web/controller`): > 70%\n- Utilities (`pkg/util`): > 90%\n\n## Subsystem Guides\n\n### Working with Jupyter Integration\n\n#### Architecture\n\n```\npkg/jupyter/\n├── client.go          # Main client\n├── transport.go       # Connection handling\n├── session/           # Session lifecycle\n├── execute/           # Execution protocol\n└── auth/              # Authentication\n```\n\n#### Adding New Kernel Support\n\n1. Define language in `pkg/runtime/language.go`:\n\n```go\nconst Ruby Language = \"ruby\"\n```\n\n2. Map to kernel in `pkg/runtime/jupyter.go`\n\n3. Test with real kernel:\n\n```bash\n# Install Ruby kernel\ngem install iruby\niruby register --force\n\n# Run test\nexport JUPYTER_URL=http://localhost:8888\ngo test -v ./pkg/jupyter/integration_test.go\n```\n\n#### Debugging Jupyter Communication\n\nRun debug integration test:\n\n```bash\ngo test -v ./pkg/jupyter/debug_integration_test.go\n```\n\nThis dumps complete HTTP request/response pairs.\n\n### Working with Command Execution\n\n#### Key Implementation Details\n\n**Process Group Management:**\n\n```go\ncmd.SysProcAttr = &syscall.SysProcAttr{\n    Setpgid: true,  // Create new process group\n}\n```\n\nThis allows signal forwarding to all child processes:\n\n```go\nsyscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)\n```\n\n**Signal Forwarding:**\n\n```go\nsignals := make(chan os.Signal, 1)\nsignal.Notify(signals)\n\ngo func() {\n    for sig := range signals {\n        if sig != syscall.SIGCHLD && sig != syscall.SIGURG {\n            syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal))\n        }\n    }\n}()\n```\n\n**Stdout/Stderr Streaming:**\n\nCommands write to temporary log files, which are tailed and streamed to hooks.\n\n## Common Development Tasks\n\n### Adding a New API Endpoint\n\n1. **Define model** in `pkg/web/model/`:\n\n```go\ntype NewFeatureRequest struct {\n    Param1 string `json:\"param1\" validate:\"required\"`\n    Param2 int    `json:\"param2\"`\n}\n```\n\n2. **Add controller method** in `pkg/web/controller/`:\n\n```go\nfunc (c *MyController) NewFeature() {\n    var req model.NewFeatureRequest\n    json.Unmarshal(c.Ctx.Input.RequestBody, &req)\n\n    // Business logic\n    result := processNewFeature(req)\n\n    c.Data[\"json\"] = result\n    c.ServeJSON()\n}\n```\n\n3. **Register route** in `pkg/web/router.go`:\n\n```go\nmyNamespace := web.NewNamespace(\"/my-feature\",\n    web.NSRouter(\"\", &controller.MyController{}, \"post:NewFeature\"),\n)\nweb.AddNamespace(myNamespace)\n```\n\n### Adding Configuration Flag\n\n1. **Declare in `pkg/flag/flags.go`:**\n\n```go\nvar NewFeatureTimeout time.Duration\n```\n\n2. **Parse in `pkg/flag/parser.go`:**\n\n```go\nfunc InitFlags() {\n    flag.DurationVar(&NewFeatureTimeout, \"new-feature-timeout\", 30*time.Second, \"Description\")\n\n    // Parse environment variable\n    if env := os.Getenv(\"NEW_FEATURE_TIMEOUT\"); env != \"\" {\n        if d, err := time.ParseDuration(env); err == nil {\n            NewFeatureTimeout = d\n        }\n    }\n\n    flag.Parse()\n}\n```\n\n3. **Update README** with new flag documentation\n\n## Debugging Techniques\n\n### Local Debugging with Delve\n\n```bash\n# Install delve\ngo install github.com/go-delve/delve/cmd/dlv@latest\n\n# Start debugging\ndlv debug . -- \\\n  --jupyter-host=http://localhost:8888 \\\n  --jupyter-token=test\n\n# Set breakpoint\n(dlv) break pkg/runtime/ctrl.go:57\n(dlv) continue\n```\n\n### Debugging SSE Streams\n\n**Test with curl:**\n\n```bash\ncurl -N -H \"x-access-token: dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"language\":\"python\",\"code\":\"print(\\\"test\\\")\"}' \\\n  http://localhost:44772/code\n```\n\nThe `-N` flag disables buffering for real-time events.\n\n**Debug in browser:**\n\n```javascript\nconst eventSource = new EventSource('/code');\n\neventSource.addEventListener('stdout', (e) => {\n    console.log('stdout:', e.data);\n});\n\neventSource.addEventListener('error', (e) => {\n    console.error('error:', e.data);\n});\n```\n\n### Performance Profiling\n\n**CPU Profile:**\n\n```bash\n# Add to main.go\nimport _ \"net/http/pprof\"\n\ngo func() {\n    http.ListenAndServe(\"localhost:6060\", nil)\n}()\n\n# Collect profile\ngo tool pprof http://localhost:6060/debug/pprof/profile?seconds=30\n```\n\n**Memory Profile:**\n\n```bash\ngo tool pprof http://localhost:6060/debug/pprof/heap\n```\n\n**Goroutine Inspection:**\n\n```bash\ncurl http://localhost:6060/debug/pprof/goroutine?debug=2\n```\n\n## Performance Optimization\n\n### Optimization Guidelines\n\n1. **Profile before optimizing** - Use pprof to identify bottlenecks\n2. **Benchmark changes** - Measure impact of optimizations\n3. **Use `sync.Pool`** for frequently allocated objects\n4. **Minimize allocations** in hot paths\n5. **Buffer channels** appropriately\n\n### Example: Optimizing SSE Writer\n\n**Before:**\n\n```go\nfunc writeEvent(w http.ResponseWriter, event, data string) {\n    fmt.Fprintf(w, \"event: %s\\ndata: %s\\n\\n\", event, data)\n    w.(http.Flusher).Flush()\n}\n```\n\n**After:**\n\n```go\nvar bufPool = sync.Pool{\n    New: func() interface{} { return new(bytes.Buffer) },\n}\n\nfunc writeEvent(w http.ResponseWriter, event, data string) {\n    buf := bufPool.Get().(*bytes.Buffer)\n    buf.Reset()\n    defer bufPool.Put(buf)\n\n    buf.WriteString(\"event: \")\n    buf.WriteString(event)\n    buf.WriteString(\"\\ndata: \")\n    buf.WriteString(data)\n    buf.WriteString(\"\\n\\n\")\n\n    w.Write(buf.Bytes())\n    w.(http.Flusher).Flush()\n}\n```\n\n**Benchmark:**\n\n```go\nfunc BenchmarkWriteEvent(b *testing.B) {\n    w := httptest.NewRecorder()\n    b.ResetTimer()\n    for i := 0; i < b.N; i++ {\n        writeEvent(w, \"test\", \"data\")\n    }\n}\n```\n\n## Contributing Guidelines\n\n### Pull Request Process\n\n1. **Fork and clone** the repository\n2. **Create feature branch** from `main`\n3. **Implement changes** following coding standards\n4. **Add tests** for new functionality\n5. **Run all tests** and ensure they pass\n6. **Update documentation** as needed\n7. **Submit PR** with clear description\n\n### Code Review Standards\n\nReviewers check for:\n\n- [ ] Correctness and functionality\n- [ ] Test coverage\n- [ ] Code style and formatting\n- [ ] Documentation completeness\n- [ ] Performance implications\n- [ ] Security considerations\n- [ ] Error handling\n- [ ] Backwards compatibility\n\n### Release Checklist\n\nBefore releasing:\n\n- [ ] All tests pass (unit, integration, e2e)\n- [ ] Documentation updated (README, DEVELOPMENT, API docs)\n- [ ] CHANGELOG updated with changes\n- [ ] Version bumped appropriately (semver)\n- [ ] Dependencies reviewed and updated\n- [ ] Security scan passed\n- [ ] Performance benchmarks run\n- [ ] Docker image built and tested\n\n## Additional Resources\n\n### Useful Commands\n\n```bash\n# Format all Go files\nmake fmt\n\n# Run linter\nmake golint\n\n# Run all tests\nmake test\n\n# Build binary\nmake build\n```\n\n### External Documentation\n\n- [Beego Documentation](https://beego.wiki/)\n- [Jupyter Kernel Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html)\n- [Go Best Practices](https://golang.org/doc/effective_go)\n- [Server-Sent Events Spec](https://html.spec.whatwg.org/multipage/server-sent-events.html)\n\n### Getting Help\n\n- **Issues**: Report bugs or request features on GitHub Issues\n- **Discussions**: Ask questions in GitHub Discussions\n- **Chat**: Join the OpenSandbox community chat\n- **Documentation**: Check the wiki for detailed guides\n\n---\n\n**Happy hacking!** Feel free to augment this guide with tips you discover along the way. For questions or suggestions,\nopen an issue or discussion on GitHub.\n"
  },
  {
    "path": "components/execd/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM golang:1.24.0 AS builder\n\nWORKDIR /build\n\nARG VERSION=dev\nARG GIT_COMMIT=unknown\nARG BUILD_TIME=unknown\n\n# Prepare local modules to satisfy replace directives.\nCOPY components/internal/go.mod components/internal/go.sum ./components/internal/\nCOPY components/execd/go.mod components/execd/go.sum ./components/execd/\n\n# Download deps with only mod files for better caching.\nRUN cd components/internal && go mod download\nRUN cd components/execd && go mod download\n\n# Copy sources.\nCOPY components/internal ./components/internal\nCOPY components/execd ./components/execd\n\nWORKDIR /build/components/execd\n\nRUN CGO_ENABLED=0 go build \\\n    -ldflags \"-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'\" \\\n    -o /build/execd ./main.go\n\nFROM alpine:latest\n\nCOPY --from=builder /build/execd .\nCOPY components/execd/bootstrap.sh ./bootstrap.sh\n\nENTRYPOINT [\"./execd\"]\n"
  },
  {
    "path": "components/execd/Makefile",
    "content": ".PHONY: fmt\nfmt: ## Run go fmt against code.\n\tgo fmt ./...\n\n.PHONY: vet\nvet: ## Run go vet against code.\n\tgo mod tidy && go mod vendor\n\tgo vet ./...\n\n.PHONY: test\ntest: vet ## Run tests\n\tgo test -v -coverpkg=./... ./pkg/...\n\n##@ Linter\n\n.PHONY: install-golint\ninstall-golint:\n\t@if ! command -v golangci-lint &> /dev/null; then \\\n  \t\techo \"installing golangci-lint...\"; \\\n  \t\tgo install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \\\n  \telse \\\n  \t    echo \"golangci-lint already installed\"; \\\n\tfi\n\n.PHONY: golint\ngolint: fmt install-golint\n\tgolangci-lint run -v --fix ./...\n\nVERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo \"dev\")\nGIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo \"unknown\")\nBUILD_TIME ?= $(shell date -u +\"%Y-%m-%dT%H:%M:%SZ\")\nLDFLAGS := -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \\\n\t-X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \\\n\t-X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)'\n\n.PHONY: build\nbuild: vet ## Build the binary.\n\t@mkdir -p bin\n\tgo build -ldflags \"$(LDFLAGS)\" -o bin/execd main.go\n\n.PHONY: multi-build\nmulti-build: vet ## Cross-compile for linux/windows/darwin amd64/arm64.\n\t@mkdir -p bin\n\t@for os in linux windows darwin; do \\\n\t\tfor arch in amd64 arm64; do \\\n\t\t\tout=bin/execd-$${os}-$${arch}; \\\n\t\t\t[ \"$${os}\" = \"windows\" ] && out=\"$${out}.exe\"; \\\n\t\t\techo \">> building $${os}/$${arch} -> $${out}\"; \\\n\t\t\tGOOS=$${os} GOARCH=$${arch} CGO_ENABLED=0 go build -ldflags \"$(LDFLAGS)\" -o \"$${out}\" main.go || exit $$?; \\\n\t\tdone; \\\n\tdone\n"
  },
  {
    "path": "components/execd/README.md",
    "content": "# execd - OpenSandbox Execution Daemon\n\nEnglish | [中文](README_zh.md)\n\n`execd` is the execution daemon for OpenSandbox. Built on Beego, it exposes a comprehensive HTTP API that turns external requests into runtime actions: managing Jupyter sessions, streaming code output via Server-Sent Events (SSE), executing shell commands, operating on the sandbox filesystem, and collecting host-side metrics.\n\n## Table of Contents\n\n- [Overview](#overview)\n- [Core Features](#core-features)\n- [Architecture](#architecture)\n- [Getting Started](#getting-started)\n- [Configuration](#configuration)\n- [API Reference](#api-reference)\n- [Supported Languages](#supported-languages)\n- [Development](#development)\n- [Testing](#testing)\n- [Observability](#observability)\n- [Performance Benchmarks](#performance-benchmarks)\n- [Contributing](#contributing)\n- [License](#license)\n- [Support](#support)\n\n## Overview\n\n`execd` provides a unified interface for:\n\n- **Code execution**: Python, Java, JavaScript, TypeScript, Go, and Bash\n- **Session management**: Long-lived Jupyter kernel sessions with state\n- **Command execution**: Synchronous and background shell commands\n- **File operations**: Full filesystem CRUD with chunked upload/download\n- **Monitoring**: Real-time host metrics (CPU, memory, uptime)\n\n## Core Features\n\n### Unified runtime management\n\n- Translate REST calls into runtime requests handled by `pkg/runtime`\n- Multiple execution backends: Jupyter, shell, etc.\n- Automatic language detection and routing\n- Pluggable Jupyter server configuration\n\n### Jupyter integration\n\n- Maintain kernel sessions via `pkg/jupyter`\n- WebSocket-based real-time communication\n- Stream execution events through SSE\n\n### Command executor\n\n- Foreground and background shell commands\n- Proper signal forwarding with process groups\n- Real-time stdout/stderr streaming\n- Context-aware interruption\n\n### Filesystem\n\n- CRUD helpers around the sandbox filesystem\n- Glob-based file search\n- Chunked upload/download with resume support\n- Permission management\n\n### Observability\n\n- Lightweight metrics endpoint (CPU, memory, uptime)\n- Structured streaming logs\n- SSE-based real-time monitoring\n\n## Architecture\n\n### Directory structure\n\n| Path                   | Purpose                                              |\n|------------------------|------------------------------------------------------|\n| `main.go`              | Entry point; initializes Beego, CLI flags, routers   |\n| `pkg/flag/`            | CLI and environment configuration                    |\n| `pkg/web/`             | HTTP layer (controllers, models, router, SSE helpers) |\n| `pkg/web/controller/`  | Handlers for files, code, commands, metrics          |\n| `pkg/web/model/`       | Request/response models and SSE event types          |\n| `pkg/runtime/`         | Dispatcher to Jupyter and shell executors            |\n| `pkg/jupyter/`         | Minimal Jupyter client (kernels/sessions/WebSocket)  |\n| `pkg/jupyter/execute/` | Execution result types and stream parsers            |\n| `pkg/jupyter/session/` | Session management and lifecycle                     |\n| `pkg/util/`            | Utilities (safe goroutine helpers, glob helpers)     |\n| `tests/`               | Test scripts and tools                               |\n\n## Getting Started\n\n### Prerequisites\n\n- **Go 1.24+** (as defined in `go.mod`)\n- **Jupyter Server** (required for code execution)\n- **Docker** (optional, for containerized builds)\n- **Make** (optional, for convenience targets)\n\n### Quick Start\n\n#### 1. Clone and build\n\n```bash\ngit clone git@github.com:alibaba/OpenSandbox.git\ncd OpenSandbox/components/execd\ngo mod download\nmake build\n```\n\n#### 2. Start Jupyter Server\n\n```bash\n# Option 1: use the provided script\n./tests/jupyter.sh\n\n# Option 2: start manually\njupyter notebook --port=54321 --no-browser --ip=0.0.0.0 \\\n  --NotebookApp.token='your-jupyter-token'\n```\n\n#### 3. Run execd\n\n```bash\n./bin/execd \\\n  --jupyter-host=http://127.0.0.1:54321 \\\n  --jupyter-token=your-jupyter-token \\\n  --port=44772\n```\n\n#### 4. Verify\n\n```bash\ncurl -v http://localhost:44772/ping\n# Expect HTTP 200\n```\n\n### Image build\n\n```bash\ndocker build -t opensandbox/execd:dev .\n\n# Run container\ndocker run -d \\\n  -p 44772:44772 \\\n  -e JUPYTER_HOST=http://jupyter-server \\\n  -e JUPYTER_TOKEN=your-token \\\n  --name execd \\\n  opensandbox/execd:dev\n```\n\n## Configuration\n\n### Command-line flags\n\n| Flag                          | Type     | Default | Description                                   |\n|-------------------------------|----------|---------|-----------------------------------------------|\n| `--jupyter-host`              | string   | `\"\"`    | Jupyter server URL (reachable by execd)       |\n| `--jupyter-token`             | string   | `\"\"`    | Jupyter HTTP/WebSocket token                  |\n| `--port`                      | int      | `44772` | HTTP listen port                              |\n| `--log-level`                 | int      | `6`     | Beego log level (0=Emergency, 7=Debug)        |\n| `--access-token`              | string   | `\"\"`    | Shared API secret (optional)                  |\n| `--graceful-shutdown-timeout` | duration | `3s`    | Wait time before cutting off SSE on shutdown  |\n\n### Environment variables\n\nAll flags can be set via environment variables:\n\n```bash\nexport JUPYTER_HOST=http://127.0.0.1:8888\nexport JUPYTER_TOKEN=your-token\n```\n\nEnvironment variables override defaults but are superseded by explicit CLI flags.\n\n## API Reference\n\n[API Spec](../../specs/execd-api.yaml).\n\n## Supported Languages\n\n### Jupyter-based\n\n| Language   | Kernel      | Highlights                  |\n|------------|-------------|-----------------------------|\n| Python     | IPython     | Full Jupyter protocol       |\n| Java       | IJava       | JShell-based execution      |\n| JavaScript | IJavaScript | Node.js runtime             |\n| TypeScript | ITypeScript | TS compilation + Node exec  |\n| Go         | gophernotes | Go interpreter              |\n| Bash       | Bash kernel | Shell scripts               |\n\n### Native executors\n\n| Mode/Language        | Backend | Highlights                   |\n|----------------------|---------|------------------------------|\n| `command`            | OS exec | Synchronous shell commands   |\n| `background-command` | OS exec | Detached background process  |\n\n## Development\n\nSee [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed guidelines.\n\n## Testing\n\n### Unit tests\n\n```bash\nmake test\n```\n\n### Integration tests\n\nIntegration tests requiring a real Jupyter Server are skipped by default:\n\n```bash\nexport JUPYTER_URL=http://localhost:8888\nexport JUPYTER_TOKEN=your-token\ngo test -v ./pkg/jupyter/...\n```\n\n### Manual testing workflow\n\n1. Start Jupyter: `./tests/jupyter.sh`\n2. Start execd: `./bin/execd --jupyter-host=http://localhost:54321 --jupyter-token=opensandboxexecdlocaltest`\n3. Execute code:\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"language\":\"python\",\"code\":\"print(\\\"test\\\")\"}' \\\n  http://localhost:44772/code\n```\n\n## Configuration\n\n### API graceful shutdown window\n\n- Env: `EXECD_API_GRACE_SHUTDOWN` (e.g. `500ms`, `2s`, `1m`)\n- Flag: `--graceful-shutdown-timeout`\n- Default: `1s`\n\nThis controls how long execd keeps SSE responses (code/command runs) alive after sending the final chunk, so clients can drain tail output before the connection closes. Set to `0s` to disable the grace period.\n\n## Observability\n\n### Logging\n\nBeego leveled logger:\n\n```go\nlogs.Info(\"message\")   // info\nlogs.Warning(\"message\") // warning\nlogs.Error(\"message\")   // error\nlogs.Debug(\"message\")   // debug\n```\n\n- Env: `EXECD_LOG_FILE` writes execd logs to the given file path; when unset, logs are sent to stdout.\n\nLog levels (0-7):\n\n- 0: Emergency\n- 1: Alert\n- 2: Critical\n- 3: Error\n- 4: Warning\n- 5: Notice\n- 6: Info (default)\n- 7: Debug\n\n### Metrics\n\n`/metrics` exposes:\n\n- CPU usage percent\n- Memory total/used (GB)\n- Memory usage percent\n- Process uptime\n- Current timestamp\n\nFor real-time monitoring, use `/metrics/watch` (SSE, 1s cadence).\n\n## Performance Benchmarks\n\n### Typical latency (localhost)\n\n| Operation           | Latency  |\n|---------------------|----------|\n| `/ping`             | < 1ms    |\n| `/files/info`       | < 5ms    |\n| Code execution (Py) | 50-200ms |\n| File upload (1MB)   | 10-50ms  |\n| Metrics snapshot    | < 10ms   |\n\n### Resource usage (idle)\n\n- Memory: ~50MB\n- CPU: < 1%\n- Goroutines: ~15\n\n### Scalability\n\n- 100+ concurrent SSE connections\n- File operations scale linearly with file size\n- Jupyter sessions are stateful and need dedicated resources\n\n## Contributing\n\n1. Fork the repository\n2. Create a feature branch\n3. Follow coding conventions (see DEVELOPMENT.md)\n4. Add tests for new functionality\n5. Run `make fmt` and `make test`\n6. Submit a pull request\n\n## License\n\n`execd` is part of the OpenSandbox project. See [LICENSE](../../LICENSE) in the repository root.\n\n## Support\n\n- Issues: [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues)\n- Documentation: [OpenSandbox Docs](https://github.com/alibaba/OpenSandbox/wiki)\n- Community: [Discussions](https://github.com/alibaba/OpenSandbox/discussions)\n"
  },
  {
    "path": "components/execd/README_zh.md",
    "content": "# execd - OpenSandbox 执行守护进程\n\n中文 | [English](README.md)\n\n`execd` 是 OpenSandbox 的执行守护进程，基于 Beego 框架提供全面的 HTTP API。它将外部请求转化为实际的运行时动作：管理 Jupyter\n会话、以 SSE（Server-Sent Events）流式返回代码输出、执行 shell 命令、操作沙箱文件系统，并采集主机侧指标。\n\n## 目录\n\n- [概述](#概述)\n- [核心特性](#核心特性)\n- [架构设计](#架构设计)\n- [快速开始](#快速开始)\n- [配置说明](#配置说明)\n- [API 参考](#api-参考)\n- [支持的语言](#支持的语言)\n- [开发指南](#开发指南)\n- [测试](#测试)\n- [可观测性](#可观测性)\n- [许可证](#许可证)\n\n## 概述\n\n`execd` 作为 OpenSandbox 的运行时守护进程，提供统一的接口用于：\n\n- **代码执行**：Python、Java、JavaScript、TypeScript、Go 和 Bash\n- **会话管理**：带状态保持的长连接 Jupyter kernel 会话\n- **命令执行**：同步执行和异步执行 shell 命令\n- **文件操作**：完整的文件系统 CRUD，支持分块上传/下载\n- **监控**：实时系统指标（CPU、内存、运行时间）\n\n## 核心特性\n\n### 统一运行时管理\n\n- 将 REST 调用转化为由 `pkg/runtime` 控制器处理的运行时请求\n- 支持多种执行后端：Jupyter、Shell、等等\n- 自动语言检测和路由\n- 可插拔 Jupyter server 配置\n\n### Jupyter 集成\n\n- 通过 `pkg/jupyter` 维护 kernel 会话\n- 基于 WebSocket 的实时通信\n- 通过 Server-Sent Events (SSE) 流式推送执行事件\n\n### 命令执行器\n\n- 前台、后台 shell 命令\n- 通过进程组管理正确转发信号\n- 实时 stdout/stderr 流式输出\n- 支持上下文感知的中断\n\n### 文件系统\n\n- 围绕沙箱文件系统的 CRUD 辅助工具\n- Glob 模式匹配文件搜索\n- 支持断点续传的分块上传/下载\n- 权限管理\n\n### 可观测性\n\n- 轻量级指标端点（CPU、内存、运行时间）\n- 结构化流式日志\n- 基于 SSE 的实时监控\n\n## 架构设计\n\n### 目录结构\n\n| 路径                     | 说明                                         |\n|------------------------|--------------------------------------------|\n| `main.go`              | 程序入口，初始化 Beego、CLI 标志和路由                   |\n| `pkg/flag/`            | 命令行与环境变量配置                                 |\n| `pkg/web/`             | HTTP 层（控制器、模型、路由、SSE 辅助）                   |\n| `pkg/web/controller/`  | 文件、代码、命令、指标的请求处理器                          |\n| `pkg/web/model/`       | 请求/响应模型与 SSE 事件类型                          |\n| `pkg/runtime/`         | 运行时控制器，调度到 Jupyter、Shell执行器                |\n| `pkg/jupyter/`         | 精简 Jupyter 客户端（kernels/sessions/WebSocket） |\n| `pkg/jupyter/execute/` | 执行结果类型与流解析器                                |\n| `pkg/jupyter/session/` | 会话管理与生命周期                                  |\n| `pkg/util/`            | 通用工具（安全 goroutine、glob 辅助）                 |\n| `tests/`               | 测试脚本和工具                                    |\n\n## 快速开始\n\n### 环境要求\n\n- **Go 1.24+**（在 `go.mod` 中定义）\n- **Jupyter Server**（代码执行上下文所需）\n- **Docker**（可选，用于容器化构建）\n- **Make**（可选，用于便捷命令）\n\n### 快速启动\n\n#### 1. 克隆并构建\n\n```bash\ngit clone git@github.com:alibaba/OpenSandbox.git\ncd OpenSandbox/components/execd\ngo mod download\nmake build\n```\n\n#### 2. 启动 Jupyter Server\n\n```bash\n# 方式 1：使用提供的脚本\n./tests/jupyter.sh\n\n# 方式 2：手动启动\njupyter notebook --port=54321 --no-browser --ip=0.0.0.0 \\\n  --NotebookApp.token='your-jupyter-token'\n```\n\n#### 3. 运行 execd\n\n```bash\n./bin/execd \\\n  --jupyter-host=http://127.0.0.1:54321 \\\n  --jupyter-token=your-jupyter-token \\\n  --port=44772\n```\n\n#### 4. 验证安装\n\n```bash\ncurl -v http://localhost:44772/ping\n# 期望200状态码\n```\n\n### 镜像构建\n\n```bash\ndocker build -t opensandbox/execd:dev .\n\n# 运行容器\ndocker run -d \\\n  -p 44772:44772 \\\n  -e JUPYTER_HOST=http://jupyter-server \\\n  -e JUPYTER_TOKEN=your-token \\\n  --name execd \\\n  opensandbox/execd:dev\n```\n\n## 配置说明\n\n### 命令行标志\n\n| 标志                            | 类型       | 默认值     | 说明                                  |\n|-------------------------------|----------|---------|-------------------------------------|\n| `--jupyter-host`              | string   | `\"\"`    | 后端 Jupyter server 地址，要求execd进程可访问即可 |\n| `--jupyter-token`             | string   | `\"\"`    | Jupyter HTTP/WebSocket 令牌           |\n| `--port`                      | int      | `44772` | HTTP 监听端口                           |\n| `--log-level`                 | int      | `6`     | Beego 日志级别（0=紧急，7=调试）               |\n| `--access-token`              | string   | `\"\"`    | API 共享密钥（可选）                        |\n| `--graceful-shutdown-timeout` | duration | `3s`    | 关闭前等待 SSE 的时间                       |\n\n### 环境变量\n\n所有标志都可以通过环境变量设置：\n\n```bash\nexport JUPYTER_HOST=http://127.0.0.1:8888\nexport JUPYTER_TOKEN=your-token\n```\n\n环境变量优先于默认值，但会被显式的 CLI 标志覆盖。\n\n## API 参考\n\n[API Spec](../../specs/execd-api.yaml)。\n\n## 支持的语言\n\n### 基于 Jupyter 的语言\n\n| 语言         | Kernel      | 特性              |\n|------------|-------------|-----------------|\n| Python     | IPython     | 完整 Jupyter 协议支持 |\n| Java       | IJava       | 基于 JShell 的执行   |\n| JavaScript | IJavaScript | Node.js 运行时     |\n| TypeScript | ITypeScript | TS 编译 + Node 执行 |\n| Go         | gophernotes | Go 解释器          |\n| Bash       | Bash kernel | Shell 脚本执行      |\n\n### 原生执行器\n\n| 模式/语言                | 后端      | 特性          |\n|----------------------|---------|-------------|\n| `command`            | OS exec | 同步 shell 命令 |\n| `background-command` | OS exec | 分离的后台进程     |\n\n## 开发指南\n\n开发指南请参见 [DEVELOPMENT.md](./DEVELOPMENT.md)。\n\n## 测试\n\n### 单元测试\n\n```bash\nmake test\n```\n\n### 集成测试\n\n需要真实 Jupyter Server 的集成测试默认跳过：\n\n```bash\nexport JUPYTER_URL=http://localhost:8888\nexport JUPYTER_TOKEN=your-token\ngo test -v ./pkg/jupyter/...\n```\n\n### 手动测试工作流\n\n1. 启动 Jupyter：`./tests/jupyter.sh`\n2. 启动 execd：`./bin/execd --jupyter-host=http://localhost:54321 --jupyter-token=opensandboxexecdlocaltest`\n\n3. 执行代码：\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"language\":\"python\",\"code\":\"print(\\\"test\\\")\"}' \\\n  http://localhost:44772/code\n```\n\n## 配置\n\n### SSE API 优雅结束时间窗口\n\n- 环境变量：`EXECD_API_GRACE_SHUTDOWN`（如 `500ms`、`2s`、`1m`）\n- 命令行参数：`--graceful-shutdown-timeout`\n- 默认值：`1s`\n\n作用：控制 SSE 响应（代码/命令执行）在发送最后一块数据后，保持连接的宽限时间，方便客户端完全读到尾部输出再关闭。如果设置为 `0s` 则关闭这一等待。\n\n## 可观测性\n\n### 日志记录\n\n全程使用 Beego 的分级日志器：\n\n```go\nlogs.Info(\"message\") // 常规信息\nlogs.Warning(\"message\") // 警告条件\nlogs.Error(\"message\")   // 错误条件\nlogs.Debug(\"message\") // 调试级别消息\n```\n\n- 环境变量：`EXECD_LOG_FILE` 指定日志输出文件；未设置时日志输出到标准输出（stdout）。\n\n日志级别（0-7）：\n\n- 0：紧急\n- 1：警报\n- 2：严重\n- 3：错误\n- 4：警告\n- 5：注意\n- 6：信息（默认）\n- 7：调试\n\n### 指标采集\n\n`/metrics` 端点提供：\n\n- CPU 使用百分比\n- 内存总量/已用（GB）\n- 内存使用百分比\n- 进程运行时间\n- 当前时间戳\n\n对于实时监控，使用 `/metrics/watch`，每秒通过 SSE 流式推送更新。\n\n## 性能基准\n\n### 典型延迟（localhost）\n\n| 操作            | 延迟       |\n|---------------|----------|\n| `/ping`       | < 1ms    |\n| `/files/info` | < 5ms    |\n| 代码执行（Python）  | 50-200ms |\n| 文件上传（1MB）     | 10-50ms  |\n| 指标快照          | < 10ms   |\n\n### 资源使用（空闲）\n\n- 内存：~50MB\n- CPU：< 1%\n- Goroutines：~15\n\n### 可扩展性\n\n- 支持 100+ 并发 SSE 连接\n- 文件操作随文件大小线性扩展\n- Jupyter 会话是有状态的，需要专用资源\n\n## 贡献\n\n1. Fork 仓库\n2. 创建特性分支\n3. 遵循编码规范（见 DEVELOPMENT.md）\n4. 为新功能添加测试\n5. 运行 `make fmt` 和 `make test`\n6. 提交 pull request\n\n## 许可证\n\n`execd` 是 OpenSandbox 项目的一部分。详见仓库根目录的 [LICENSE](../../LICENSE)。\n\n## 支持\n\n- 问题：[GitHub Issues](https://github.com/alibaba/OpenSandbox/issues)\n- 文档：[OpenSandbox Docs](https://github.com/alibaba/OpenSandbox/wiki)\n- 社区：[Discussions](https://github.com/alibaba/OpenSandbox/discussions)\n"
  },
  {
    "path": "components/execd/bootstrap.sh",
    "content": "#!/bin/sh\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -e\n\nEXECD=\"${EXECD:=/opt/opensandbox/execd}\"\n\nif [ -z \"${EXECD_ENVS:-}\" ]; then\n\tEXECD_ENVS=\"/opt/opensandbox/.env\"\nfi\n# Best-effort ensure file exists.\nif ! mkdir -p \"$(dirname \"$EXECD_ENVS\")\" 2>/dev/null; then\n\techo \"warning: failed to create dir for EXECD_ENVS=$EXECD_ENVS\" >&2\nfi\nif ! touch \"$EXECD_ENVS\" 2>/dev/null; then\n\techo \"warning: failed to touch EXECD_ENVS=$EXECD_ENVS\" >&2\nfi\nexport EXECD_ENVS\n\necho \"starting OpenSandbox Execd daemon at $EXECD.\"\n$EXECD &\n\n# Allow chained shell commands (e.g., /test1.sh && /test2.sh)\n# Usage:\n#   bootstrap.sh -c \"/test1.sh && /test2.sh\"\n# Or set BOOTSTRAP_CMD=\"/test1.sh && /test2.sh\"\nCMD=\"\"\nif [ \"${BOOTSTRAP_CMD:-}\" != \"\" ]; then\n\tCMD=\"$BOOTSTRAP_CMD\"\nelif [ $# -ge 1 ] && [ \"$1\" = \"-c\" ]; then\n\tshift\n\tCMD=\"$*\"\nfi\n\nSHELL_BIN=\"${BOOTSTRAP_SHELL:-}\"\nif [ -z \"$SHELL_BIN\" ]; then\n\tif command -v bash >/dev/null 2>&1; then\n\t\tSHELL_BIN=\"$(command -v bash)\"\n\telif command -v sh >/dev/null 2>&1; then\n\t\tSHELL_BIN=\"$(command -v sh)\"\n\telse\n\t\techo \"error: neither bash nor sh found in PATH\" >&2\n\t\texit 1\n\tfi\nfi\n\nset -x\nif [ \"$CMD\" != \"\" ]; then\n\texec \"$SHELL_BIN\" -c \"$CMD\"\nfi\n\nif [ $# -eq 0 ]; then\n\texec \"$SHELL_BIN\"\nfi\n\nexec \"$@\"\n"
  },
  {
    "path": "components/execd/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\nVERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo \"dev\")}\nGIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo \"unknown\")}\nBUILD_TIME=${BUILD_TIME:-$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")}\n\nREPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath \"$(dirname \"$0\")/../..\")\ncd \"${REPO_ROOT}\"\n\ndocker buildx rm execd-builder || true\n\ndocker buildx create --use --name execd-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\nLATEST_TAGS=()\nif [[ \"${TAG}\" == v* ]]; then\n  LATEST_TAGS+=(-t opensandbox/execd:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest)\nfi\n\ndocker buildx build \\\n  -t opensandbox/execd:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:${TAG} \\\n  \"${LATEST_TAGS[@]}\" \\\n  -f components/execd/Dockerfile \\\n  --build-arg VERSION=\"${VERSION}\" \\\n  --build-arg GIT_COMMIT=\"${GIT_COMMIT}\" \\\n  --build-arg BUILD_TIME=\"${BUILD_TIME}\" \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "components/execd/go.mod",
    "content": "module github.com/alibaba/opensandbox/execd\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/alibaba/opensandbox/internal v0.0.0\n\tgithub.com/bmatcuk/doublestar/v4 v4.9.1\n\tgithub.com/gin-gonic/gin v1.10.0\n\tgithub.com/go-playground/validator/v10 v10.28.0\n\tgithub.com/go-sql-driver/mysql v1.8.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674\n\tgithub.com/shirou/gopsutil v3.21.11+incompatible\n\tgithub.com/stretchr/testify v1.10.0\n\tgo.uber.org/automaxprocs v1.6.0\n\tk8s.io/apimachinery v0.34.2\n\tk8s.io/client-go v0.34.2\n)\n\nrequire (\n\tfilippo.io/edwards25519 v1.1.1 // indirect\n\tgithub.com/bytedance/sonic v1.11.6 // indirect\n\tgithub.com/bytedance/sonic/loader v0.1.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.10 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-ole/go-ole v1.2.6 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.7 // indirect\n\tgithub.com/kr/pretty v0.3.1 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/tklauser/go-sysconf v0.3.16 // indirect\n\tgithub.com/tklauser/numcpus v0.11.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/yusufpapurcu/wmi v1.2.4 // indirect\n\tgo.uber.org/multierr v1.10.0 // indirect\n\tgo.uber.org/zap v1.27.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgolang.org/x/arch v0.8.0 // indirect\n\tgolang.org/x/crypto v0.45.0 // indirect\n\tgolang.org/x/net v0.47.0 // indirect\n\tgolang.org/x/sys v0.38.0 // indirect\n\tgolang.org/x/text v0.31.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.5 // indirect\n\tgopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/klog/v2 v2.130.1 // indirect\n\tk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect\n\tsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect\n)\n\nreplace github.com/alibaba/opensandbox/internal => ../internal\n"
  },
  {
    "path": "components/execd/go.sum",
    "content": "filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=\nfilippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=\ngithub.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=\ngithub.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=\ngithub.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=\ngithub.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=\ngithub.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=\ngithub.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=\ngithub.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=\ngithub.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=\ngithub.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=\ngithub.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=\ngithub.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=\ngithub.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=\ngithub.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=\ngithub.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=\ngithub.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=\ngithub.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=\ngo.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=\ngo.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=\ngo.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=\ngolang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=\ngolang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=\ngolang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=\ngolang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=\ngoogle.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=\nk8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nk8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=\nk8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "components/execd/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/alibaba/opensandbox/internal/version\"\n\n\t_ \"go.uber.org/automaxprocs/maxprocs\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/flag\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t_ \"github.com/alibaba/opensandbox/execd/pkg/util/safego\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/controller\"\n)\n\n// main initializes and starts the execd server.\nfunc main() {\n\tversion.EchoVersion(\"OpenSandbox Execd\")\n\n\tflag.InitFlags()\n\n\tlog.Init(flag.ServerLogLevel)\n\n\tcontroller.InitCodeRunner()\n\tengine := web.NewRouter(flag.ServerAccessToken)\n\taddr := fmt.Sprintf(\":%d\", flag.ServerPort)\n\tlog.Info(\"execd listening on %s\", addr)\n\tif err := engine.Run(addr); err != nil {\n\t\tlog.Error(\"failed to start execd server: %v\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/flag/flags.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage flag\n\nimport \"time\"\n\nvar (\n\t// JupyterServerHost points to the target Jupyter instance.\n\tJupyterServerHost string\n\n\t// JupyterServerToken authenticates requests to the Jupyter server.\n\tJupyterServerToken string\n\n\t// ServerPort controls the HTTP listener port.\n\tServerPort int\n\n\t// ServerLogLevel controls the server log verbosity.\n\tServerLogLevel int\n\n\t// ServerAccessToken guards API entrypoints when set.\n\tServerAccessToken string\n\n\t// ApiGracefulShutdownTimeout waits before tearing down SSE streams.\n\tApiGracefulShutdownTimeout time.Duration\n)\n"
  },
  {
    "path": "components/execd/pkg/flag/parser.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage flag\n\nimport (\n\t\"flag\"\n\tstdlog \"log\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\nconst (\n\tjupyterHostEnv             = \"JUPYTER_HOST\"\n\tjupyterTokenEnv            = \"JUPYTER_TOKEN\"\n\tgracefulShutdownTimeoutEnv = \"EXECD_API_GRACE_SHUTDOWN\"\n)\n\n// InitFlags registers CLI flags and env overrides.\nfunc InitFlags() {\n\t// Set default values\n\tServerPort = 44772\n\tServerLogLevel = 6\n\tServerAccessToken = \"\"\n\tApiGracefulShutdownTimeout = time.Second * 1\n\n\t// First, set default values from environment variables\n\tif jupyterFromEnv := os.Getenv(jupyterHostEnv); jupyterFromEnv != \"\" {\n\t\tif !strings.HasPrefix(jupyterFromEnv, \"http://\") && !strings.HasPrefix(jupyterFromEnv, \"https://\") {\n\t\t\tstdlog.Panic(\"Invalid JUPYTER_HOST format: must start with http:// or https://\")\n\t\t}\n\t\tJupyterServerHost = jupyterFromEnv\n\t}\n\n\tif jupyterTokenFromEnv := os.Getenv(jupyterTokenEnv); jupyterTokenFromEnv != \"\" {\n\t\tJupyterServerToken = jupyterTokenFromEnv\n\t}\n\n\t// Then define flags with current values as defaults\n\tflag.StringVar(&JupyterServerHost, \"jupyter-host\", JupyterServerHost, \"Jupyter server host address (e.g., http://localhost, http://192.168.1.100)\")\n\tflag.StringVar(&JupyterServerToken, \"jupyter-token\", JupyterServerToken, \"Jupyter server authentication token\")\n\tflag.IntVar(&ServerPort, \"port\", ServerPort, \"Server listening port (default: 44772)\")\n\tflag.IntVar(&ServerLogLevel, \"log-level\", ServerLogLevel, \"Server log level (0=LevelEmergency, 1=LevelAlert, 2=LevelCritical, 3=LevelError, 4=LevelWarning, 5=LevelNotice, 6=LevelInformational, 7=LevelDebug, default: 6)\")\n\tflag.StringVar(&ServerAccessToken, \"access-token\", ServerAccessToken, \"Server access token for API authentication\")\n\n\tif graceShutdownTimeout := os.Getenv(gracefulShutdownTimeoutEnv); graceShutdownTimeout != \"\" {\n\t\tduration, err := time.ParseDuration(graceShutdownTimeout)\n\t\tif err != nil {\n\t\t\tstdlog.Panicf(\"Failed to parse graceful shutdown timeout from env: %v\", err)\n\t\t}\n\t\tApiGracefulShutdownTimeout = duration\n\t}\n\n\tflag.DurationVar(&ApiGracefulShutdownTimeout, \"graceful-shutdown-timeout\", ApiGracefulShutdownTimeout, \"API graceful shutdown timeout duration (default: 3s)\")\n\n\t// Parse flags - these will override environment variables if provided\n\tflag.Parse()\n\n\t// Log final values\n\tlog.Info(\"Jupyter server host is: %s\", JupyterServerHost)\n\tlog.Info(\"Jupyter server token is: %s\", JupyterServerToken)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/auth/auth.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n)\n\n// Auth represents authentication configuration.\ntype Auth struct {\n\tToken    string\n\tUsername string\n\tPassword string\n}\n\n// NewTokenAuth builds a token-based config.\nfunc NewTokenAuth(token string) *Auth {\n\treturn &Auth{\n\t\tToken: token,\n\t}\n}\n\n// NewBasicAuth builds a basic-auth config.\nfunc NewBasicAuth(username, password string) *Auth {\n\treturn &Auth{\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n}\n\n// Validate reports which auth mode is configured.\nfunc (a *Auth) Validate() string {\n\tif a.Token != \"\" {\n\t\treturn \"token\"\n\t}\n\tif a.Username != \"\" {\n\t\treturn \"basic\"\n\t}\n\treturn \"none\"\n}\n\n// AddAuthToURL appends token query parameters to the URL.\nfunc (a *Auth) AddAuthToURL(baseURL string) (string, error) {\n\tparsedURL, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse URL: %w\", err)\n\t}\n\n\tquery := parsedURL.Query()\n\n\tif a.Token != \"\" {\n\t\tquery.Set(\"token\", a.Token)\n\t}\n\n\tparsedURL.RawQuery = query.Encode()\n\treturn parsedURL.String(), nil\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/auth/auth_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestTokenAuthentication(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttoken := r.Header.Get(\"Authorization\")\n\t\texpectedToken := \"token test-token\"\n\t\tif token != expectedToken {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tauth := NewAuth()\n\tauth.Token = \"test-token\"\n\n\tclient := NewClient(&http.Client{}, auth)\n\n\treq, err := http.NewRequest(\"GET\", server.URL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to send request: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t}\n}\n\nfunc TestBasicAuthentication(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tusername, password, ok := r.BasicAuth()\n\t\tif !ok || username != \"testuser\" || password != \"testpass\" {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tauth := NewAuth()\n\tauth.Username = \"testuser\"\n\tauth.Password = \"testpass\"\n\n\tclient := NewClient(&http.Client{}, auth)\n\n\treq, err := http.NewRequest(\"GET\", server.URL, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t}\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to send request: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tt.Errorf(\"Expected status code %d, got %d\", http.StatusOK, resp.StatusCode)\n\t}\n}\n\nfunc TestAuthValidation(t *testing.T) {\n\temptyAuth := NewAuth()\n\tif emptyAuth.IsValid() {\n\t\tt.Error(\"Empty Auth should be invalid, but was determined to be valid\")\n\t}\n\n\ttokenAuth := NewAuth()\n\ttokenAuth.Token = \"test-token\"\n\tif !tokenAuth.IsValid() {\n\t\tt.Error(\"Auth with token should be valid, but was determined to be invalid\")\n\t}\n\n\tbasicAuth := NewAuth()\n\tbasicAuth.Username = \"testuser\"\n\tbasicAuth.Password = \"testpass\"\n\tif !basicAuth.IsValid() {\n\t\tt.Error(\"Auth with Basic Auth should be valid, but was determined to be invalid\")\n\t}\n\n\tinvalidBasicAuth := NewAuth()\n\tinvalidBasicAuth.Username = \"testuser\"\n\tif invalidBasicAuth.IsValid() {\n\t\tt.Error(\"Auth with only username and no password should be invalid, but was determined to be valid\")\n\t}\n\n\tmixedAuth := NewAuth()\n\tmixedAuth.Token = \"test-token\"\n\tmixedAuth.Username = \"testuser\"\n\tmixedAuth.Password = \"testpass\"\n\tif !mixedAuth.IsValid() {\n\t\tt.Error(\"Auth with both token and Basic Auth should be valid, but was determined to be invalid\")\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/auth/client.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// Client wraps http.Client and injects auth headers.\ntype Client struct {\n\thttpClient *http.Client\n\tauth       *Auth\n}\n\n// NewClient creates a new authenticated HTTP client.\nfunc NewClient(httpClient *http.Client, auth *Auth) *Client {\n\treturn &Client{\n\t\thttpClient: httpClient,\n\t\tauth:       auth,\n\t}\n}\n\n// Do sends an HTTP request and automatically adds authentication data.\nfunc (c *Client) Do(req *http.Request) (*http.Response, error) {\n\tif c.auth == nil {\n\t\treturn c.httpClient.Do(req)\n\t}\n\n\tif c.auth.Token != \"\" {\n\t\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"token %s\", c.auth.Token))\n\t} else if c.auth.Username != \"\" {\n\t\treq.SetBasicAuth(c.auth.Username, c.auth.Password)\n\t}\n\n\treturn c.httpClient.Do(req)\n}\n\n// Get sends a GET request.\nfunc (c *Client) Get(url string) (*http.Response, error) {\n\treq, err := http.NewRequest(http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn c.Do(req)\n}\n\n// Post sends a POST request.\nfunc (c *Client) Post(url, contentType string, body io.Reader) (*http.Response, error) {\n\treq, err := http.NewRequest(http.MethodPost, url, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", contentType)\n\treturn c.Do(req)\n}\n\n// Put sends a PUT request.\nfunc (c *Client) Put(url, contentType string, body io.Reader) (*http.Response, error) {\n\treq, err := http.NewRequest(http.MethodPut, url, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", contentType)\n\treturn c.Do(req)\n}\n\n// Delete sends a DELETE request.\nfunc (c *Client) Delete(url string) (*http.Response, error) {\n\treq, err := http.NewRequest(http.MethodDelete, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn c.Do(req)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/auth/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage auth\n\nconst (\n\tAuthTypeNone          = \"none\"\n\tAuthTypeToken         = \"token\"\n\tAuthTypeBasic         = \"basic\"\n\tAuthHeaderKey         = \"Authorization\"\n\tAuthHeaderValuePrefix = \"token \"\n\tAuthURLParamKey       = \"token\"\n)\n\n// NewAuth creates an empty authentication configuration.\nfunc NewAuth() *Auth {\n\treturn &Auth{}\n}\n\n// IsValid reports whether token or username/password are present.\nfunc (a *Auth) IsValid() bool {\n\treturn a.Token != \"\" || (a.Username != \"\" && a.Password != \"\")\n}\n\n// GetAuthType returns token/basic/none.\nfunc (a *Auth) GetAuthType() string {\n\treturn a.Validate()\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/client.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jupyter\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/auth\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/kernel\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/session\"\n)\n\n// Client interacts with the Jupyter server.\ntype Client struct {\n\tBaseURL       string\n\thttpClient    *http.Client\n\tAuth          *auth.Auth\n\tkernelClient  *kernel.Client\n\tsessionClient *session.Client\n\texecuteClient *execute.Client\n\tauthClient    *auth.Client\n}\n\ntype ClientOption func(*Client)\n\n// WithHTTPClient sets a custom HTTP client.\nfunc WithHTTPClient(client *http.Client) ClientOption {\n\treturn func(c *Client) {\n\t\tc.httpClient = client\n\t}\n}\n\n// WithToken configures the client with an authentication token.\nfunc WithToken(token string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.Auth.Token = token\n\t}\n}\n\n// WithBasicAuth configures the client with basic authentication.\nfunc WithBasicAuth(username, password string) ClientOption {\n\treturn func(c *Client) {\n\t\tc.Auth.Username = username\n\t\tc.Auth.Password = password\n\t}\n}\n\n// NewClient creates a new Jupyter client instance.\nfunc NewClient(baseURL string, options ...ClientOption) *Client {\n\tclient := &Client{\n\t\tBaseURL:    baseURL,\n\t\thttpClient: http.DefaultClient,\n\t\tAuth:       auth.NewAuth(),\n\t}\n\n\tfor _, option := range options {\n\t\toption(client)\n\t}\n\n\tclient.authClient = auth.NewClient(client.httpClient, client.Auth)\n\n\tclient.kernelClient = kernel.NewClient(baseURL, client.httpClient)\n\tclient.sessionClient = session.NewClient(baseURL, client.httpClient)\n\tclient.executeClient = execute.NewClient(baseURL, client.authClient)\n\n\treturn client\n}\n\n// SetToken configures token authentication.\nfunc (c *Client) SetToken(token string) {\n\tc.Auth.Token = token\n}\n\n// SetBasicAuth configures username/password authentication.\nfunc (c *Client) SetBasicAuth(username, password string) {\n\tc.Auth.Username = username\n\tc.Auth.Password = password\n}\n\n// ValidateAuth quickly checks that some auth data is present.\nfunc (c *Client) ValidateAuth() (string, error) {\n\tauthType := c.Auth.Validate()\n\tif authType == \"none\" {\n\t\treturn \"error\", errors.New(\"no valid authentication information provided\")\n\t}\n\treturn \"ok\", nil\n}\n\n// GetKernelSpecs retrieves available kernel specifications.\nfunc (c *Client) GetKernelSpecs() (*kernel.KernelSpecs, error) {\n\treturn c.kernelClient.GetKernelSpecs()\n}\n\n// ListKernels retrieves all running kernels.\nfunc (c *Client) ListKernels() ([]*kernel.Kernel, error) {\n\treturn c.kernelClient.ListKernels()\n}\n\n// GetKernel retrieves information about a specific kernel.\nfunc (c *Client) GetKernel(kernelId string) (*kernel.Kernel, error) {\n\treturn c.kernelClient.GetKernel(kernelId)\n}\n\n// StartKernel starts a new kernel.\nfunc (c *Client) StartKernel(name string) (*kernel.Kernel, error) {\n\treturn c.kernelClient.StartKernel(name)\n}\n\n// RestartKernel restarts the specified kernel.\nfunc (c *Client) RestartKernel(kernelId string) (bool, error) {\n\treturn c.kernelClient.RestartKernel(kernelId)\n}\n\n// InterruptKernel interrupts the specified kernel.\nfunc (c *Client) InterruptKernel(kernelId string) error {\n\treturn c.kernelClient.InterruptKernel(kernelId)\n}\n\n// ShutdownKernel shuts down (and optionally restarts) the specified kernel.\nfunc (c *Client) ShutdownKernel(kernelId string, restart bool) error {\n\treturn c.kernelClient.ShutdownKernel(kernelId, restart)\n}\n\n// ListSessions retrieves active sessions.\nfunc (c *Client) ListSessions() ([]*session.Session, error) {\n\treturn c.sessionClient.ListSessions()\n}\n\n// GetSession retrieves information about a specific session.\nfunc (c *Client) GetSession(sessionId string) (*session.Session, error) {\n\treturn c.sessionClient.GetSession(sessionId)\n}\n\n// CreateSession creates a new session.\nfunc (c *Client) CreateSession(name, ipynb, kernel string) (*session.Session, error) {\n\treturn c.sessionClient.CreateSession(name, ipynb, kernel)\n}\n\n// ModifySession updates an existing session.\nfunc (c *Client) ModifySession(sessionId, name, path, kernel string) (*session.Session, error) {\n\treturn c.sessionClient.ModifySession(sessionId, name, path, kernel)\n}\n\n// DeleteSession deletes the specified session.\nfunc (c *Client) DeleteSession(sessionId string) error {\n\treturn c.sessionClient.DeleteSession(sessionId)\n}\n\n// ConnectToKernel establishes a websocket connection to the kernel.\nfunc (c *Client) ConnectToKernel(kernelId string) error {\n\tparsedURL, err := url.Parse(c.BaseURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid base URL: %w\", err)\n\t}\n\n\tscheme := \"ws\"\n\tif parsedURL.Scheme == \"https\" {\n\t\tscheme = \"wss\"\n\t}\n\n\twsURL := fmt.Sprintf(\"%s://%s/api/kernels/%s/channels\", scheme, parsedURL.Host, kernelId)\n\n\tif c.Auth.Token != \"\" {\n\t\twsURL = fmt.Sprintf(\"%s?token=%s\", wsURL, c.Auth.Token)\n\t}\n\n\treturn c.executeClient.Connect(wsURL)\n}\n\n// DisconnectFromKernel closes the websocket connection.\nfunc (c *Client) DisconnectFromKernel(kernelId string) {\n\tc.executeClient.Disconnect()\n}\n\n// ExecuteCodeStream streams execution results into resultChan.\nfunc (c *Client) ExecuteCodeStream(kernelId, code string, resultChan chan *execute.ExecutionResult) error {\n\treturn c.executeClient.ExecuteCodeStream(code, resultChan)\n}\n\n// ExecuteCodeWithCallback processes execution events via callbacks.\nfunc (c *Client) ExecuteCodeWithCallback(code string, handler execute.CallbackHandler) error {\n\treturn c.executeClient.ExecuteCodeWithCallback(code, handler)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/debug_integration_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jupyter\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"testing\"\n)\n\n// TestDebugServerIntegration logs real server interactions for debugging.\nfunc TestDebugServerIntegration(t *testing.T) {\n\tjupyterURL := getEnv(\"JUPYTER_URL\", \"\")\n\tjupyterToken := getEnv(\"JUPYTER_TOKEN\", \"\")\n\tif jupyterURL == \"\" || jupyterToken == \"\" {\n\t\tt.Skip(\"JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test\")\n\t}\n\n\tt.Logf(\"Connecting to Jupyter server: %s\", jupyterURL)\n\n\thttpClient := &http.Client{\n\t\tTransport: &debugTransport{t: t},\n\t}\n\n\tclient := NewClient(jupyterURL,\n\t\tWithToken(jupyterToken),\n\t\tWithHTTPClient(httpClient))\n\n\tt.Run(\"Validate Authentication\", func(t *testing.T) {\n\t\tt.Logf(\"Calling ValidateAuth...\")\n\t\tstatus, err := client.ValidateAuth()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Authentication validation failed: %v\", err)\n\t\t}\n\t\tt.Logf(\"Authentication validation successful! Status: %s\", status)\n\t})\n\n\tt.Run(\"Get API Information\", func(t *testing.T) {\n\t\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/api\", jupyterURL), nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t\t}\n\t\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"Token %s\", jupyterToken))\n\n\t\tt.Logf(\"Sending request to /api endpoint...\")\n\t\tresp, err := httpClient.Do(req)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to send request: %v\", err)\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode != http.StatusOK {\n\t\t\tt.Logf(\"API request returned non-200 status code: %d %s\", resp.StatusCode, resp.Status)\n\t\t} else {\n\t\t\tt.Logf(\"API request successful, status code: %d %s\", resp.StatusCode, resp.Status)\n\n\t\t\trespDump, err := httputil.DumpResponse(resp, true)\n\t\t\tif err != nil {\n\t\t\t\tt.Logf(\"Unable to dump response: %v\", err)\n\t\t\t} else {\n\t\t\t\tt.Logf(\"Response details:\\n%s\", string(respDump))\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"Test Different Header Combinations\", func(t *testing.T) {\n\t\theaderSets := []map[string]string{\n\t\t\t{\n\t\t\t\t\"Authorization\": fmt.Sprintf(\"Token %s\", jupyterToken),\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"Authorization\": fmt.Sprintf(\"Token %s\", jupyterToken),\n\t\t\t\t\"X-XSRFToken\":   jupyterToken[:16], // Use first 16 characters of token as XSRF token attempt\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"Authorization\": fmt.Sprintf(\"token %s\", jupyterToken), // lowercase token\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"Cookie\": fmt.Sprintf(\"_xsrf=%s; jupyter_token=%s\", jupyterToken[:16], jupyterToken),\n\t\t\t},\n\t\t}\n\n\t\tfor i, headers := range headerSets {\n\t\t\tt.Logf(\"Testing header combination #%d:\", i+1)\n\t\t\tfor k, v := range headers {\n\t\t\t\tt.Logf(\"  %s: %s\", k, v)\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(\"GET\", fmt.Sprintf(\"%s/api/kernelspecs\", jupyterURL), nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t\t\t}\n\n\t\t\tfor k, v := range headers {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\n\t\t\tt.Logf(\"Sending request to /api/kernelspecs endpoint...\")\n\t\t\tresp, err := httpClient.Do(req)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Failed to send request: %v\", err)\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tt.Logf(\"Response status code: %d %s\", resp.StatusCode, resp.Status)\n\t\t\tif resp.StatusCode == http.StatusOK {\n\t\t\t\tt.Logf(\"Successfully found valid header combination!\")\n\n\t\t\t\trespDump, err := httputil.DumpResponse(resp, true)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Logf(\"Unable to dump response: %v\", err)\n\t\t\t\t} else {\n\t\t\t\t\tmaxLen := 500\n\t\t\t\t\trespStr := string(respDump)\n\t\t\t\t\tif len(respStr) > maxLen {\n\t\t\t\t\t\tt.Logf(\"Response (truncated):\\n%s...\", respStr[:maxLen])\n\t\t\t\t\t} else {\n\t\t\t\t\t\tt.Logf(\"Response:\\n%s\", respStr)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\n// debugTransport logs request and response dumps.\ntype debugTransport struct {\n\tt *testing.T\n}\n\nfunc (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n\treqDump, err := httputil.DumpRequestOut(req, true)\n\tif err != nil {\n\t\td.t.Logf(\"Unable to dump request: %v\", err)\n\t} else {\n\t\tmaxLen := 500\n\t\treqStr := string(reqDump)\n\t\tif len(reqStr) > maxLen {\n\t\t\td.t.Logf(\"Request (truncated):\\n%s...\", reqStr[:maxLen])\n\t\t} else {\n\t\t\td.t.Logf(\"Request:\\n%s\", reqStr)\n\t\t}\n\t}\n\n\tresp, err := http.DefaultTransport.RoundTrip(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\td.t.Logf(\"Response status: %d %s\", resp.StatusCode, resp.Status)\n\n\treturn resp, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/events.json",
    "content": "[\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_6\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.206377Z\",\n      \"msg_type\": \"status\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e1df6eb2-f395e4906c9cecd23d97b548_7_2\",\n      \"username\": \"username\",\n      \"session\": \"e1df6eb2-f395e4906c9cecd23d97b548\",\n      \"date\": \"2025-06-06T09:20:51.204953Z\",\n      \"msg_type\": \"kernel_info_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"execution_state\": \"busy\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_8\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.207083Z\",\n      \"msg_type\": \"status\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e1df6eb2-f395e4906c9cecd23d97b548_7_1\",\n      \"username\": \"username\",\n      \"session\": \"e1df6eb2-f395e4906c9cecd23d97b548\",\n      \"date\": \"2025-06-06T09:20:51.204866Z\",\n      \"msg_type\": \"kernel_info_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"execution_state\": \"idle\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_9\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.207169Z\",\n      \"msg_type\": \"status\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e1df6eb2-f395e4906c9cecd23d97b548_7_2\",\n      \"username\": \"username\",\n      \"session\": \"e1df6eb2-f395e4906c9cecd23d97b548\",\n      \"date\": \"2025-06-06T09:20:51.204953Z\",\n      \"msg_type\": \"kernel_info_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"execution_state\": \"idle\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_10\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.248234Z\",\n      \"msg_type\": \"status\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"execution_state\": \"busy\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_11\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.248481Z\",\n      \"msg_type\": \"execute_input\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"code\": \"print('Hello, Jupyter!')\\nresult = 2 + 2\\nresult\",\n      \"execution_count\": 1\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_13\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.253641Z\",\n      \"msg_type\": \"stream\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"name\": \"stdout\",\n      \"text\": \"Hello, Jupyter!\\n\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_12\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.251743Z\",\n      \"msg_type\": \"execute_result\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"data\": {\n        \"text/plain\": \"4\"\n      },\n      \"metadata\": {},\n      \"execution_count\": 1\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_14\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.255042Z\",\n      \"msg_type\": \"execute_reply\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {\n      \"dependencies_met\": true,\n      \"engine\": \"d82231bb-94b0-4296-8372-2913351ee2a1\",\n      \"started\": \"2025-06-06T09:20:51.248468Z\",\n      \"status\": \"ok\"\n    },\n    \"content\": {\n      \"status\": \"ok\",\n      \"execution_count\": 1,\n      \"user_expressions\": {},\n      \"payload\": []\n    },\n    \"buffers\": [],\n    \"channel\": \"shell\"\n  },\n  {\n    \"header\": {\n      \"msg_id\": \"e5e24851-db96ed91126b13f9b603136f_123284_15\",\n      \"username\": \"username\",\n      \"session\": \"e5e24851-db96ed91126b13f9b603136f\",\n      \"date\": \"2025-06-06T09:20:51.255385Z\",\n      \"msg_type\": \"status\",\n      \"version\": \"5.3\"\n    },\n    \"parent_header\": {\n      \"msg_id\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1\",\n      \"username\": \"go-client\",\n      \"session\": \"e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0\",\n      \"date\": \"2025-06-06T17:20:51+08:00\",\n      \"msg_type\": \"execute_request\",\n      \"version\": \"5.3\"\n    },\n    \"metadata\": {},\n    \"content\": {\n      \"execution_state\": \"idle\"\n    },\n    \"buffers\": [],\n    \"channel\": \"iopub\"\n  }\n]\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/execute.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package execute provides functionality for executing Jupyter kernel code via WebSocket\npackage execute\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/gorilla/websocket\"\n)\n\n// HTTPClient defines the HTTP client interface\ntype HTTPClient interface {\n\tDo(req *http.Request) (*http.Response, error)\n}\n\n// Client is the client for code execution\ntype Client struct {\n\t// Internal HTTP client for sending HTTP requests\n\thttpClient HTTPClient\n\n\t// WebSocket connection\n\tconn *websocket.Conn\n\n\t// Message handler mappings\n\thandlers map[MessageType]func(*Message)\n\n\t// Session ID\n\tsession string\n\n\t// Message ID counter\n\tmsgCounter int\n\n\t// Mutex for protecting concurrent access\n\tmu sync.Mutex\n\n\t// WebSocket URL for kernel connection\n\twsURL string\n}\n\n// NewClient creates a new code execution client\nfunc NewClient(baseURL string, httpClient HTTPClient) *Client {\n\treturn &Client{\n\t\thttpClient: httpClient,\n\t\thandlers:   make(map[MessageType]func(*Message)),\n\t\tsession:    uuid.New().String(),\n\t\tmsgCounter: 0,\n\t}\n}\n\n// Connect connects to the WebSocket of the specified kernel\nfunc (c *Client) Connect(wsURL string) error {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\t// Save WebSocket URL\n\tc.wsURL = wsURL\n\n\t// Connect to WebSocket\n\tconn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)\n\tif resp != nil && err != nil {\n\t\tresp.Body.Close()\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to connect to kernel: %w\", err)\n\t}\n\tc.conn = conn\n\n\t// Register default message handlers\n\tc.registerDefaultHandlers()\n\n\t// Start message receiving goroutine\n\tgo c.receiveMessages()\n\n\treturn nil\n}\n\n// Disconnect disconnects the WebSocket connection to the kernel\nfunc (c *Client) Disconnect() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif c.conn != nil {\n\t\tc.conn.Close()\n\t\tc.conn = nil\n\t}\n}\n\n// IsConnected checks if connected to the kernel\nfunc (c *Client) IsConnected() bool {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\treturn c.conn != nil\n}\n\n// ExecuteCodeStream executes code in streaming mode, sending results to the provided channel\nfunc (c *Client) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error {\n\tif !c.IsConnected() {\n\t\treturn errors.New(\"not connected to kernel, please call Connect method\")\n\t}\n\n\t// record start time\n\tstartTime := time.Now()\n\n\t// prepare execution request\n\tmsgID := c.nextMessageID()\n\trequest := &ExecuteRequest{\n\t\tCode:            code,\n\t\tSilent:          false,\n\t\tStoreHistory:    true,\n\t\tUserExpressions: make(map[string]string),\n\t\tAllowStdin:      false,\n\t\tStopOnError:     true,\n\t}\n\n\t// serialize request content\n\tcontent, err := json.Marshal(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// create message\n\tmsg := &Message{\n\t\tHeader: Header{\n\t\t\tMessageID:   msgID,\n\t\t\tUsername:    \"go-client\",\n\t\t\tSession:     c.session,\n\t\t\tDate:        time.Now().Format(time.RFC3339),\n\t\t\tMessageType: string(MsgExecuteRequest),\n\t\t\tVersion:     \"5.3\",\n\t\t},\n\t\tParentHeader: Header{},\n\t\tMetadata:     make(map[string]interface{}),\n\t\tContent:      content,\n\t\tChannel:      \"shell\",\n\t}\n\n\t// Create result object\n\tresult := &ExecutionResult{\n\t\tStatus:        \"ok\",\n\t\tStream:        make([]*StreamOutput, 0),\n\t\tExecutionTime: 0,\n\t}\n\n\t// Register temporary handler to receive execution result\n\tvar executeDone bool\n\tvar executeMutex sync.Mutex\n\tvar executeResult *ExecuteResult\n\n\t// Create mutex to protect result object\n\tvar resultMutex sync.Mutex\n\n\t// Clear temporary handlers\n\tc.clearTemporaryHandlers()\n\n\tc.registerHandler(MsgExecuteReply, func(msg *Message) {\n\t\tvar execReply ExecuteReply\n\t\tif err := json.Unmarshal(msg.Content, &execReply); err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tresultMutex.Lock()\n\t\tresult.ExecutionCount = execReply.ExecutionCount\n\t\tif execReply.EName != \"\" {\n\t\t\tresult.Error = &execReply.ErrorOutput\n\t\t}\n\t\tresultMutex.Unlock()\n\t})\n\n\t// register execution result handler\n\tc.registerHandler(MsgExecuteResult, func(msg *Message) {\n\t\tvar execResult ExecuteResult\n\t\tif err := json.Unmarshal(msg.Content, &execResult); err != nil {\n\t\t\treturn\n\t\t}\n\n\t\texecuteMutex.Lock()\n\t\texecuteResult = &execResult\n\t\texecuteMutex.Unlock()\n\n\t\tresultMutex.Lock()\n\t\tresult.ExecutionCount = execResult.ExecutionCount\n\n\t\tnotify := &ExecutionResult{}\n\t\tnotify.ExecutionCount = executeResult.ExecutionCount\n\t\tnotify.ExecutionData = executeResult.Data\n\n\t\tresultChan <- notify\n\t\tresultMutex.Unlock()\n\t})\n\n\t// Register stream output handler\n\tc.registerHandler(MsgStream, func(msg *Message) {\n\t\tvar stream StreamOutput\n\t\tif err := json.Unmarshal(msg.Content, &stream); err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tresultMutex.Lock()\n\t\tresult.Stream = append(result.Stream, &stream)\n\n\t\tnotify := &ExecutionResult{}\n\t\tnotify.Stream = []*StreamOutput{&stream}\n\n\t\tresultChan <- notify\n\t\tresultMutex.Unlock()\n\t})\n\n\t// register error handler\n\tc.registerHandler(MsgError, func(msg *Message) {\n\t\tvar errOutput ErrorOutput\n\t\tif err := json.Unmarshal(msg.Content, &errOutput); err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tresultMutex.Lock()\n\t\tresult.Status = \"error\"\n\t\tresult.Error = &errOutput\n\n\t\tnotify := &ExecutionResult{}\n\t\tnotify.Error = &errOutput\n\t\tnotify.Status = \"error\"\n\n\t\tresultChan <- notify\n\t\tresultMutex.Unlock()\n\t})\n\n\t// register status handler\n\tc.registerHandler(MsgStatus, func(msg *Message) {\n\t\tvar status StatusUpdate\n\t\tif err := json.Unmarshal(msg.Content, &status); err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tif status.ExecutionState == StateIdle {\n\t\t\texecuteMutex.Lock()\n\n\t\t\t// Check whether execution can be completed\n\t\t\tif !executeDone {\n\t\t\t\texecuteDone = true\n\t\t\t\tgo func() {\n\t\t\t\t\t// calculate execution time\n\t\t\t\t\tresultMutex.Lock()\n\t\t\t\t\tresult.ExecutionTime = time.Since(startTime)\n\n\t\t\t\t\t// Send final result\n\t\t\t\t\tnotify := &ExecutionResult{}\n\t\t\t\t\tnotify.ExecutionTime = result.ExecutionTime\n\n\t\t\t\t\tresultChan <- notify\n\t\t\t\t\tresultMutex.Unlock()\n\n\t\t\t\t\tfor result.ExecutionCount <= 0 && result.Error == nil {\n\t\t\t\t\t\ttime.Sleep(300 * time.Millisecond)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Close result channel\n\t\t\t\t\tclose(resultChan)\n\t\t\t\t}()\n\t\t\t}\n\t\t\texecuteMutex.Unlock()\n\t\t}\n\t})\n\n\t// send execution request\n\tc.mu.Lock()\n\terr = c.conn.WriteJSON(msg)\n\tc.mu.Unlock()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send execution request: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ExecuteCodeWithCallback executes code using callback functions\nfunc (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) error {\n\tif !c.IsConnected() {\n\t\treturn errors.New(\"not connected to kernel, please call Connect method\")\n\t}\n\n\t// prepare execution request\n\tmsgID := c.nextMessageID()\n\trequest := &ExecuteRequest{\n\t\tCode:            code,\n\t\tSilent:          false,\n\t\tStoreHistory:    true,\n\t\tUserExpressions: make(map[string]string),\n\t\tAllowStdin:      false,\n\t\tStopOnError:     true,\n\t}\n\n\t// serialize request content\n\tcontent, err := json.Marshal(request)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// create message\n\tmsg := &Message{\n\t\tHeader: Header{\n\t\t\tMessageID:   msgID,\n\t\t\tUsername:    \"go-client\",\n\t\t\tSession:     c.session,\n\t\t\tDate:        time.Now().Format(time.RFC3339),\n\t\t\tMessageType: string(MsgExecuteRequest),\n\t\t\tVersion:     \"5.3\",\n\t\t},\n\t\tParentHeader: Header{},\n\t\tMetadata:     make(map[string]interface{}),\n\t\tContent:      content,\n\t\tChannel:      \"shell\",\n\t}\n\n\t// register execution result handler\n\tif handler.OnExecuteResult != nil {\n\t\tc.registerHandler(MsgExecuteResult, func(msg *Message) {\n\t\t\tvar execResult ExecuteResult\n\t\t\tif err := json.Unmarshal(msg.Content, &execResult); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// calls callback functions\n\t\t\thandler.OnExecuteResult(&execResult)\n\t\t})\n\t}\n\n\t// Register stream output handler\n\tif handler.OnStream != nil {\n\t\tc.registerHandler(MsgStream, func(msg *Message) {\n\t\t\tvar stream StreamOutput\n\t\t\tif err := json.Unmarshal(msg.Content, &stream); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// calls callback functions\n\t\t\thandler.OnStream(&stream)\n\t\t})\n\t}\n\n\t// Register display data handler\n\tif handler.OnDisplayData != nil {\n\t\tc.registerHandler(MsgDisplayData, func(msg *Message) {\n\t\t\tvar display DisplayData\n\t\t\tif err := json.Unmarshal(msg.Content, &display); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// calls callback functions\n\t\t\thandler.OnDisplayData(&display)\n\t\t})\n\t}\n\n\t// register error handler\n\tif handler.OnError != nil {\n\t\tc.registerHandler(MsgError, func(msg *Message) {\n\t\t\tvar errOutput ErrorOutput\n\t\t\tif err := json.Unmarshal(msg.Content, &errOutput); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// calls callback functions\n\t\t\thandler.OnError(&errOutput)\n\t\t})\n\t}\n\n\t// register status handler\n\tif handler.OnStatus != nil {\n\t\tc.registerHandler(MsgStatus, func(msg *Message) {\n\t\t\tvar status StatusUpdate\n\t\t\tif err := json.Unmarshal(msg.Content, &status); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// calls callback functions\n\t\t\thandler.OnStatus(&status)\n\t\t})\n\t}\n\n\t// send execution request\n\tc.mu.Lock()\n\terr = c.conn.WriteJSON(msg)\n\tc.mu.Unlock()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send execution request: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// Register default message handlers\nfunc (c *Client) registerDefaultHandlers() {\n\t// default message handlers can be registered here\n}\n\n// Register temporary message handler\nfunc (c *Client) registerHandler(msgType MessageType, handler func(*Message)) {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.handlers[msgType] = handler\n}\n\n// Clear temporary message handlers\nfunc (c *Client) clearTemporaryHandlers() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.handlers = make(map[MessageType]func(*Message))\n\tc.registerDefaultHandlers()\n}\n\n// Receive WebSocket messages\nfunc (c *Client) receiveMessages() {\n\tfor {\n\t\tc.mu.Lock()\n\t\tconn := c.conn\n\t\tc.mu.Unlock()\n\n\t\tif conn == nil {\n\t\t\tbreak\n\t\t}\n\n\t\t// Receive message\n\t\tvar msg Message\n\t\terr := conn.ReadJSON(&msg)\n\t\tif err != nil {\n\t\t\t// connection may already be closed\n\t\t\tbreak\n\t\t}\n\n\t\t// Process message\n\t\tc.handleMessage(&msg)\n\t}\n}\n\n// Handle received messages\nfunc (c *Client) handleMessage(msg *Message) {\n\t// Extract message type\n\tmsgType := MessageType(msg.Header.MessageType)\n\n\t// call the corresponding handler\n\tc.mu.Lock()\n\thandler, ok := c.handlers[msgType]\n\tc.mu.Unlock()\n\n\tif ok && handler != nil {\n\t\thandler(msg)\n\t}\n}\n\n// generate next messageID\nfunc (c *Client) nextMessageID() string {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.msgCounter++\n\treturn fmt.Sprintf(\"%s-%d\", c.session, c.msgCounter)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/execute_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage execute\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\n// Create WebSocket test server\nfunc createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *httptest.Server {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Validate request path\n\t\tif !strings.HasPrefix(r.URL.Path, \"/api/kernels/\") {\n\t\t\tt.Errorf(\"expected path to start with '/api/kernels/', got '%s'\", r.URL.Path)\n\t\t}\n\t\tif !strings.HasSuffix(r.URL.Path, \"/channels\") {\n\t\t\tt.Errorf(\"expected path to end with '/channels', got '%s'\", r.URL.Path)\n\t\t}\n\n\t\t// Upgrade HTTP connection to WebSocket\n\t\tupgrader := websocket.Upgrader{\n\t\t\tCheckOrigin: func(r *http.Request) bool { return true },\n\t\t}\n\t\tconn, err := upgrader.Upgrade(w, r, nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to upgrade to WebSocket: %v\", err)\n\t\t}\n\t\tdefer conn.Close()\n\n\t\t// Handle WebSocket connection\n\t\thandleFunc(conn)\n\t}))\n\n\treturn server\n}\n\n// Test streaming code execution\nfunc TestExecuteCodeStream(t *testing.T) {\n\t// Spin up mock WebSocket server\n\tserver := createTestServer(t, func(conn *websocket.Conn) {\n\t\t// Read execution request\n\t\tvar executeRequest Message\n\t\terr := conn.ReadJSON(&executeRequest)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to read execution request: %v\", err)\n\t\t}\n\n\t\t// Send multiple stream messages\n\t\tfor i := 0; i < 3; i++ {\n\t\t\tstreamContent, _ := json.Marshal(StreamOutput{\n\t\t\t\tName: StreamStdout,\n\t\t\t\tText: \"Line \" + string(rune('0'+i)) + \"\\n\",\n\t\t\t})\n\n\t\t\tstreamMsg := Message{\n\t\t\t\tHeader: Header{\n\t\t\t\t\tMessageID:   \"stream-msg-id-\" + string(rune('0'+i)),\n\t\t\t\t\tSession:     executeRequest.Header.Session,\n\t\t\t\t\tMessageType: string(MsgStream),\n\t\t\t\t},\n\t\t\t\tParentHeader: executeRequest.Header,\n\t\t\t\tContent:      json.RawMessage(streamContent),\n\t\t\t}\n\t\t\tconn.WriteJSON(streamMsg)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t}\n\n\t\t// Send execution result\n\t\tresultContent, _ := json.Marshal(ExecuteResult{\n\t\t\tExecutionCount: 1,\n\t\t\tData: map[string]interface{}{\n\t\t\t\t\"text/plain\": \"Completed\",\n\t\t\t},\n\t\t\tMetadata: map[string]interface{}{},\n\t\t})\n\n\t\texecuteResultMsg := Message{\n\t\t\tHeader: Header{\n\t\t\t\tMessageID:   \"result-msg-id\",\n\t\t\t\tSession:     executeRequest.Header.Session,\n\t\t\t\tMessageType: string(MsgExecuteResult),\n\t\t\t},\n\t\t\tParentHeader: executeRequest.Header,\n\t\t\tContent:      json.RawMessage(resultContent),\n\t\t}\n\t\tconn.WriteJSON(executeResultMsg)\n\n\t\t// Send status message\n\t\tstatusContent, _ := json.Marshal(StatusUpdate{\n\t\t\tExecutionState: StateIdle,\n\t\t})\n\n\t\tstatusMsg := Message{\n\t\t\tHeader: Header{\n\t\t\t\tMessageID:   \"status-msg-id\",\n\t\t\t\tSession:     executeRequest.Header.Session,\n\t\t\t\tMessageType: string(MsgStatus),\n\t\t\t},\n\t\t\tParentHeader: executeRequest.Header,\n\t\t\tContent:      json.RawMessage(statusContent),\n\t\t}\n\t\tconn.WriteJSON(statusMsg)\n\t})\n\tdefer server.Close()\n\n\t// Convert HTTP URL to WebSocket URL\n\twsURL := \"ws\" + strings.TrimPrefix(server.URL, \"http\") + \"/api/kernels/test-kernel-id/channels\"\n\n\t// Create executor client\n\texecutor := NewExecutor(wsURL, nil)\n\n\t// Connect to WebSocket\n\terr := executor.Connect()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to connect to WebSocket: %v\", err)\n\t}\n\tdefer executor.Disconnect()\n\n\t// Execute code in streaming mode\n\tresultChan := make(chan *ExecutionResult, 10)\n\terr = executor.ExecuteCodeStream(\"for i in range(3):\\n    print(f'Line {i}')\", resultChan)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to start streaming execution: %v\", err)\n\t}\n\n\t// Receive and verify stream results\n\tresultCount := 0\n\tfor result := range resultChan {\n\t\tif result == nil {\n\t\t\tbreak\n\t\t}\n\t\tresultCount++\n\t}\n\n\t// Should receive at least 4 results (3 stream outputs + 1 final result)\n\tif resultCount < 4 {\n\t\tt.Errorf(\"expected at least 4 results, got %d\", resultCount)\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/executor.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage execute\n\n// Executor is the interface for code execution\ntype Executor struct {\n\t// Internal client\n\tclient *Client\n\t// WebSocket URL\n\twsURL string\n}\n\n// NewExecutor creates a new code executor\nfunc NewExecutor(wsURL string, httpClient HTTPClient) *Executor {\n\tclient := NewClient(\"\", httpClient)\n\treturn &Executor{\n\t\tclient: client,\n\t\twsURL:  wsURL,\n\t}\n}\n\n// Connect connects to the kernel\nfunc (e *Executor) Connect() error {\n\treturn e.client.Connect(e.wsURL)\n}\n\n// Disconnect disconnects from the kernel\nfunc (e *Executor) Disconnect() {\n\te.client.Disconnect()\n}\n\n// ExecuteCodeStream executes code in streaming mode, sending results to the provided channel\nfunc (e *Executor) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error {\n\treturn e.client.ExecuteCodeStream(code, resultChan)\n}\n\n// ExecuteCodeWithCallback executes code using callback functions\nfunc (e *Executor) ExecuteCodeWithCallback(code string, handler CallbackHandler) error {\n\treturn e.client.ExecuteCodeWithCallback(code, handler)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package execute provides functionality for executing Jupyter kernel code via WebSocket\npackage execute\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\n// MessageType represents Jupyter message types\ntype MessageType string\n\nconst (\n\t// MsgExecuteRequest requests code execution\n\tMsgExecuteRequest MessageType = \"execute_request\"\n\n\t// MsgExecuteInput represents the input code\n\tMsgExecuteInput MessageType = \"execute_input\"\n\n\t// MsgExecuteResult represents execution results\n\tMsgExecuteResult MessageType = \"execute_result\"\n\n\t// MsgDisplayData represents data to be displayed\n\tMsgDisplayData MessageType = \"display_data\"\n\n\t// MsgStream represents stream output (stdout/stderr)\n\tMsgStream MessageType = \"stream\"\n\n\t// MsgError represents errors during execution\n\tMsgError MessageType = \"error\"\n\n\t// MsgStatus represents kernel status updates\n\tMsgStatus MessageType = \"status\"\n\n\t// MsgClearOutput represents clearing output\n\tMsgClearOutput MessageType = \"clear_output\"\n\n\t// MsgComm represents communication messages\n\tMsgComm MessageType = \"comm\"\n\n\t// MsgCommOpen represents opening communication\n\tMsgCommOpen MessageType = \"comm_open\"\n\n\t// MsgCommClose represents closing communication\n\tMsgCommClose MessageType = \"comm_close\"\n\n\t// MsgCommMsg representscommunication message content\n\tMsgCommMsg MessageType = \"comm_msg\"\n\n\t// MsgKernelInfo represents kernel information request\n\tMsgKernelInfo MessageType = \"kernel_info_request\"\n\n\t// MsgKernelInfoReply represents kernel information response\n\tMsgKernelInfoReply MessageType = \"kernel_info_reply\"\n\n\tMsgExecuteReply MessageType = \"execute_reply\"\n)\n\n// StreamType representsoutput stream type\ntype StreamType string\n\nconst (\n\t// StreamStdout represents standard output stream\n\tStreamStdout StreamType = \"stdout\"\n\n\t// StreamStderr representsstandard error stream\n\tStreamStderr StreamType = \"stderr\"\n)\n\n// ExecutionState represents kernel execution state\ntype ExecutionState string\n\nconst (\n\t// StateIdle representskernel is idle\n\tStateIdle ExecutionState = \"idle\"\n\n\t// StateBusy representskernel is busy\n\tStateBusy ExecutionState = \"busy\"\n\n\t// StateStarting representskernel is starting\n\tStateStarting ExecutionState = \"starting\"\n)\n\n// Header defines Jupyter message header\ntype Header struct {\n\t// MessageID is the unique identifier of the message\n\tMessageID string `json:\"msg_id\"`\n\n\t// Username is the username sending the message\n\tUsername string `json:\"username\"`\n\n\t// Session is the session identifier\n\tSession string `json:\"session\"`\n\n\t// Date is the timestamp when the message was sent\n\tDate string `json:\"date\"`\n\n\t// MessageType is the type of the message\n\tMessageType string `json:\"msg_type\"`\n\n\t// Version is the version of the message protocol\n\tVersion string `json:\"version\"`\n}\n\n// Message defines the basic structure of Jupyter messages\ntype Message struct {\n\t// Header is the message header\n\tHeader Header `json:\"header\"`\n\n\t// ParentHeader is the parent message header, used to track requests and responses\n\tParentHeader Header `json:\"parent_header\"`\n\n\t// Metadata is the metadata related to the message\n\tMetadata map[string]interface{} `json:\"metadata\"`\n\n\t// Content is the actual content of the message\n\tContent json.RawMessage `json:\"content\"`\n\n\t// Buffers is the binary buffer\n\tBuffers [][]byte `json:\"buffers\"`\n\n\t// Channel is the channel of the message\n\tChannel string `json:\"channel\"`\n}\n\n// ExecuteRequest defines the request content for code execution\ntype ExecuteRequest struct {\n\t// Code is the code to execute\n\tCode string `json:\"code\"`\n\n\t// Silent represents whether to execute in silent mode\n\tSilent bool `json:\"silent\"`\n\n\t// StoreHistory represents whether to store execution history\n\tStoreHistory bool `json:\"store_history\"`\n\n\t// UserExpressions contains expressions to evaluate in the execution context\n\tUserExpressions map[string]string `json:\"user_expressions\"`\n\n\t// AllowStdin represents whether to allow reading from standard input\n\tAllowStdin bool `json:\"allow_stdin\"`\n\n\t// StopOnError represents whether to stop execution when an error is encountered\n\tStopOnError bool `json:\"stop_on_error\"`\n}\n\n// StreamOutput represents stream output content\ntype StreamOutput struct {\n\t// Name is the stream name (stdout or stderr)\n\tName StreamType `json:\"name\"`\n\n\t// Text is the text content of the stream\n\tText string `json:\"text\"`\n}\n\n// ExecuteResult represents the result of code execution\ntype ExecuteResult struct {\n\t// ExecutionCount is the execution counter value\n\tExecutionCount int `json:\"execution_count\"`\n\n\t// Data contains result data in different formats\n\tData map[string]interface{} `json:\"data\"`\n\n\t// Metadata is the metadata related to the result\n\tMetadata map[string]interface{} `json:\"metadata\"`\n}\n\ntype ExecuteReply struct {\n\t// ExecutionCount is the execution counter value\n\tExecutionCount int `json:\"execution_count\"`\n\n\tStatus string `json:\"status\"`\n\n\tErrorOutput `json:\",inline\"`\n}\n\n// DisplayData representsdata to display\ntype DisplayData struct {\n\t// Data contains display data in different formats\n\tData map[string]interface{} `json:\"data\"`\n\n\t// Metadata is the metadata related to display data\n\tMetadata map[string]interface{} `json:\"metadata\"`\n}\n\n// ErrorOutput representserrors during execution\ntype ErrorOutput struct {\n\t// EName is the name of the error\n\tEName string `json:\"ename\"`\n\n\t// EValue is the value of the error\n\tEValue string `json:\"evalue\"`\n\n\t// Traceback is the traceback of the error\n\tTraceback []string `json:\"traceback\"`\n}\n\nfunc (e *ErrorOutput) String() string {\n\treturn fmt.Sprintf(`\nError: %s\nValue: %s\nTraceback: %s\n`, e.EName, e.EValue, strings.Join(e.Traceback, \"\\n\"))\n}\n\n// StatusUpdate represents kernel status update\ntype StatusUpdate struct {\n\t// ExecutionState is the execution state of the kernel\n\tExecutionState ExecutionState `json:\"execution_state\"`\n}\n\n// ExecutionResult represents the complete result of code execution\ntype ExecutionResult struct {\n\t// Status represents the status of execution\n\tStatus string `json:\"status\"`\n\n\t// ExecutionCount is the execution counter value\n\tExecutionCount int `json:\"execution_count\"`\n\n\t// Stream contains all stream output\n\tStream []*StreamOutput `json:\"stream\"`\n\n\t// Error contains errors during execution (if any)\n\tError *ErrorOutput `json:\"error\"`\n\n\t// ExecutionTime is the total time of code execution\n\tExecutionTime time.Duration `json:\"execution_time\"`\n\n\t// ExecutionData\n\tExecutionData map[string]interface{} `json:\"execution_data\"`\n}\n\n// CallbackHandler defines callback functions for handling different types of messages\ntype CallbackHandler struct {\n\t// OnExecuteResult handles execution result messages\n\tOnExecuteResult func(*ExecuteResult)\n\n\t// OnStream handles stream output messages\n\tOnStream func(...*StreamOutput)\n\n\t// OnDisplayData handles display data messages\n\tOnDisplayData func(*DisplayData)\n\n\t// OnError handles error messages\n\tOnError func(*ErrorOutput)\n\n\t// OnStatus handles status update messages\n\tOnStatus func(*StatusUpdate)\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/execute/zz_generated.deepcopy.go",
    "content": "//go:build !ignore_autogenerated\n\n/*\nCopyright 2022.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Code generated by controller-gen. DO NOT EDIT.\n\npackage execute\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *ErrorOutput) DeepCopyInto(out *ErrorOutput) {\n\t*out = *in\n\tif in.Traceback != nil {\n\t\tin, out := &in.Traceback, &out.Traceback\n\t\t*out = make([]string, len(*in))\n\t\tcopy(*out, *in)\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorOutput.\nfunc (in *ErrorOutput) DeepCopy() *ErrorOutput {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(ErrorOutput)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *ExecutionResult) DeepCopyInto(out *ExecutionResult) {\n\t*out = *in\n\tif in.Stream != nil {\n\t\tin, out := &in.Stream, &out.Stream\n\t\t*out = make([]*StreamOutput, len(*in))\n\t\tfor i := range *in {\n\t\t\tif (*in)[i] != nil {\n\t\t\t\tin, out := &(*in)[i], &(*out)[i]\n\t\t\t\t*out = new(StreamOutput)\n\t\t\t\t**out = **in\n\t\t\t}\n\t\t}\n\t}\n\tif in.Error != nil {\n\t\tin, out := &in.Error, &out.Error\n\t\t*out = new(ErrorOutput)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionResult.\nfunc (in *ExecutionResult) DeepCopy() *ExecutionResult {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(ExecutionResult)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *StreamOutput) DeepCopyInto(out *StreamOutput) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StreamOutput.\nfunc (in *StreamOutput) DeepCopy() *StreamOutput {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(StreamOutput)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/integration_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jupyter\n\nimport (\n\t\"encoding/json\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gorilla/websocket\"\n)\n\n// Test integration flow: authentication -> get kernel specs -> create session -> execute code -> close session\nfunc TestIntegrationFlow(t *testing.T) {\n\t// Create mock HTTP server\n\thttpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Handle authentication validation request\n\t\tif r.URL.Path == \"/api/status\" {\n\t\t\t// Check authentication token\n\t\t\tauth := r.Header.Get(\"Authorization\")\n\t\t\tif auth != \"token test-token\" {\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Return status information\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.Write([]byte(`{\"status\": \"ok\"}`))\n\t\t\treturn\n\t\t}\n\n\t\t// Handle kernel specs request\n\t\tif r.URL.Path == \"/api/kernelspecs\" {\n\t\t\t// Return kernel specs\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"default\": \"python3\",\n\t\t\t\t\"kernelspecs\": {\n\t\t\t\t\t\"python3\": {\n\t\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\t\"display_name\": \"Python 3\",\n\t\t\t\t\t\t\"language\": \"python\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}`))\n\t\t\treturn\n\t\t}\n\n\t\t// Handle session-related requests\n\t\tif r.URL.Path == \"/api/sessions\" {\n\t\t\tif r.Method == http.MethodGet {\n\t\t\t\t// List sessions\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`[{\n\t\t\t\t\t\"id\": \"test-session-id\",\n\t\t\t\t\t\"path\": \"/path/to/notebook.ipynb\",\n\t\t\t\t\t\"name\": \"Test Session\",\n\t\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\t\"kernel\": {\n\t\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\t\"name\": \"python3\"\n\t\t\t\t\t}\n\t\t\t\t}]`))\n\t\t\t\treturn\n\t\t\t} else if r.Method == http.MethodPost {\n\t\t\t\t// Create session\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.WriteHeader(http.StatusCreated)\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\t\"id\": \"test-session-id\",\n\t\t\t\t\t\"path\": \"/path/to/notebook.ipynb\",\n\t\t\t\t\t\"name\": \"Test Session\",\n\t\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\t\"kernel\": {\n\t\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\t\"name\": \"python3\"\n\t\t\t\t\t}\n\t\t\t\t}`))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Handle specific session requests\n\t\tif strings.HasPrefix(r.URL.Path, \"/api/sessions/test-session-id\") {\n\t\t\tif r.Method == http.MethodDelete {\n\t\t\t\t// Delete session\n\t\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t\t\treturn\n\t\t\t} else if r.Method == http.MethodPatch {\n\t\t\t\t// Modify session\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\t\"id\": \"test-session-id\",\n\t\t\t\t\t\"path\": \"/path/to/updated-notebook.ipynb\",\n\t\t\t\t\t\"name\": \"Updated Test Session\",\n\t\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\t\"kernel\": {\n\t\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\t\"name\": \"python3\"\n\t\t\t\t\t}\n\t\t\t\t}`))\n\t\t\t\treturn\n\t\t\t} else if r.Method == http.MethodGet {\n\t\t\t\t// Get session\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\t\"id\": \"test-session-id\",\n\t\t\t\t\t\"path\": \"/path/to/notebook.ipynb\",\n\t\t\t\t\t\"name\": \"Test Session\",\n\t\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\t\"kernel\": {\n\t\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\t\"name\": \"python3\"\n\t\t\t\t\t}\n\t\t\t\t}`))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Handle kernel requests\n\t\tif r.URL.Path == \"/api/kernels\" {\n\t\t\tif r.Method == http.MethodGet {\n\t\t\t\t// List kernels\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`[{\n\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\"execution_state\": \"idle\"\n\t\t\t\t}]`))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Handle specific kernel requests\n\t\tif strings.HasPrefix(r.URL.Path, \"/api/kernels/test-kernel-id\") {\n\t\t\tif r.Method == http.MethodGet {\n\t\t\t\t// Get kernel\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\"execution_state\": \"idle\"\n\t\t\t\t}`))\n\t\t\t\treturn\n\t\t\t} else if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, \"/restart\") {\n\t\t\t\t// Restart kernel\n\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\"restarted\": true\n\t\t\t\t}`))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// If it's a WebSocket connection request, upgrade to WebSocket\n\t\tif strings.HasSuffix(r.URL.Path, \"/channels\") {\n\t\t\t// Return 404, as WebSocket connections will be handled by a dedicated WebSocket server\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\t// For other requests, return 404\n\t\tw.WriteHeader(http.StatusNotFound)\n\t}))\n\tdefer httpServer.Close()\n\n\t// Create mock WebSocket server for code execution\n\twsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !strings.HasSuffix(r.URL.Path, \"/channels\") {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\treturn\n\t\t}\n\n\t\t// Upgrade HTTP connection to WebSocket\n\t\tupgrader := websocket.Upgrader{\n\t\t\tCheckOrigin: func(r *http.Request) bool { return true },\n\t\t}\n\t\tconn, err := upgrader.Upgrade(w, r, nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to upgrade connection to WebSocket: %v\", err)\n\t\t}\n\t\tdefer conn.Close()\n\n\t\t// Continuously handle WebSocket messages\n\t\tfor {\n\t\t\t// Read request message\n\t\t\tvar msg execute.Message\n\t\t\terr := conn.ReadJSON(&msg)\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// If it's an execute request, send mock response\n\t\t\tif msg.Header.MessageType == string(execute.MsgExecuteRequest) {\n\t\t\t\t// Send stream output\n\t\t\t\tstreamContent, _ := json.Marshal(execute.StreamOutput{\n\t\t\t\t\tName: execute.StreamStdout,\n\t\t\t\t\tText: \"Hello from test WebSocket!\\n\",\n\t\t\t\t})\n\n\t\t\t\tstreamMsg := execute.Message{\n\t\t\t\t\tHeader: execute.Header{\n\t\t\t\t\t\tMessageID:   \"stream-msg-id\",\n\t\t\t\t\t\tSession:     msg.Header.Session,\n\t\t\t\t\t\tMessageType: string(execute.MsgStream),\n\t\t\t\t\t},\n\t\t\t\t\tParentHeader: msg.Header,\n\t\t\t\t\tContent:      json.RawMessage(streamContent),\n\t\t\t\t}\n\t\t\t\tconn.WriteJSON(streamMsg)\n\n\t\t\t\t// Send execution result\n\t\t\t\tresultContent, _ := json.Marshal(execute.ExecuteResult{\n\t\t\t\t\tExecutionCount: 1,\n\t\t\t\t\tData: map[string]interface{}{\n\t\t\t\t\t\t\"text/plain\": \"Integration test result\",\n\t\t\t\t\t},\n\t\t\t\t\tMetadata: map[string]interface{}{},\n\t\t\t\t})\n\n\t\t\t\texecuteResultMsg := execute.Message{\n\t\t\t\t\tHeader: execute.Header{\n\t\t\t\t\t\tMessageID:   \"result-msg-id\",\n\t\t\t\t\t\tSession:     msg.Header.Session,\n\t\t\t\t\t\tMessageType: string(execute.MsgExecuteResult),\n\t\t\t\t\t},\n\t\t\t\t\tParentHeader: msg.Header,\n\t\t\t\t\tContent:      json.RawMessage(resultContent),\n\t\t\t\t}\n\t\t\t\tconn.WriteJSON(executeResultMsg)\n\n\t\t\t\t// Send status message\n\t\t\t\tstatusContent, _ := json.Marshal(execute.StatusUpdate{\n\t\t\t\t\tExecutionState: execute.StateIdle,\n\t\t\t\t})\n\n\t\t\t\tstatusMsg := execute.Message{\n\t\t\t\t\tHeader: execute.Header{\n\t\t\t\t\t\tMessageID:   \"status-msg-id\",\n\t\t\t\t\t\tSession:     msg.Header.Session,\n\t\t\t\t\t\tMessageType: string(execute.MsgStatus),\n\t\t\t\t\t},\n\t\t\t\t\tParentHeader: msg.Header,\n\t\t\t\t\tContent:      json.RawMessage(statusContent),\n\t\t\t\t}\n\t\t\t\tconn.WriteJSON(statusMsg)\n\t\t\t}\n\t\t}\n\t}))\n\tdefer wsServer.Close()\n\n\t// Create Jupyter client\n\tclient := NewClient(httpServer.URL)\n\tclient.SetToken(\"test-token\")\n\n\t// Test 1: Validate authentication\n\tstatus, err := client.ValidateAuth()\n\tif err != nil {\n\t\tt.Fatalf(\"Authentication validation failed: %v\", err)\n\t}\n\tif status != \"ok\" {\n\t\tt.Errorf(\"Authentication status incorrect, expected 'ok', got '%s'\", status)\n\t}\n\n\t// Test 2: Get kernel specs\n\tspecs, err := client.GetKernelSpecs()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to get kernel specs: %v\", err)\n\t}\n\tif specs.Default != \"python3\" {\n\t\tt.Errorf(\"Default kernel incorrect, expected 'python3', got '%s'\", specs.Default)\n\t}\n\tif len(specs.Kernelspecs) != 1 {\n\t\tt.Errorf(\"Kernel count incorrect, expected 1, got %d\", len(specs.Kernelspecs))\n\t}\n\n\t// Test 3: Create session\n\tsession, err := client.CreateSession(\"Test Session\", \"/path/to/notebook.ipynb\", \"python3\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create session: %v\", err)\n\t}\n\tif session.ID != \"test-session-id\" {\n\t\tt.Errorf(\"Session ID incorrect, expected 'test-session-id', got '%s'\", session.ID)\n\t}\n\tif session.Kernel.ID != \"test-kernel-id\" {\n\t\tt.Errorf(\"Kernel ID incorrect, expected 'test-kernel-id', got '%s'\", session.Kernel.ID)\n\t}\n\n\t// Modify WebSocket URL to point to WebSocket test server\n\twsURL := \"ws\" + strings.TrimPrefix(wsServer.URL, \"http\") + \"/api/kernels/test-kernel-id/channels\"\n\n\t// Test 4: Connect to kernel and execute code\n\texecutor := execute.NewExecutor(wsURL, nil)\n\terr = executor.Connect()\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to connect to kernel: %v\", err)\n\t}\n\tdefer executor.Disconnect()\n\n\t// Execute code\n\terr = executor.ExecuteCodeWithCallback(\"print('Hello from integration test!')\", execute.CallbackHandler{})\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to execute code: %v\", err)\n\t}\n\n\t// Test 5: Delete session\n\terr = client.DeleteSession(session.ID)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to delete session: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/kernel/kernel.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package kernel provides functionality for managing Jupyter kernels\npackage kernel\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// Client is the client for kernel management\ntype Client struct {\n\t// baseURL is the base URL of the Jupyter server\n\tbaseURL string\n\n\t// httpClient is the client for sending HTTP requests, with authentication support\n\thttpClient *http.Client\n}\n\n// NewClient creates a new kernel management client\nfunc NewClient(baseURL string, httpClient *http.Client) *Client {\n\treturn &Client{\n\t\tbaseURL:    baseURL,\n\t\thttpClient: httpClient,\n\t}\n}\n\n// GetKernelSpecs retrieves the list of available kernel specifications\nfunc (c *Client) GetKernelSpecs() (*KernelSpecs, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernelspecs\", c.baseURL)\n\n\t// Send GET request\n\tresp, err := c.httpClient.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar specs KernelSpecs\n\tif err := json.Unmarshal(body, &specs); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &specs, nil\n}\n\n// ListKernels retrieves the list of all running kernels\nfunc (c *Client) ListKernels() ([]*Kernel, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels\", c.baseURL)\n\n\t// Send GET request\n\tresp, err := c.httpClient.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar kernels []*Kernel\n\tif err := json.Unmarshal(body, &kernels); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn kernels, nil\n}\n\n// GetKernel retrieves information about a specific kernel\nfunc (c *Client) GetKernel(kernelId string) (*Kernel, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels/%s\", c.baseURL, kernelId)\n\n\t// Send GET request\n\tresp, err := c.httpClient.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar kernel Kernel\n\tif err := json.Unmarshal(body, &kernel); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &kernel, nil\n}\n\n// StartKernel starts a new kernel\nfunc (c *Client) StartKernel(name string) (*Kernel, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels\", c.baseURL)\n\n\t// Build request body\n\treqBody := &KernelStartRequest{\n\t\tName: name,\n\t}\n\n\t// Serialize request body to JSON\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// Create POST request\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar kernel Kernel\n\tif err := json.Unmarshal(body, &kernel); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &kernel, nil\n}\n\n// RestartKernel restarts the specified kernel\nfunc (c *Client) RestartKernel(kernelId string) (bool, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels/%s/restart\", c.baseURL, kernelId)\n\n\t// Create POST request\n\treq, err := http.NewRequest(http.MethodPost, url, nil)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn false, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar response KernelRestartResponse\n\tif err := json.Unmarshal(body, &response); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn response.Restarted, nil\n}\n\n// InterruptKernel interrupts the specified kernel\nfunc (c *Client) InterruptKernel(kernelId string) error {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels/%s/interrupt\", c.baseURL, kernelId)\n\n\t// Create POST request\n\treq, err := http.NewRequest(http.MethodPost, url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// ShutdownKernel shuts down the specified kernel\nfunc (c *Client) ShutdownKernel(kernelId string, restart bool) error {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/kernels/%s\", c.baseURL, kernelId)\n\n\t// Build request body\n\treqBody := &KernelShutdownRequest{\n\t\tRestart: restart,\n\t}\n\n\t// Serialize request body to JSON\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// Create DELETE request\n\treq, err := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/kernel/kernelspecs.json",
    "content": "{\n  \"default\" : \"python3\",\n  \"kernelspecs\" : {\n    \"python3\" : {\n      \"name\" : \"python3\",\n      \"spec\" : {\n        \"argv\" : [ \"/opt/conda/bin/python\", \"-m\", \"ipykernel_launcher\", \"-f\", \"{connection_file}\" ],\n        \"env\" : { },\n        \"display_name\" : \"Python 3 (ipykernel)\",\n        \"language\" : \"python\",\n        \"interrupt_mode\" : \"signal\",\n        \"metadata\" : {\n          \"debugger\" : true\n        }\n      },\n      \"resources\" : {\n        \"logo-svg\" : \"/kernelspecs/python3/logo-svg.svg\",\n        \"logo-64x64\" : \"/kernelspecs/python3/logo-64x64.png\",\n        \"logo-32x32\" : \"/kernelspecs/python3/logo-32x32.png\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/kernel/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package kernel provides functionality for managing Jupyter kernels\npackage kernel\n\nimport (\n\t\"time\"\n)\n\n// KernelSpecs contains available kernel specification information\ntype KernelSpecs struct {\n\t// Default is the name of the default kernel\n\tDefault string `json:\"default\"`\n\n\t// Kernelspecs is a mapping from kernel names to kernel specifications\n\tKernelspecs map[string]*KernelSpecInfo `json:\"kernelspecs\"`\n}\n\n// KernelSpecInfo contains detailed kernel specification information\ntype KernelSpecInfo struct {\n\t// Name is the name of the kernel\n\tName string `json:\"name\"`\n\n\tSpec KernelSpecDetail `json:\"spec\"`\n\n\t// Resources contains resource paths related to the kernel\n\tResources map[string]string `json:\"resources,omitempty\"`\n}\n\ntype KernelSpecDetail struct {\n\tArgv []string `json:\"argv,omitempty\"`\n\n\t// DisplayName is the display name of the kernel\n\tDisplayName string `json:\"display_name\"`\n\n\t// Language is the programming language used by the kernel\n\tLanguage string `json:\"language,omitempty\"`\n\n\t// InterruptMode is the interrupt mode of the kernel\n\tInterruptMode string `json:\"interrupt_mode,omitempty\"`\n}\n\n// Kernel represents a running kernel instance\ntype Kernel struct {\n\t// ID is the unique identifier of the kernel\n\tID string `json:\"id\"`\n\n\t// Name is the name of the kernel\n\tName string `json:\"name\"`\n\n\t// LastActivity is the timestamp of the kernel's last activity\n\tLastActivity time.Time `json:\"last_activity,omitempty\"`\n\n\t// Connections is the number of clients currently connected to the kernel\n\tConnections int `json:\"connections,omitempty\"`\n\n\t// ExecutionState is the execution state of the kernel (e.g., idle, busy)\n\tExecutionState string `json:\"execution_state,omitempty\"`\n}\n\n// KernelStartRequest is the request for starting a new kernel\ntype KernelStartRequest struct {\n\t// Name is the name of the kernel to start\n\tName string `json:\"name\"`\n\n\t// Path is the optional path for the kernel\n\tPath string `json:\"path,omitempty\"`\n}\n\n// KernelRestartResponse representsresponse of kernel restart\ntype KernelRestartResponse struct {\n\t// ID is the ID of the restarted kernel\n\tID string `json:\"id\"`\n\n\t// Name is the restarted kernel name\n\tName string `json:\"name\"`\n\n\t// Restarted represents whether the kernel was successfully restarted\n\tRestarted bool `json:\"restarted\"`\n\n\t// LastActivity is the timestamp of the kernel's last activity\n\tLastActivity time.Time `json:\"last_activity,omitempty\"`\n}\n\n// KernelInterruptRequest request to interrupt a kernel\ntype KernelInterruptRequest struct {\n\t// Restart represents whether to restart the kernel after interruption\n\tRestart bool `json:\"restart,omitempty\"`\n}\n\n// KernelShutdownRequest request to close a kernel\ntype KernelShutdownRequest struct {\n\t// Restart representswhether torestart kernel after shutdown\n\tRestart bool `json:\"restart\"`\n}\n\n// KernelStatus represents the status of the kernel\ntype KernelStatus string\n\nconst (\n\t// KernelStatusIdle representskernel is idle\n\tKernelStatusIdle KernelStatus = \"idle\"\n\n\t// KernelStatusBusy representskernel is busy\n\tKernelStatusBusy KernelStatus = \"busy\"\n\n\t// KernelStatusStarting representskernel is starting\n\tKernelStatusStarting KernelStatus = \"starting\"\n\n\t// KernelStatusRestarting represents the kernel is restarting\n\tKernelStatusRestarting KernelStatus = \"restarting\"\n\n\t// KernelStatusDead represents the kernel is dead\n\tKernelStatusDead KernelStatus = \"dead\"\n)\n"
  },
  {
    "path": "components/execd/pkg/jupyter/live_integration_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jupyter\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n)\n\n// authTransport is a custom transport layer for adding authentication headers\ntype authTransport struct {\n\ttoken string\n\tbase  http.RoundTripper\n}\n\n// RoundTrip implements the http.RoundTripper interface, adding authentication headers to each request\nfunc (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n\t// Clone the request to avoid modifying the original request\n\treqClone := req.Clone(req.Context())\n\t// Add authentication header\n\treqClone.Header.Set(\"Authorization\", \"Token \"+t.token)\n\t// Send the request using the base transport layer\n\treturn t.base.RoundTrip(reqClone)\n}\n\n// TestLiveServerIntegration tests SDK integration with a real Jupyter server\nfunc TestLiveServerIntegration(t *testing.T) {\n\tt.Skip()\n\t// Get configuration from environment variables, use default values if not set\n\tjupyterURL := getEnv(\"JUPYTER_URL\", \"\")\n\tjupyterToken := getEnv(\"JUPYTER_TOKEN\", \"\")\n\tif jupyterURL == \"\" || jupyterToken == \"\" {\n\t\tt.Skip(\"JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test\")\n\t}\n\n\t// Output test information\n\tt.Logf(\"Connecting to Jupyter server: %s\", jupyterURL)\n\n\t// Create HTTP client with authentication capability\n\thttpClient := &http.Client{\n\t\tTransport: &authTransport{\n\t\t\ttoken: jupyterToken,\n\t\t\tbase:  http.DefaultTransport,\n\t\t},\n\t}\n\n\t// Create client and set authentication\n\tclient := NewClient(jupyterURL,\n\t\tWithToken(jupyterToken), // Keep Token setting to support ValidateAuth and WebSocket connections\n\t\tWithHTTPClient(httpClient))\n\n\t// Test 1: Validate authentication\n\tt.Run(\"Validate Authentication\", func(t *testing.T) {\n\t\tstatus, err := client.ValidateAuth()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Authentication validation failed: %v\", err)\n\t\t}\n\t\tif status != \"ok\" {\n\t\t\tt.Errorf(\"Authentication status incorrect, expected 'ok', got '%s'\", status)\n\t\t}\n\t\tt.Logf(\"Authentication validation successful! Status: %s\", status)\n\t})\n\n\t// Test 2: Get kernel specs\n\tvar kernelName string\n\tt.Run(\"Get Kernel Specs\", func(t *testing.T) {\n\t\tspecs, err := client.GetKernelSpecs()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to get kernel specs: %v\", err)\n\t\t}\n\t\tif specs.Default == \"\" {\n\t\t\tt.Errorf(\"No default kernel\")\n\t\t}\n\t\tif len(specs.Kernelspecs) == 0 {\n\t\t\tt.Errorf(\"No available kernels\")\n\t\t}\n\n\t\t// Use default kernel or Python kernel (if available)\n\t\tkernelName = specs.Default\n\t\tfor name, spec := range specs.Kernelspecs {\n\t\t\tif spec.Spec.Language == \"python\" {\n\t\t\t\tkernelName = name\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tt.Logf(\"Get kernel specs successful! Default kernel: %s, Selected kernel: %s\", specs.Default, kernelName)\n\t\tt.Logf(\"Available kernels: %v\", specs.Kernelspecs)\n\t})\n\n\t// Test 3: List sessions\n\tt.Run(\"List Sessions\", func(t *testing.T) {\n\t\tsessions, err := client.ListSessions()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to list sessions: %v\", err)\n\t\t}\n\t\tt.Logf(\"List sessions successful! Number of existing sessions: %d\", len(sessions))\n\t\tfor i, s := range sessions {\n\t\t\tt.Logf(\"Session %d: ID=%s, Path=%s, Kernel=%s\", i+1, s.ID, s.Path, s.Kernel.Name)\n\t\t}\n\t})\n\n\t// Test 4: Create new session\n\tvar sessionID string\n\tt.Run(\"Create Session\", func(t *testing.T) {\n\t\t// Generate unique name for test session\n\t\tsessionName := fmt.Sprintf(\"test-session-%d\", time.Now().Unix())\n\t\tsessionPath := \"/test-notebook.ipynb\"\n\n\t\tsession, err := client.CreateSession(sessionName, sessionPath, kernelName)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to create session: %v\", err)\n\t\t}\n\t\tif session.ID == \"\" {\n\t\t\tt.Errorf(\"Created session has no ID\")\n\t\t}\n\t\tif session.Kernel.ID == \"\" {\n\t\t\tt.Errorf(\"Created session has no kernel ID\")\n\t\t}\n\n\t\t// Save session ID for subsequent tests\n\t\tsessionID = session.ID\n\n\t\tt.Logf(\"Create session successful! Session ID: %s, Kernel ID: %s\", session.ID, session.Kernel.ID)\n\t})\n\n\t// Test 5: Get created session\n\tvar kernelID string\n\tt.Run(\"Get Session\", func(t *testing.T) {\n\t\tif sessionID == \"\" {\n\t\t\tt.Skip(\"No session ID, skipping test\")\n\t\t}\n\n\t\tsession, err := client.GetSession(sessionID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to get session: %v\", err)\n\t\t}\n\t\tif session.ID != sessionID {\n\t\t\tt.Errorf(\"Session ID mismatch, expected '%s', got '%s'\", sessionID, session.ID)\n\t\t}\n\n\t\t// Save kernel ID for subsequent tests\n\t\tkernelID = session.Kernel.ID\n\n\t\tt.Logf(\"Get session successful! Session name: %s, Kernel name: %s\", session.Name, session.Kernel.Name)\n\t})\n\n\t// Test 6: List all kernels\n\tt.Run(\"List Kernels\", func(t *testing.T) {\n\t\tkernels, err := client.ListKernels()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to list kernels: %v\", err)\n\t\t}\n\t\tt.Logf(\"List kernels successful! Number of kernels: %d\", len(kernels))\n\t\tfor i, k := range kernels {\n\t\t\tt.Logf(\"Kernel %d: ID=%s, Name=%s, State=%s\", i+1, k.ID, k.Name, k.ExecutionState)\n\t\t}\n\n\t\t// Verify that the created kernel is in the list\n\t\tif kernelID != \"\" {\n\t\t\tfound := false\n\t\t\tfor _, k := range kernels {\n\t\t\t\tif k.ID == kernelID {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tt.Errorf(\"Cannot find created kernel in kernel list ID=%s\", kernelID)\n\t\t\t}\n\t\t}\n\t})\n\n\t// Test 7: Connect to kernel and execute code\n\tt.Run(\"Execute Code\", func(t *testing.T) {\n\t\tif kernelID == \"\" {\n\t\t\tt.Skip(\"No kernel ID, skipping test\")\n\t\t}\n\n\t\t// Connect to kernel\n\t\terr := client.ConnectToKernel(kernelID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to connect to kernel: %v\", err)\n\t\t}\n\t\tdefer client.DisconnectFromKernel(kernelID)\n\n\t\t// Execute simple code\n\t\tcode := \"print('Hello, Jupyter!')\\nresult = 2 + 2\\nresult\"\n\t\tt.Logf(\"Executing code:\\n%s\", code)\n\n\t\terr = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to execute code: %v\", err)\n\t\t}\n\t})\n\n\t// Test 7: Connect to kernel and execute code\n\tt.Run(\"Execute Code\", func(t *testing.T) {\n\t\tif kernelID == \"\" {\n\t\t\tt.Skip(\"No kernel ID, skipping test\")\n\t\t}\n\n\t\t// Connect to kernel\n\t\terr := client.ConnectToKernel(kernelID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to connect to kernel: %v\", err)\n\t\t}\n\t\tdefer client.DisconnectFromKernel(kernelID)\n\n\t\t// Execute simple code\n\t\tcode := \"print(f'2 + 2 = {result}')\\nresult\"\n\t\tt.Logf(\"Executing code:\\n%s\", code)\n\n\t\terr = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to execute code: %v\", err)\n\t\t}\n\t})\n\n\t// Test 8: Execute complex code with different types of output\n\tt.Run(\"Execute Complex Code\", func(t *testing.T) {\n\t\tif kernelID == \"\" {\n\t\t\tt.Skip(\"No kernel ID, skipping test\")\n\t\t}\n\n\t\t// Connect to kernel\n\t\terr := client.ConnectToKernel(kernelID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to connect to kernel: %v\", err)\n\t\t}\n\t\tdefer client.DisconnectFromKernel(kernelID)\n\n\t\t// Execute code that generates multiple output types\n\t\tcode := `\n# Display table data\nimport pandas as pd\nimport numpy as np\ntry:\n    df = pd.DataFrame({\n        'A': np.random.rand(5),\n        'B': np.random.rand(5)\n    })\n    display(df)\n    print(\"DataFrame created successfully\")\nexcept Exception as e:\n    print(f\"Error creating DataFrame: {e}\")\n\n# Generate error\ntry:\n    print(undefined_variable)\nexcept Exception as e:\n    print(f\"Expected error: {e}\")\n\n# Return dictionary\n{'hello': 'world', 'number': 42}\n`\n\n\t\tt.Logf(\"Executing complex code...\")\n\n\t\terr = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to execute complex code: %v\", err)\n\t\t}\n\t})\n\n\t// Test 9: Restart kernel\n\tt.Run(\"Restart Kernel\", func(t *testing.T) {\n\t\tif kernelID == \"\" {\n\t\t\tt.Skip(\"No kernel ID, skipping test\")\n\t\t}\n\n\t\t// Restart kernel\n\t\trestarted, err := client.RestartKernel(kernelID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to restart kernel: %v\", err)\n\t\t}\n\n\t\t// Wait for kernel restart to complete\n\t\ttime.Sleep(2 * time.Second)\n\n\t\t// Verify kernel state\n\t\tkernel, err := client.GetKernel(kernelID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to get kernel: %v\", err)\n\t\t}\n\n\t\tt.Logf(\"Restart kernel successful! Restart status: %v, Kernel state: %s\", restarted, kernel.ExecutionState)\n\t})\n\n\t// Test 10: Close session\n\tt.Run(\"Close Session\", func(t *testing.T) {\n\t\tif sessionID == \"\" {\n\t\t\tt.Skip(\"No session ID, skipping test\")\n\t\t}\n\n\t\t// Delete session\n\t\terr := client.DeleteSession(sessionID)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to delete session: %v\", err)\n\t\t}\n\n\t\t// Verify session is deleted\n\t\tsessions, err := client.ListSessions()\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to list sessions: %v\", err)\n\t\t}\n\n\t\tfor _, s := range sessions {\n\t\t\tif s.ID == sessionID {\n\t\t\t\tt.Errorf(\"Session still exists, not properly deleted ID=%s\", sessionID)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tt.Logf(\"Close session successful!\")\n\t})\n}\n\n// Helper function: Get environment variable, use default value if not exists\nfunc getEnv(key, defaultValue string) string {\n\tvalue := os.Getenv(key)\n\tif value == \"\" {\n\t\treturn defaultValue\n\t}\n\treturn value\n}\n\n// Helper function: Truncate string\nfunc truncateString(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\treturn s[:maxLen] + \"...\"\n}\n\n// Helper function: Get all keys from map\nfunc getKeys(m map[string]interface{}) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\treturn keys\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/session/session.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package session provides functionality for managing Jupyter sessions\npackage session\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// Client is the client for session management\ntype Client struct {\n\t// baseURL is the base URL of the Jupyter server\n\tbaseURL string\n\n\t// httpClient is the client for sending HTTP requests, with authentication support\n\thttpClient *http.Client\n}\n\n// NewClient creates a new session management client\nfunc NewClient(baseURL string, httpClient *http.Client) *Client {\n\treturn &Client{\n\t\tbaseURL:    baseURL,\n\t\thttpClient: httpClient,\n\t}\n}\n\n// ListSessions retrieves the list of all active sessions\nfunc (c *Client) ListSessions() ([]*Session, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions\", c.baseURL)\n\n\t// Send GET request\n\tresp, err := c.httpClient.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar sessions []*Session\n\tif err := json.Unmarshal(body, &sessions); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn sessions, nil\n}\n\n// GetSession retrieves information about a specific session\nfunc (c *Client) GetSession(sessionId string) (*Session, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions/%s\", c.baseURL, sessionId)\n\n\t// Send GET request\n\tresp, err := c.httpClient.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar session Session\n\tif err := json.Unmarshal(body, &session); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &session, nil\n}\n\n// CreateSession creates a new session\nfunc (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions\", c.baseURL)\n\n\t// Build request body\n\treqBody := &SessionCreateRequest{\n\t\tPath: ipynb,\n\t\tName: name,\n\t\tType: DefaultSessionType,\n\t\tKernel: &KernelSpec{\n\t\t\tName: kernel,\n\t\t},\n\t}\n\n\t// Serialize request body to JSON\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// Create POST request\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar session Session\n\tif err := json.Unmarshal(body, &session); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &session, nil\n}\n\n// ModifySession modifies properties of an existing session\nfunc (c *Client) ModifySession(sessionId, name, path, kernel string) (*Session, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions/%s\", c.baseURL, sessionId)\n\n\t// Build request body\n\treqBody := &SessionUpdateRequest{}\n\tif name != \"\" {\n\t\treqBody.Name = name\n\t}\n\tif path != \"\" {\n\t\treqBody.Path = path\n\t}\n\tif kernel != \"\" {\n\t\treqBody.Kernel = &KernelSpec{\n\t\t\tName: kernel,\n\t\t}\n\t}\n\n\t// Serialize request body to JSON\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// Create PATCH request\n\treq, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar session Session\n\tif err := json.Unmarshal(body, &session); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &session, nil\n}\n\n// DeleteSession deletes the specified session\nfunc (c *Client) DeleteSession(sessionId string) error {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions/%s\", c.baseURL, sessionId)\n\n\t// Create DELETE request\n\treq, err := http.NewRequest(http.MethodDelete, url, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\treturn nil\n}\n\n// CreateSessionWithOptions usingoption to create a new session\nfunc (c *Client) CreateSessionWithOptions(options *SessionOptions) (*Session, error) {\n\t// Build request URL\n\turl := fmt.Sprintf(\"%s/api/sessions\", c.baseURL)\n\n\t// Build request body\n\treqBody := &SessionCreateRequest{\n\t\tPath: options.Path,\n\t\tName: options.Name,\n\t}\n\n\t// set session type\n\tif options.Type != \"\" {\n\t\treqBody.Type = options.Type\n\t} else {\n\t\treqBody.Type = DefaultSessionType\n\t}\n\n\t// set kernel information\n\tif options.KernelID != \"\" {\n\t\t// If kernel ID is provided, use existing kernel\n\t\treqBody.Kernel = &KernelSpec{\n\t\t\tID: options.KernelID,\n\t\t}\n\t} else if options.KernelName != \"\" {\n\t\t// If kernel name is provided, start new kernel\n\t\treqBody.Kernel = &KernelSpec{\n\t\t\tName: options.KernelName,\n\t\t}\n\t}\n\n\t// Serialize request body to JSON\n\tjsonData, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to serialize request: %w\", err)\n\t}\n\n\t// Create POST request\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to send request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Check response status\n\tif resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"server returned error status code: %d\", resp.StatusCode)\n\t}\n\n\t// Read response\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read response: %w\", err)\n\t}\n\n\t// Parse JSON response\n\tvar session Session\n\tif err := json.Unmarshal(body, &session); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse response: %w\", err)\n\t}\n\n\treturn &session, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/session/session_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage session\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\n// Test listing sessions\nfunc TestListSessions(t *testing.T) {\n\t// Create mock server\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Verify request method and path\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected request method GET, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/api/sessions\" {\n\t\t\tt.Errorf(\"expected request path /api/sessions, got %s\", r.URL.Path)\n\t\t}\n\n\t\t// Return mocked session list\n\t\tresponse := `[\n\t\t\t{\n\t\t\t\t\"id\": \"session-1\",\n\t\t\t\t\"path\": \"/path/to/notebook1.ipynb\",\n\t\t\t\t\"name\": \"Session 1\",\n\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\"kernel\": {\n\t\t\t\t\t\"id\": \"kernel-1\",\n\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\"last_activity\": \"2023-01-01T00:00:00Z\",\n\t\t\t\t\t\"execution_state\": \"idle\",\n\t\t\t\t\t\"connections\": 1\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"id\": \"session-2\",\n\t\t\t\t\"path\": \"/path/to/notebook2.ipynb\",\n\t\t\t\t\"name\": \"Session 2\",\n\t\t\t\t\"type\": \"notebook\",\n\t\t\t\t\"kernel\": {\n\t\t\t\t\t\"id\": \"kernel-2\",\n\t\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\t\"last_activity\": \"2023-01-01T00:00:00Z\",\n\t\t\t\t\t\"execution_state\": \"idle\",\n\t\t\t\t\t\"connections\": 1\n\t\t\t\t}\n\t\t\t}\n\t\t]`\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(response))\n\t}))\n\tdefer server.Close()\n\n\t// Create client\n\tclient := NewClient(server.URL, &http.Client{})\n\n\t// Fetch session list\n\tsessions, err := client.ListSessions()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to list sessions: %v\", err)\n\t}\n\n\t// Validate session count\n\tif len(sessions) != 2 {\n\t\tt.Errorf(\"expected 2 sessions, got %d\", len(sessions))\n\t}\n\n\t// Validate first session fields\n\tif sessions[0].ID != \"session-1\" {\n\t\tt.Errorf(\"expected session ID 'session-1', got '%s'\", sessions[0].ID)\n\t}\n\tif sessions[0].Name != \"Session 1\" {\n\t\tt.Errorf(\"expected session name 'Session 1', got '%s'\", sessions[0].Name)\n\t}\n\tif sessions[0].Path != \"/path/to/notebook1.ipynb\" {\n\t\tt.Errorf(\"expected session path '/path/to/notebook1.ipynb', got '%s'\", sessions[0].Path)\n\t}\n\tif sessions[0].Type != \"notebook\" {\n\t\tt.Errorf(\"expected session type 'notebook', got '%s'\", sessions[0].Type)\n\t}\n\n\t// Validate first session kernel fields\n\tif sessions[0].Kernel.ID != \"kernel-1\" {\n\t\tt.Errorf(\"expected kernel ID 'kernel-1', got '%s'\", sessions[0].Kernel.ID)\n\t}\n\tif sessions[0].Kernel.Name != \"python3\" {\n\t\tt.Errorf(\"expected kernel name 'python3', got '%s'\", sessions[0].Kernel.Name)\n\t}\n}\n\n// Test creating session\nfunc TestCreateSession(t *testing.T) {\n\t// Create mock server\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Verify request method and path\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"expected request method POST, got %s\", r.Method)\n\t\t}\n\t\tif r.URL.Path != \"/api/sessions\" {\n\t\t\tt.Errorf(\"expected request path /api/sessions, got %s\", r.URL.Path)\n\t\t}\n\n\t\t// Parse request body\n\t\tvar requestBody SessionCreateRequest\n\t\tdecoder := json.NewDecoder(r.Body)\n\t\tif err := decoder.Decode(&requestBody); err != nil {\n\t\t\tt.Fatalf(\"failed to decode request body: %v\", err)\n\t\t}\n\n\t\t// Validate request params\n\t\tif requestBody.Name != \"Test Session\" {\n\t\t\tt.Errorf(\"expected session name 'Test Session', got '%s'\", requestBody.Name)\n\t\t}\n\t\tif requestBody.Path != \"/path/to/notebook.ipynb\" {\n\t\t\tt.Errorf(\"expected session path '/path/to/notebook.ipynb', got '%s'\", requestBody.Path)\n\t\t}\n\t\tif requestBody.Type != \"notebook\" {\n\t\t\tt.Errorf(\"expected session type 'notebook', got '%s'\", requestBody.Type)\n\t\t}\n\t\tif requestBody.Kernel.Name != \"python3\" {\n\t\t\tt.Errorf(\"expected kernel name 'python3', got '%s'\", requestBody.Kernel.Name)\n\t\t}\n\n\t\t// Return mocked create response\n\t\tresponse := `{\n\t\t\t\"id\": \"new-session-id\",\n\t\t\t\"path\": \"/path/to/notebook.ipynb\",\n\t\t\t\"name\": \"Test Session\",\n\t\t\t\"type\": \"notebook\",\n\t\t\t\"kernel\": {\n\t\t\t\t\"id\": \"new-kernel-id\",\n\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\"last_activity\": \"2023-01-01T00:00:00Z\",\n\t\t\t\t\"execution_state\": \"idle\",\n\t\t\t\t\"connections\": 0\n\t\t\t}\n\t\t}`\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusCreated)\n\t\tw.Write([]byte(response))\n\t}))\n\tdefer server.Close()\n\n\t// Create client\n\tclient := NewClient(server.URL, &http.Client{})\n\n\t// Create session\n\tnewSession, err := client.CreateSession(\"Test Session\", \"/path/to/notebook.ipynb\", \"python3\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create session: %v\", err)\n\t}\n\n\t// Validate created session\n\tif newSession.ID != \"new-session-id\" {\n\t\tt.Errorf(\"expected session ID 'new-session-id', got '%s'\", newSession.ID)\n\t}\n\tif newSession.Name != \"Test Session\" {\n\t\tt.Errorf(\"expected session name 'Test Session', got '%s'\", newSession.Name)\n\t}\n\tif newSession.Path != \"/path/to/notebook.ipynb\" {\n\t\tt.Errorf(\"expected session path '/path/to/notebook.ipynb', got '%s'\", newSession.Path)\n\t}\n\tif newSession.Kernel.ID != \"new-kernel-id\" {\n\t\tt.Errorf(\"expected kernel ID 'new-kernel-id', got '%s'\", newSession.Kernel.ID)\n\t}\n}\n\n// Test fetching a specific session\nfunc TestGetSession(t *testing.T) {\n\tsessionID := \"test-session-id\"\n\n\t// Create mock server\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Verify request method and path\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected request method GET, got %s\", r.Method)\n\t\t}\n\n\t\texpectedPath := \"/api/sessions/\" + sessionID\n\t\tif r.URL.Path != expectedPath {\n\t\t\tt.Errorf(\"expected request path '%s', got '%s'\", expectedPath, r.URL.Path)\n\t\t}\n\n\t\t// Return mocked session\n\t\tresponse := `{\n\t\t\t\"id\": \"test-session-id\",\n\t\t\t\"path\": \"/path/to/notebook.ipynb\",\n\t\t\t\"name\": \"Test Session\",\n\t\t\t\"type\": \"notebook\",\n\t\t\t\"kernel\": {\n\t\t\t\t\"id\": \"test-kernel-id\",\n\t\t\t\t\"name\": \"python3\",\n\t\t\t\t\"last_activity\": \"2023-01-01T00:00:00Z\",\n\t\t\t\t\"execution_state\": \"idle\",\n\t\t\t\t\"connections\": 1\n\t\t\t}\n\t\t}`\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(response))\n\t}))\n\tdefer server.Close()\n\n\t// Create client\n\tclient := NewClient(server.URL, &http.Client{})\n\n\t// Fetch session\n\tsession, err := client.GetSession(sessionID)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get session: %v\", err)\n\t}\n\n\t// Validate session\n\tif session.ID != sessionID {\n\t\tt.Errorf(\"expected session ID '%s', got '%s'\", sessionID, session.ID)\n\t}\n\tif session.Name != \"Test Session\" {\n\t\tt.Errorf(\"expected session name 'Test Session', got '%s'\", session.Name)\n\t}\n\tif session.Kernel.ID != \"test-kernel-id\" {\n\t\tt.Errorf(\"expected kernel ID 'test-kernel-id', got '%s'\", session.Kernel.ID)\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/jupyter/session/sessions.json",
    "content": "[ {\n  \"id\" : \"cb1baca9-a60e-4937-a1d0-18bc1fc45e60\",\n  \"path\" : \"my_notebook.ipynb\",\n  \"name\" : \"my_session\",\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"d7052326-5c98-4575-bb18-a7902ef5f623\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-05T09:09:54.420827Z\",\n    \"execution_state\" : \"idle\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"my_notebook.ipynb\",\n    \"name\" : \"my_session\"\n  }\n}, {\n  \"id\" : \"a3378ca1-ba62-4341-9db5-3bc612fb3517\",\n  \"path\" : \"Untitled.ipynb\",\n  \"name\" : \"Untitled.ipynb\",\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"d7052326-5c98-4575-bb18-a7902ef5f623\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-05T09:09:54.420827Z\",\n    \"execution_state\" : \"idle\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"Untitled.ipynb\",\n    \"name\" : \"Untitled.ipynb\"\n  }\n}, {\n  \"id\" : \"c4829f29-8430-4dce-b1f5-9d2ac6c4f570\",\n  \"path\" : \"/tmp/example_notebook.ipynb\",\n  \"name\" : \"example_session\",\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"00349e07-3877-4eb0-a676-0df5b886d770\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-05T11:51:22.194821Z\",\n    \"execution_state\" : \"starting\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"/tmp/example_notebook.ipynb\",\n  \"name\" : \"example_session\"\n  }\n}, {\n  \"id\" : \"9a8e1857-b737-41a6-8f81-6039f6ae0ac1\",\n  \"path\" : \"e0ebd37c-578a-443c-8f58-236984aea7ff\",\n  \"name\" : \"session_5c4e8183-9e8a-4879-93b2-5622518193d7\",\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"e8792c3e-3190-4b11-92e8-b7ec9ef44da9\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-05T12:26:01.610210Z\",\n    \"execution_state\" : \"starting\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"e0ebd37c-578a-443c-8f58-236984aea7ff\",\n    \"name\" : \"session_5c4e8183-9e8a-4879-93b2-5622518193d7\"\n  }\n}, {\n  \"id\" : \"cc06c06d-4f6b-45a5-a546-11a5b5f246f8\",\n  \"path\" : \"notebook.ipynb\",\n  \"name\" : null,\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"62e7fd9e-ea50-4045-861b-3a5a7073ee22\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-05T12:26:51.714871Z\",\n    \"execution_state\" : \"starting\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"notebook.ipynb\",\n    \"name\" : null\n  }\n}, {\n  \"id\" : \"db123df4-ec13-4fe0-b3c3-ef85464b8a42\",\n  \"path\" : \"/tmp/test.ipynb\",\n  \"name\" : \"\",\n  \"type\" : \"notebook\",\n  \"kernel\" : {\n    \"id\" : \"7d3091af-8b0a-474a-be04-f64191a43d0f\",\n    \"name\" : \"python3\",\n    \"last_activity\" : \"2025-06-06T01:29:16.712732Z\",\n    \"execution_state\" : \"starting\",\n    \"connections\" : 0\n  },\n  \"notebook\" : {\n    \"path\" : \"/tmp/test.ipynb\",\n    \"name\" : \"\"\n  }\n} ]\n"
  },
  {
    "path": "components/execd/pkg/jupyter/session/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package session provides functionality for managing Jupyter sessions\npackage session\n\nimport (\n\t\"time\"\n)\n\n// Session represents a Jupyter session\ntype Session struct {\n\t// ID is the unique identifier of the session\n\tID string `json:\"id\"`\n\n\t// Path is the path associated with the session (typically the notebook file path)\n\tPath string `json:\"path\"`\n\n\t// Name is the name of the session\n\tName string `json:\"name\"`\n\n\t// Type is the type of the session (e.g., notebook, console)\n\tType string `json:\"type\"`\n\n\t// Kernel contains information about the kernel associated with the session\n\tKernel *KernelInfo `json:\"kernel\"`\n\n\t// CreatedAt is the timestamp when the session was created\n\tCreatedAt time.Time `json:\"created,omitempty\"`\n\n\t// LastModified is the timestamp when the session was last modified\n\tLastModified time.Time `json:\"last_modified,omitempty\"`\n}\n\n// KernelInfo contains basic kernel information\ntype KernelInfo struct {\n\t// ID is the unique identifier of the kernel\n\tID string `json:\"id\"`\n\n\t// Name is the name of the kernel (e.g., python3, ir)\n\tName string `json:\"name\"`\n\n\t// LastActivity is the timestamp of the kernel's last activity\n\tLastActivity time.Time `json:\"last_activity,omitempty\"`\n\n\t// Connections is the number of clients currently connected to the kernel\n\tConnections int `json:\"connections,omitempty\"`\n\n\t// ExecutionState is the execution state of the kernel (e.g., idle, busy)\n\tExecutionState string `json:\"execution_state,omitempty\"`\n}\n\n// SessionCreateRequest is the request for creating a new session\ntype SessionCreateRequest struct {\n\t// Path is the path associated with the session (typically the notebook file path)\n\tPath string `json:\"path\"`\n\n\t// Name is the name of the session\n\tName string `json:\"name,omitempty\"`\n\n\t// Type is the type of the session (defaults to \"notebook\")\n\tType string `json:\"type,omitempty\"`\n\n\t// Kernel contains information about the kernel to start\n\tKernel *KernelSpec `json:\"kernel,omitempty\"`\n}\n\n// KernelSpec contains kernel specification information\ntype KernelSpec struct {\n\t// Name is the name of the kernel (e.g., python3, ir)\n\tName string `json:\"name\"`\n\n\t// ID is the unique identifier of the kernel (optional, used only when reusing existing kernel)\n\tID string `json:\"id,omitempty\"`\n}\n\n// SessionUpdateRequest request to update an existing session\ntype SessionUpdateRequest struct {\n\t// Path is the new session path\n\tPath string `json:\"path,omitempty\"`\n\n\t// Name is the new session name\n\tName string `json:\"name,omitempty\"`\n\n\t// Type is the new session type\n\tType string `json:\"type,omitempty\"`\n\n\t// Kernel contains the new kernel information\n\tKernel *KernelSpec `json:\"kernel,omitempty\"`\n}\n\n// SessionListResponse represents the response for listing sessions\ntype SessionListResponse []*Session\n\n// SessionOptions contains options for creating or updating sessions\ntype SessionOptions struct {\n\t// Name is the name of the session\n\tName string\n\n\t// Path is the path associated with the session\n\tPath string\n\n\t// Type is the type of the session (defaults to \"notebook\")\n\tType string\n\n\t// KernelName is the kernel name to use (e.g., python3, ir, etc.)\n\tKernelName string\n\n\t// KernelID is the ID of the existing kernel to reuse (if provided, KernelName will be ignored)\n\tKernelID string\n}\n\n// DefaultSessionType is the default session type\nconst DefaultSessionType = \"notebook\"\n"
  },
  {
    "path": "components/execd/pkg/jupyter/transport.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage jupyter\n\nimport \"net/http\"\n\ntype AuthTransport struct {\n\tToken string\n\tBase  http.RoundTripper\n}\n\nfunc (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n\treqClone := req.Clone(req.Context())\n\treqClone.Header.Set(\"Authorization\", \"Token \"+t.Token)\n\treturn t.Base.RoundTrip(reqClone)\n}\n"
  },
  {
    "path": "components/execd/pkg/log/log.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage log\n\nimport (\n\t\"os\"\n\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n)\n\nconst logFileEnvKey = \"EXECD_LOG_FILE\"\n\nvar current slogger.Logger\n\n// Init constructs the singleton logger. Call once during startup.\n// Legacy levels: 0/1/2=fatal, 3=error, 4=warn, 5/6=info, 7+=debug.\nfunc Init(level int) {\n\tcurrent = newLogger(mapLevel(level))\n}\n\nfunc mapLevel(level int) string {\n\tswitch {\n\tcase level <= 2:\n\t\treturn \"fatal\"\n\tcase level == 3:\n\t\treturn \"error\"\n\tcase level == 4:\n\t\treturn \"warn\"\n\tcase level == 5 || level == 6:\n\t\treturn \"info\"\n\tdefault:\n\t\treturn \"debug\"\n\t}\n}\n\nfunc newLogger(level string) slogger.Logger {\n\tcfg := slogger.Config{\n\t\tLevel: level,\n\t}\n\tif logFile := os.Getenv(logFileEnvKey); logFile != \"\" {\n\t\tcfg.OutputPaths = []string{logFile}\n\t\tcfg.ErrorOutputPaths = cfg.OutputPaths\n\t}\n\treturn slogger.MustNew(cfg)\n}\n\nfunc getLogger() slogger.Logger {\n\tif current != nil {\n\t\treturn current\n\t}\n\tl := newLogger(\"info\")\n\tcurrent = l\n\treturn l\n}\n\nfunc Debug(format string, args ...any) {\n\tgetLogger().Debugf(format, args...)\n}\n\nfunc Info(format string, args ...any) {\n\tgetLogger().Infof(format, args...)\n}\n\nfunc Warn(format string, args ...any) {\n\tgetLogger().Warnf(format, args...)\n}\n\n// Warning is an alias to Warn for compatibility.\nfunc Warning(format string, args ...any) {\n\tWarn(format, args...)\n}\n\nfunc Error(format string, args ...any) {\n\tgetLogger().Errorf(format, args...)\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/bash_session.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage runtime\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\nconst (\n\tenvDumpStartMarker = \"__ENV_DUMP_START__\"\n\tenvDumpEndMarker   = \"__ENV_DUMP_END__\"\n\texitMarkerPrefix   = \"__EXIT_CODE__:\"\n\tpwdMarkerPrefix    = \"__PWD__:\"\n)\n\nfunc (c *Controller) createBashSession(req *CreateContextRequest) (string, error) {\n\tsession := newBashSession(req.Cwd)\n\tif err := session.start(); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to start bash session: %w\", err)\n\t}\n\n\tc.bashSessionClientMap.Store(session.config.Session, session)\n\tlog.Info(\"created bash session %s\", session.config.Session)\n\treturn session.config.Session, nil\n}\n\nfunc (c *Controller) runBashSession(ctx context.Context, request *ExecuteCodeRequest) error {\n\tsession := c.getBashSession(request.Context)\n\tif session == nil {\n\t\treturn ErrContextNotFound\n\t}\n\n\treturn session.run(ctx, request)\n}\n\nfunc (c *Controller) getBashSession(sessionId string) *bashSession {\n\tif v, ok := c.bashSessionClientMap.Load(sessionId); ok {\n\t\tif s, ok := v.(*bashSession); ok {\n\t\t\treturn s\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *Controller) closeBashSession(sessionId string) error {\n\tsession := c.getBashSession(sessionId)\n\tif session == nil {\n\t\treturn ErrContextNotFound\n\t}\n\n\terr := session.close()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.bashSessionClientMap.Delete(sessionId)\n\treturn nil\n}\n\nfunc (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) {\n\treturn c.createBashSession(req)\n}\n\nfunc (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error {\n\treturn c.runBashSession(ctx, req)\n}\n\nfunc (c *Controller) DeleteBashSession(sessionID string) error {\n\treturn c.closeBashSession(sessionID)\n}\n\n// Session implementation (pipe-based, no PTY)\nfunc newBashSession(cwd string) *bashSession {\n\tconfig := &bashSessionConfig{\n\t\tSession:        uuidString(),\n\t\tStartupTimeout: 5 * time.Second,\n\t}\n\n\tenv := make(map[string]string)\n\tfor _, kv := range os.Environ() {\n\t\tif k, v, ok := splitEnvPair(kv); ok {\n\t\t\tenv[k] = v\n\t\t}\n\t}\n\n\treturn &bashSession{\n\t\tconfig: config,\n\t\tenv:    env,\n\t\tcwd:    cwd,\n\t}\n}\n\nfunc (s *bashSession) start() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.started {\n\t\treturn errors.New(\"session already started\")\n\t}\n\n\ts.started = true\n\treturn nil\n}\n\nfunc (s *bashSession) trackCurrentProcess(pid int) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.currentProcessPid = pid\n}\n\nfunc (s *bashSession) untrackCurrentProcess() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.currentProcessPid = 0\n}\n\n//nolint:gocognit\nfunc (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error {\n\ts.mu.Lock()\n\tif !s.started {\n\t\ts.mu.Unlock()\n\t\treturn errors.New(\"session not started\")\n\t}\n\n\tenvSnapshot := copyEnvMap(s.env)\n\n\tcwd := s.cwd\n\t// override original cwd if specified\n\tif request.Cwd != \"\" {\n\t\tcwd = request.Cwd\n\t}\n\tsessionID := s.config.Session\n\ts.mu.Unlock()\n\n\tstartAt := time.Now()\n\tif request.Hooks.OnExecuteInit != nil {\n\t\trequest.Hooks.OnExecuteInit(sessionID)\n\t}\n\n\twait := request.Timeout\n\tif wait <= 0 {\n\t\twait = 24 * 3600 * time.Second // max to 24 hours\n\t}\n\n\tctx, cancel := context.WithTimeout(ctx, wait)\n\tdefer cancel()\n\n\tscript := buildWrappedScript(request.Code, envSnapshot, cwd)\n\tscriptFile, err := os.CreateTemp(\"\", \"execd_bash_*.sh\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create script file: %w\", err)\n\t}\n\tscriptPath := scriptFile.Name()\n\tif _, err := scriptFile.WriteString(script); err != nil {\n\t\t_ = scriptFile.Close()\n\t\treturn fmt.Errorf(\"write script file: %w\", err)\n\t}\n\tif err := scriptFile.Close(); err != nil {\n\t\treturn fmt.Errorf(\"close script file: %w\", err)\n\t}\n\n\tcmd := exec.CommandContext(ctx, \"bash\", \"--noprofile\", \"--norc\", scriptPath)\n\tcmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}\n\t// Do not pass envSnapshot via cmd.Env to avoid \"argument list too long\" when session env is large.\n\t// Child inherits parent env (nil => default in Go). The script file already has \"export K=V\" for\n\t// all session vars at the top, so the session environment is applied when the script runs.\n\tstdout, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"stdout pipe: %w\", err)\n\t}\n\tcmd.Stderr = cmd.Stdout\n\n\tif err := cmd.Start(); err != nil {\n\t\tlog.Error(\"start bash session failed: %v (command: %q)\", err, request.Code)\n\t\treturn fmt.Errorf(\"start bash: %w\", err)\n\t}\n\tdefer s.untrackCurrentProcess()\n\ts.trackCurrentProcess(cmd.Process.Pid)\n\n\tscanner := bufio.NewScanner(stdout)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)\n\n\tvar (\n\t\tenvLines []string\n\t\tpwdLine  string\n\t\texitCode *int\n\t\tinEnv    bool\n\t)\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tswitch {\n\t\tcase line == envDumpStartMarker:\n\t\t\tinEnv = true\n\t\tcase line == envDumpEndMarker:\n\t\t\tinEnv = false\n\t\tcase strings.HasPrefix(line, exitMarkerPrefix):\n\t\t\tif code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil {\n\t\t\t\texitCode = &code //nolint:ineffassign\n\t\t\t}\n\t\tcase strings.HasPrefix(line, pwdMarkerPrefix):\n\t\t\tpwdLine = strings.TrimPrefix(line, pwdMarkerPrefix)\n\t\tdefault:\n\t\t\tif inEnv {\n\t\t\t\tenvLines = append(envLines, line)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif request.Hooks.OnExecuteStdout != nil {\n\t\t\t\trequest.Hooks.OnExecuteStdout(line)\n\t\t\t}\n\t\t}\n\t}\n\n\tscanErr := scanner.Err()\n\twaitErr := cmd.Wait()\n\n\tif scanErr != nil {\n\t\tlog.Error(\"read stdout failed: %v (command: %q)\", scanErr, request.Code)\n\t\treturn fmt.Errorf(\"read stdout: %w\", scanErr)\n\t}\n\n\tif errors.Is(ctx.Err(), context.DeadlineExceeded) {\n\t\tlog.Error(\"timeout after %s while running command: %q\", wait, request.Code)\n\t\treturn fmt.Errorf(\"timeout after %s while running command %q\", wait, request.Code)\n\t}\n\n\tif exitCode == nil && cmd.ProcessState != nil {\n\t\tcode := cmd.ProcessState.ExitCode() //nolint:staticcheck\n\t\texitCode = &code                    //nolint:ineffassign\n\t}\n\n\tupdatedEnv := parseExportDump(envLines)\n\ts.mu.Lock()\n\tif len(updatedEnv) > 0 {\n\t\ts.env = updatedEnv\n\t}\n\tif pwdLine != \"\" {\n\t\ts.cwd = pwdLine\n\t}\n\ts.mu.Unlock()\n\n\tvar exitErr *exec.ExitError\n\tif waitErr != nil && !errors.As(waitErr, &exitErr) {\n\t\tlog.Error(\"command wait failed: %v (command: %q)\", waitErr, request.Code)\n\t\treturn waitErr\n\t}\n\n\tuserExitCode := 0\n\tif exitCode != nil {\n\t\tuserExitCode = *exitCode\n\t}\n\n\tif userExitCode != 0 {\n\t\terrMsg := fmt.Sprintf(\"command exited with code %d\", userExitCode)\n\t\tif waitErr != nil {\n\t\t\terrMsg = waitErr.Error()\n\t\t}\n\t\tif request.Hooks.OnExecuteError != nil {\n\t\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{\n\t\t\t\tEName:     \"CommandExecError\",\n\t\t\t\tEValue:    strconv.Itoa(userExitCode),\n\t\t\t\tTraceback: []string{errMsg},\n\t\t\t})\n\t\t}\n\t\tlog.Error(\"CommandExecError: %s (command: %q)\", errMsg, request.Code)\n\t\treturn nil\n\t}\n\n\tif request.Hooks.OnExecuteComplete != nil {\n\t\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\t}\n\n\treturn nil\n}\n\nfunc buildWrappedScript(command string, env map[string]string, cwd string) string {\n\tvar b strings.Builder\n\n\tkeys := make([]string, 0, len(env))\n\tfor k := range env {\n\t\tv := env[k]\n\t\tif isValidEnvKey(k) && !envKeysNotPersisted[k] && len(v) <= maxPersistedEnvValueSize {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t}\n\tsort.Strings(keys)\n\tfor _, k := range keys {\n\t\tb.WriteString(\"export \")\n\t\tb.WriteString(k)\n\t\tb.WriteString(\"=\")\n\t\tb.WriteString(shellEscape(env[k]))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tif cwd != \"\" {\n\t\tb.WriteString(\"cd \")\n\t\tb.WriteString(shellEscape(cwd))\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tb.WriteString(command)\n\tif !strings.HasSuffix(command, \"\\n\") {\n\t\tb.WriteString(\"\\n\")\n\t}\n\n\tb.WriteString(\"__USER_EXIT_CODE__=$?\\n\")\n\tb.WriteString(\"printf \\\"\\\\n%s\\\\n\\\" \\\"\" + envDumpStartMarker + \"\\\"\\n\")\n\tb.WriteString(\"export -p\\n\")\n\tb.WriteString(\"printf \\\"%s\\\\n\\\" \\\"\" + envDumpEndMarker + \"\\\"\\n\")\n\tb.WriteString(\"printf \\\"\" + pwdMarkerPrefix + \"%s\\\\n\\\" \\\"$(pwd)\\\"\\n\")\n\tb.WriteString(\"printf \\\"\" + exitMarkerPrefix + \"%s\\\\n\\\" \\\"$__USER_EXIT_CODE__\\\"\\n\")\n\tb.WriteString(\"exit \\\"$__USER_EXIT_CODE__\\\"\\n\")\n\n\treturn b.String()\n}\n\n// envKeysNotPersisted are not carried across runs (prompt/display vars).\nvar envKeysNotPersisted = map[string]bool{\n\t\"PS1\": true, \"PS2\": true, \"PS3\": true, \"PS4\": true,\n\t\"PROMPT_COMMAND\": true,\n}\n\n// maxPersistedEnvValueSize caps single env value length as a safeguard.\nconst maxPersistedEnvValueSize = 8 * 1024\n\nfunc parseExportDump(lines []string) map[string]string {\n\tif len(lines) == 0 {\n\t\treturn nil\n\t}\n\tenv := make(map[string]string, len(lines))\n\tfor _, line := range lines {\n\t\tk, v, ok := parseExportLine(line)\n\t\tif !ok || envKeysNotPersisted[k] || len(v) > maxPersistedEnvValueSize {\n\t\t\tcontinue\n\t\t}\n\t\tenv[k] = v\n\t}\n\treturn env\n}\n\nfunc parseExportLine(line string) (string, string, bool) {\n\tconst prefix = \"declare -x \"\n\tif !strings.HasPrefix(line, prefix) {\n\t\treturn \"\", \"\", false\n\t}\n\trest := strings.TrimSpace(strings.TrimPrefix(line, prefix))\n\tif rest == \"\" {\n\t\treturn \"\", \"\", false\n\t}\n\tname, value := rest, \"\"\n\tif eq := strings.Index(rest, \"=\"); eq >= 0 {\n\t\tname = rest[:eq]\n\t\traw := rest[eq+1:]\n\t\tif unquoted, err := strconv.Unquote(raw); err == nil {\n\t\t\tvalue = unquoted\n\t\t} else {\n\t\t\tvalue = strings.Trim(raw, `\"`)\n\t\t}\n\t}\n\tif !isValidEnvKey(name) {\n\t\treturn \"\", \"\", false\n\t}\n\treturn name, value, true\n}\n\nfunc shellEscape(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", `'\"'\"'`) + \"'\"\n}\n\nfunc isValidEnvKey(key string) bool {\n\tif key == \"\" {\n\t\treturn false\n\t}\n\n\tfor i, r := range key {\n\t\tif i == 0 {\n\t\t\tif (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && r != '_' {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tif (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && (r < '0' || r > '9') && r != '_' {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc copyEnvMap(src map[string]string) map[string]string {\n\tif src == nil {\n\t\treturn map[string]string{}\n\t}\n\n\tdst := make(map[string]string, len(src))\n\tfor k, v := range src {\n\t\tdst[k] = v\n\t}\n\treturn dst\n}\n\nfunc splitEnvPair(kv string) (string, string, bool) {\n\tparts := strings.SplitN(kv, \"=\", 2)\n\tif len(parts) != 2 {\n\t\treturn \"\", \"\", false\n\t}\n\tif !isValidEnvKey(parts[0]) {\n\t\treturn \"\", \"\", false\n\t}\n\treturn parts[0], parts[1], true\n}\n\nfunc (s *bashSession) close() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tpid := s.currentProcessPid\n\ts.currentProcessPid = 0\n\ts.started = false\n\ts.env = nil\n\ts.cwd = \"\"\n\n\tif pid != 0 {\n\t\tif err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {\n\t\t\tlog.Warning(\"kill session process group %d: %v (process may have already exited)\", pid, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc uuidString() string {\n\treturn uuid.New().String()\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/bash_session_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n)\n\nfunc TestBashSession_NonZeroExitEmitsError(t *testing.T) {\n\tif _, err := exec.LookPath(\"bash\"); err != nil {\n\t\tt.Skip(\"bash not found in PATH\")\n\t}\n\n\tc := NewController(\"\", \"\")\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tvar (\n\t\tsessionID  string\n\t\tstdoutLine string\n\t\terrCh      = make(chan *execute.ErrorOutput, 1)\n\t\tcompleteCh = make(chan struct{}, 1)\n\t)\n\n\treq := &ExecuteCodeRequest{\n\t\tLanguage: Bash,\n\t\tCode:     `echo \"before\"; exit 7`,\n\t\tCwd:      t.TempDir(),\n\t\tTimeout:  5 * time.Second,\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteInit:   func(s string) { sessionID = s },\n\t\t\tOnExecuteStdout: func(s string) { stdoutLine = s },\n\t\t\tOnExecuteError:  func(err *execute.ErrorOutput) { errCh <- err },\n\t\t\tOnExecuteComplete: func(_ time.Duration) {\n\t\t\t\tcompleteCh <- struct{}{}\n\t\t\t},\n\t\t},\n\t}\n\n\tsession, err := c.createBashSession(&CreateContextRequest{})\n\tassert.NoError(t, err)\n\treq.Context = session\n\trequire.NoError(t, c.runBashSession(ctx, req))\n\n\tvar gotErr *execute.ErrorOutput\n\tselect {\n\tcase gotErr = <-errCh:\n\tcase <-time.After(2 * time.Second):\n\t\trequire.Fail(t, \"expected error hook to be called\")\n\t}\n\trequire.NotNil(t, gotErr, \"expected non-nil error output\")\n\trequire.Equal(t, \"CommandExecError\", gotErr.EName)\n\trequire.Equal(t, \"7\", gotErr.EValue)\n\trequire.NotEmpty(t, sessionID, \"expected session id to be set\")\n\trequire.Equal(t, \"before\", stdoutLine)\n\n\tselect {\n\tcase <-completeCh:\n\t\trequire.Fail(t, \"did not expect completion hook on non-zero exit\")\n\tdefault:\n\t}\n}\n\nfunc TestBashSession_envAndExitCode(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar (\n\t\tinitCalls     int\n\t\tcompleteCalls int\n\t\tstdoutLines   []string\n\t)\n\n\thooks := ExecuteResultHook{\n\t\tOnExecuteInit: func(ctx string) {\n\t\t\trequire.Equal(t, session.config.Session, ctx, \"unexpected session in OnExecuteInit\")\n\t\t\tinitCalls++\n\t\t},\n\t\tOnExecuteStdout: func(text string) {\n\t\t\tt.Log(text)\n\t\t\tstdoutLines = append(stdoutLines, text)\n\t\t},\n\t\tOnExecuteComplete: func(_ time.Duration) {\n\t\t\tcompleteCalls++\n\t\t},\n\t}\n\n\t// 1) export an env var\n\trequest := &ExecuteCodeRequest{\n\t\tCode:    \"export FOO=hello\",\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request))\n\texportStdoutCount := len(stdoutLines)\n\n\t// 2) verify env is persisted\n\trequest = &ExecuteCodeRequest{\n\t\tCode:    \"echo $FOO\",\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request))\n\techoLines := stdoutLines[exportStdoutCount:]\n\tfoundHello := false\n\tfor _, line := range echoLines {\n\t\tif strings.TrimSpace(line) == \"hello\" {\n\t\t\tfoundHello = true\n\t\t\tbreak\n\t\t}\n\t}\n\trequire.True(t, foundHello, \"expected echo $FOO to output 'hello', got %v\", echoLines)\n\n\t// 3) ensure exit code of previous command is reflected in shell state\n\trequest = &ExecuteCodeRequest{\n\t\tCode:    \"false; echo EXIT:$?\",\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t}\n\tprevCount := len(stdoutLines)\n\trequire.NoError(t, session.run(context.Background(), request))\n\texitLines := stdoutLines[prevCount:]\n\tfoundExit := false\n\tfor _, line := range exitLines {\n\t\tif strings.Contains(line, \"EXIT:1\") {\n\t\t\tfoundExit = true\n\t\t\tbreak\n\t\t}\n\t}\n\trequire.True(t, foundExit, \"expected exit code output 'EXIT:1', got %v\", exitLines)\n\trequire.Equal(t, 3, initCalls, \"OnExecuteInit expected 3 calls\")\n\trequire.Equal(t, 3, completeCalls, \"OnExecuteComplete expected 3 calls\")\n}\n\nfunc TestBashSession_envLargeOutputChained(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar (\n\t\tinitCalls     int\n\t\tcompleteCalls int\n\t\tstdoutLines   []string\n\t)\n\n\thooks := ExecuteResultHook{\n\t\tOnExecuteInit: func(ctx string) {\n\t\t\trequire.Equal(t, session.config.Session, ctx, \"unexpected session in OnExecuteInit\")\n\t\t\tinitCalls++\n\t\t},\n\t\tOnExecuteStdout: func(text string) {\n\t\t\tt.Log(text)\n\t\t\tstdoutLines = append(stdoutLines, text)\n\t\t},\n\t\tOnExecuteComplete: func(_ time.Duration) {\n\t\t\tcompleteCalls++\n\t\t},\n\t}\n\n\trunAndCollect := func(cmd string) []string {\n\t\tstart := len(stdoutLines)\n\t\trequest := &ExecuteCodeRequest{\n\t\t\tCode:    cmd,\n\t\t\tHooks:   hooks,\n\t\t\tTimeout: 10 * time.Second,\n\t\t}\n\t\trequire.NoError(t, session.run(context.Background(), request))\n\t\treturn append([]string(nil), stdoutLines[start:]...)\n\t}\n\n\tlines1 := runAndCollect(\"export FOO=hello1; for i in $(seq 1 60); do echo A${i}:$FOO; done\")\n\trequire.GreaterOrEqual(t, len(lines1), 60, \"expected >=60 lines for cmd1\")\n\trequire.True(t, containsLine(lines1, \"A1:hello1\") && containsLine(lines1, \"A60:hello1\"), \"env not reflected in cmd1 output, got %v\", lines1[:3])\n\n\tlines2 := runAndCollect(\"export FOO=${FOO}_next; export BAR=bar1; for i in $(seq 1 60); do echo B${i}:$FOO:$BAR; done\")\n\trequire.GreaterOrEqual(t, len(lines2), 60, \"expected >=60 lines for cmd2\")\n\trequire.True(t, containsLine(lines2, \"B1:hello1_next:bar1\") && containsLine(lines2, \"B60:hello1_next:bar1\"), \"env not propagated to cmd2 output, sample %v\", lines2[:3])\n\n\tlines3 := runAndCollect(\"export BAR=${BAR}_last; for i in $(seq 1 60); do echo C${i}:$FOO:$BAR; done; echo FINAL_FOO=$FOO; echo FINAL_BAR=$BAR\")\n\trequire.GreaterOrEqual(t, len(lines3), 62, \"expected >=62 lines for cmd3\") // 60 lines + 2 finals\n\trequire.True(t, containsLine(lines3, \"C1:hello1_next:bar1_last\") && containsLine(lines3, \"C60:hello1_next:bar1_last\"), \"env not propagated to cmd3 output, sample %v\", lines3[:3])\n\trequire.True(t, containsLine(lines3, \"FINAL_FOO=hello1_next\") && containsLine(lines3, \"FINAL_BAR=bar1_last\"), \"final env lines missing, got %v\", lines3[len(lines3)-5:])\n\trequire.Equal(t, 3, initCalls, \"OnExecuteInit expected 3 calls\")\n\trequire.Equal(t, 3, completeCalls, \"OnExecuteComplete expected 3 calls\")\n}\n\nfunc TestBashSession_cwdPersistsWithoutOverride(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\ttargetDir := t.TempDir()\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\trunAndCollect := func(req *ExecuteCodeRequest) []string {\n\t\tstart := len(stdoutLines)\n\t\trequire.NoError(t, session.run(context.Background(), req))\n\t\treturn append([]string(nil), stdoutLines[start:]...)\n\t}\n\n\tfirstRunLines := runAndCollect(&ExecuteCodeRequest{\n\t\tCode:    fmt.Sprintf(\"cd %s\\npwd\", targetDir),\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t})\n\trequire.True(t, containsLine(firstRunLines, targetDir), \"expected cd to update cwd to %q, got %v\", targetDir, firstRunLines)\n\n\tsecondRunLines := runAndCollect(&ExecuteCodeRequest{\n\t\tCode:    \"pwd\",\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t})\n\trequire.True(t, containsLine(secondRunLines, targetDir), \"expected subsequent run to inherit cwd %q, got %v\", targetDir, secondRunLines)\n\n\tsession.mu.Lock()\n\tfinalCwd := session.cwd\n\tsession.mu.Unlock()\n\trequire.Equal(t, targetDir, finalCwd, \"expected session cwd to stay at %q\", targetDir)\n}\n\nfunc TestBashSession_requestCwdOverridesAfterCd(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tinitialDir := t.TempDir()\n\toverrideDir := t.TempDir()\n\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\trunAndCollect := func(req *ExecuteCodeRequest) []string {\n\t\tstart := len(stdoutLines)\n\t\trequire.NoError(t, session.run(context.Background(), req))\n\t\treturn append([]string(nil), stdoutLines[start:]...)\n\t}\n\n\t// First request: change session cwd via script.\n\tfirstRunLines := runAndCollect(&ExecuteCodeRequest{\n\t\tCode:    fmt.Sprintf(\"cd %s\\npwd\", initialDir),\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t})\n\trequire.True(t, containsLine(firstRunLines, initialDir), \"expected cd to update cwd to %q, got %v\", initialDir, firstRunLines)\n\n\t// Second request: explicit Cwd overrides session cwd.\n\tsecondRunLines := runAndCollect(&ExecuteCodeRequest{\n\t\tCode:    \"pwd\",\n\t\tCwd:     overrideDir,\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t})\n\trequire.True(t, containsLine(secondRunLines, overrideDir), \"expected command to run in override cwd %q, got %v\", overrideDir, secondRunLines)\n\n\tsession.mu.Lock()\n\tfinalCwd := session.cwd\n\tsession.mu.Unlock()\n\trequire.Equal(t, overrideDir, finalCwd, \"expected session cwd updated to override dir %q\", overrideDir)\n}\n\nfunc TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\trequest := &ExecuteCodeRequest{\n\t\tCode:    `set +x; printf '{\"foo\":1}'`,\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request))\n\n\trequire.Len(t, stdoutLines, 1, \"expected exactly one stdout line\")\n\trequire.Equal(t, `{\"foo\":1}`, strings.TrimSpace(stdoutLines[0]))\n\tfor _, line := range stdoutLines {\n\t\trequire.NotContains(t, line, envDumpStartMarker, \"env dump leaked into stdout: %v\", stdoutLines)\n\t\trequire.NotContains(t, line, \"declare -x\", \"env dump leaked into stdout: %v\", stdoutLines)\n\t}\n}\n\nfunc TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\trequest := &ExecuteCodeRequest{\n\t\tCode:    `set +x; true`,\n\t\tHooks:   hooks,\n\t\tTimeout: 3 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request))\n\n\trequire.LessOrEqual(t, len(stdoutLines), 1, \"expected at most one stdout line, got %v\", stdoutLines)\n\tif len(stdoutLines) == 1 {\n\t\trequire.Empty(t, strings.TrimSpace(stdoutLines[0]), \"expected empty stdout\")\n\t}\n\tfor _, line := range stdoutLines {\n\t\trequire.NotContains(t, line, envDumpStartMarker, \"env dump leaked into stdout: %v\", stdoutLines)\n\t\trequire.NotContains(t, line, \"declare -x\", \"env dump leaked into stdout: %v\", stdoutLines)\n\t}\n}\n\nfunc TestBashSession_heredoc(t *testing.T) {\n\trewardDir := t.TempDir()\n\tcontroller := NewController(\"\", \"\")\n\n\tsessionID, err := controller.CreateBashSession(&CreateContextRequest{})\n\trequire.NoError(t, err)\n\tt.Cleanup(func() { _ = controller.DeleteBashSession(sessionID) })\n\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tfmt.Printf(\"[stdout] %s\\n\", line)\n\t\t},\n\t\tOnExecuteComplete: func(d time.Duration) {\n\t\t\tfmt.Printf(\"[complete] %s\\n\", d)\n\t\t},\n\t}\n\n\t// First run: heredoc + reward file write.\n\tscript := fmt.Sprintf(`\nset -x\nreward_dir=%q\nmkdir -p \"$reward_dir\"\n\ncat > /tmp/repro_script.sh <<'SHEOF'\n#!/usr/bin/env sh\necho \"hello heredoc\"\nSHEOF\n\nchmod +x /tmp/repro_script.sh\n/tmp/repro_script.sh\necho \"after heredoc\"\necho 1 > \"$reward_dir/reward.txt\"\ncat \"$reward_dir/reward.txt\"\n`, rewardDir)\n\n\tctx := context.Background()\n\trequire.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{\n\t\tContext:  sessionID,\n\t\tLanguage: Bash,\n\t\tTimeout:  10 * time.Second,\n\t\tCode:     script,\n\t\tHooks:    hooks,\n\t}))\n\n\t// Second run: ensure the session keeps working.\n\trequire.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{\n\t\tContext:  sessionID,\n\t\tLanguage: Bash,\n\t\tTimeout:  5 * time.Second,\n\t\tCode:     \"echo 'second command works'\",\n\t\tHooks:    hooks,\n\t}))\n}\n\nfunc TestBashSession_execReplacesShell(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\tscript := `\ncat > /tmp/exec_child.sh <<'EOF'\necho \"child says hi\"\nEOF\nchmod +x /tmp/exec_child.sh\nexec /tmp/exec_child.sh\n`\n\n\trequest := &ExecuteCodeRequest{\n\t\tCode:    script,\n\t\tHooks:   hooks,\n\t\tTimeout: 5 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request), \"expected exec to complete without killing the session\")\n\trequire.True(t, containsLine(stdoutLines, \"child says hi\"), \"expected child output, got %v\", stdoutLines)\n\n\t// Subsequent run should still work because we restart bash per run.\n\trequest = &ExecuteCodeRequest{\n\t\tCode:    \"echo still-alive\",\n\t\tHooks:   hooks,\n\t\tTimeout: 2 * time.Second,\n\t}\n\tstdoutLines = nil\n\trequire.NoError(t, session.run(context.Background(), request), \"expected run to succeed after exec replaced the shell\")\n\trequire.True(t, containsLine(stdoutLines, \"still-alive\"), \"expected follow-up output, got %v\", stdoutLines)\n}\n\nfunc TestBashSession_complexExec(t *testing.T) {\n\tsession := newBashSession(\"\")\n\tt.Cleanup(func() { _ = session.close() })\n\n\trequire.NoError(t, session.start())\n\n\tvar stdoutLines []string\n\thooks := ExecuteResultHook{\n\t\tOnExecuteStdout: func(line string) {\n\t\t\tstdoutLines = append(stdoutLines, line)\n\t\t},\n\t}\n\n\tscript := `\nLOG_FILE=$(mktemp)\nexport LOG_FILE\nexec 3>&1 4>&2\nexec > >(tee \"$LOG_FILE\") 2>&1\n\nset -x\necho \"from-complex-exec\"\nexec 1>&3 2>&4 # step record\necho \"after-restore\"\n`\n\n\trequest := &ExecuteCodeRequest{\n\t\tCode:    script,\n\t\tHooks:   hooks,\n\t\tTimeout: 5 * time.Second,\n\t}\n\trequire.NoError(t, session.run(context.Background(), request), \"expected complex exec to finish\")\n\trequire.True(t, containsLine(stdoutLines, \"from-complex-exec\") && containsLine(stdoutLines, \"after-restore\"), \"expected exec outputs, got %v\", stdoutLines)\n\n\t// Session should still be usable.\n\trequest = &ExecuteCodeRequest{\n\t\tCode:    \"echo still-alive\",\n\t\tHooks:   hooks,\n\t\tTimeout: 2 * time.Second,\n\t}\n\tstdoutLines = nil\n\trequire.NoError(t, session.run(context.Background(), request), \"expected run to succeed after complex exec\")\n\trequire.True(t, containsLine(stdoutLines, \"still-alive\"), \"expected follow-up output, got %v\", stdoutLines)\n}\n\nfunc containsLine(lines []string, target string) bool {\n\tfor _, l := range lines {\n\t\tif strings.TrimSpace(l) == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBashSession_CloseKillsRunningProcess verifies that session.close() kills the active\n// process group so that a long-running command (e.g. sleep) does not keep running after close.\nfunc TestBashSession_CloseKillsRunningProcess(t *testing.T) {\n\tif _, err := exec.LookPath(\"bash\"); err != nil {\n\t\tt.Skip(\"bash not found in PATH\")\n\t}\n\n\tsession := newBashSession(\"\")\n\trequire.NoError(t, session.start())\n\n\trunDone := make(chan error, 1)\n\treq := &ExecuteCodeRequest{\n\t\tCode:    \"sleep 30\",\n\t\tTimeout: 60 * time.Second,\n\t\tHooks:   ExecuteResultHook{},\n\t}\n\tgo func() {\n\t\trunDone <- session.run(context.Background(), req)\n\t}()\n\n\t// Give the child process time to start.\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Close should kill the process group; run() should return soon (it may return nil\n\t// because the code path treats non-zero exit as success after calling OnExecuteError).\n\trequire.NoError(t, session.close())\n\n\tselect {\n\tcase <-runDone:\n\t\t// run() returned; process was killed so we did not wait 30s\n\tcase <-time.After(3 * time.Second):\n\t\trequire.Fail(t, \"run did not return within 3s after close (process was not killed)\")\n\t}\n}\n\n// TestBashSession_DeleteBashSessionKillsRunningProcess verifies that DeleteBashSession\n// (close path) kills the active run and removes the session from the controller.\nfunc TestBashSession_DeleteBashSessionKillsRunningProcess(t *testing.T) {\n\tif _, err := exec.LookPath(\"bash\"); err != nil {\n\t\tt.Skip(\"bash not found in PATH\")\n\t}\n\n\tc := NewController(\"\", \"\")\n\tsessionID, err := c.CreateBashSession(&CreateContextRequest{})\n\trequire.NoError(t, err)\n\n\trunDone := make(chan error, 1)\n\treq := &ExecuteCodeRequest{\n\t\tLanguage: Bash,\n\t\tContext:  sessionID,\n\t\tCode:     \"sleep 30\",\n\t\tTimeout:  60 * time.Second,\n\t\tHooks:    ExecuteResultHook{},\n\t}\n\tgo func() {\n\t\trunDone <- c.RunInBashSession(context.Background(), req)\n\t}()\n\n\ttime.Sleep(200 * time.Millisecond)\n\n\trequire.NoError(t, c.DeleteBashSession(sessionID))\n\n\tselect {\n\tcase <-runDone:\n\t\t// RunInBashSession returned; process was killed\n\tcase <-time.After(3 * time.Second):\n\t\trequire.Fail(t, \"RunInBashSession did not return within 3s after DeleteBashSession\")\n\t}\n\n\t// Session should be gone; deleting again should return ErrContextNotFound.\n\terr = c.DeleteBashSession(sessionID)\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, ErrContextNotFound)\n}\n\n// TestBashSession_CloseWithNoActiveRun verifies that close() with no running command\n// completes without error and does not hang.\nfunc TestBashSession_CloseWithNoActiveRun(t *testing.T) {\n\tsession := newBashSession(\"\")\n\trequire.NoError(t, session.start())\n\n\tdone := make(chan struct{}, 1)\n\tgo func() {\n\t\t_ = session.close()\n\t\tdone <- struct{}{}\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// close() returned\n\tcase <-time.After(2 * time.Second):\n\t\trequire.Fail(t, \"close() did not return within 2s when no run was active\")\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/bash_session_windows.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build windows\n// +build windows\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n)\n\nvar errBashSessionNotSupported = errors.New(\"bash session is not supported on windows\")\n\n// CreateBashSession is not supported on Windows.\nfunc (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive\n\treturn \"\", errBashSessionNotSupported\n}\n\n// RunInBashSession is not supported on Windows.\nfunc (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive\n\treturn errBashSessionNotSupported\n}\n\n// DeleteBashSession is not supported on Windows.\nfunc (c *Controller) DeleteBashSession(_ string) error { //nolint:revive\n\treturn errBashSessionNotSupported\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"os/signal\"\n\t\"os/user\"\n\t\"strconv\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/util/safego\"\n)\n\n// getShell returns the preferred shell, falling back to sh if bash is not available.\n// This is needed for Alpine-based Docker images that only have sh by default.\nfunc getShell() string {\n\tif _, err := exec.LookPath(\"bash\"); err == nil {\n\t\treturn \"bash\"\n\t}\n\treturn \"sh\"\n}\n\nfunc buildCredential(uid, gid *uint32) (*syscall.Credential, error) {\n\tif uid == nil && gid == nil {\n\t\treturn nil, nil //nolint:nilnil\n\t}\n\n\tcred := &syscall.Credential{}\n\tif uid != nil {\n\t\tcred.Uid = *uid\n\t\t// Load user info to get primary GID and supplemental groups\n\t\tu, err := user.LookupId(strconv.FormatUint(uint64(*uid), 10))\n\t\tif err == nil {\n\t\t\t// Set primary GID if not explicitly provided\n\t\t\tif gid == nil {\n\t\t\t\tprimaryGid, err := strconv.ParseUint(u.Gid, 10, 32)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcred.Gid = uint32(primaryGid)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load supplemental groups\n\t\t\tgids, err := u.GroupIds()\n\t\t\tif err == nil {\n\t\t\t\tfor _, g := range gids {\n\t\t\t\t\tid, err := strconv.ParseUint(g, 10, 32)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tcred.Groups = append(cred.Groups, uint32(id))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Override Gid if explicitly provided\n\tif gid != nil {\n\t\tcred.Gid = *gid\n\t}\n\n\treturn cred, nil\n}\n\n// runCommand executes shell commands and streams their output.\nfunc (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error {\n\tsession := c.newContextID()\n\n\tsignals := make(chan os.Signal, 1)\n\tdefer close(signals)\n\tsignal.Notify(signals)\n\tdefer signal.Reset()\n\n\tstdout, stderr, err := c.stdLogDescriptor(session)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get stdlog descriptor: %w\", err)\n\t}\n\tdefer stdout.Close()\n\tdefer stderr.Close()\n\tstdoutPath := c.stdoutFileName(session)\n\tstderrPath := c.stderrFileName(session)\n\n\tstartAt := time.Now()\n\tlog.Info(\"received command: %v\", request.Code)\n\tshell := getShell()\n\tcmd := exec.CommandContext(ctx, shell, \"-c\", request.Code)\n\n\t// Configure credentials and process group\n\tcred, err := buildCredential(request.Uid, request.Gid)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to build credential: %w\", err)\n\t}\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tSetpgid:    true,\n\t\tCredential: cred,\n\t}\n\n\tcmd.Stdout = stdout\n\tcmd.Stderr = stderr\n\textraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs)\n\tcmd.Env = mergeEnvs(os.Environ(), extraEnv)\n\tcmd.Dir = request.Cwd\n\n\tdone := make(chan struct{}, 1)\n\tvar wg sync.WaitGroup\n\twg.Add(2)\n\tsafego.Go(func() {\n\t\tdefer wg.Done()\n\t\tc.tailStdPipe(stdoutPath, request.Hooks.OnExecuteStdout, done)\n\t})\n\tsafego.Go(func() {\n\t\tdefer wg.Done()\n\t\tc.tailStdPipe(stderrPath, request.Hooks.OnExecuteStderr, done)\n\t})\n\n\terr = cmd.Start()\n\tif err != nil {\n\t\tclose(done)\n\t\twg.Wait()\n\t\trequest.Hooks.OnExecuteInit(session)\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"CommandExecError\", EValue: err.Error()})\n\t\tlog.Error(\"CommandExecError: error starting commands: %v\", err)\n\t\treturn nil\n\t}\n\n\tkernel := &commandKernel{\n\t\tpid:          cmd.Process.Pid,\n\t\tstdoutPath:   stdoutPath,\n\t\tstderrPath:   stderrPath,\n\t\tstartedAt:    startAt,\n\t\trunning:      true,\n\t\tcontent:      request.Code,\n\t\tisBackground: false,\n\t}\n\tc.storeCommandKernel(session, kernel)\n\trequest.Hooks.OnExecuteInit(session)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase sig := <-signals:\n\t\t\t\tif sig == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// DO NOT forward syscall.SIGURG to children processes.\n\t\t\t\tif sig != syscall.SIGCHLD && sig != syscall.SIGURG {\n\t\t\t\t\t_ = syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\terr = cmd.Wait()\n\tclose(done)\n\twg.Wait()\n\tif err != nil {\n\t\tvar eName, eValue string\n\t\tvar eCode int\n\t\tvar traceback []string\n\n\t\tvar exitError *exec.ExitError\n\t\tif errors.As(err, &exitError) {\n\t\t\texitCode := exitError.ExitCode()\n\t\t\teName = \"CommandExecError\"\n\t\t\teValue = strconv.Itoa(exitCode)\n\t\t\teCode = exitCode\n\t\t} else {\n\t\t\teName = \"CommandExecError\"\n\t\t\teValue = err.Error()\n\t\t\teCode = 1\n\t\t}\n\t\ttraceback = []string{err.Error()}\n\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{\n\t\t\tEName:     eName,\n\t\t\tEValue:    eValue,\n\t\t\tTraceback: traceback,\n\t\t})\n\n\t\tlog.Error(\"CommandExecError: error running commands: %v\", err)\n\t\tc.markCommandFinished(session, eCode, err.Error())\n\t\treturn nil\n\t}\n\n\tc.markCommandFinished(session, 0, \"\")\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n\n// runBackgroundCommand executes shell commands in detached mode.\nfunc (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error {\n\tsession := c.newContextID()\n\trequest.Hooks.OnExecuteInit(session)\n\n\tpipe, err := c.combinedOutputDescriptor(session)\n\tif err != nil {\n\t\tcancel()\n\t\treturn fmt.Errorf(\"failed to get combined output descriptor: %w\", err)\n\t}\n\tstdoutPath := c.combinedOutputFileName(session)\n\tstderrPath := c.combinedOutputFileName(session)\n\n\tsignals := make(chan os.Signal, 1)\n\tdefer close(signals)\n\tsignal.Notify(signals)\n\tdefer signal.Reset()\n\n\tstartAt := time.Now()\n\tlog.Info(\"received command: %v\", request.Code)\n\tshell := getShell()\n\tcmd := exec.CommandContext(ctx, shell, \"-c\", request.Code)\n\tcmd.Dir = request.Cwd\n\t// Configure credentials and process group\n\tcred, err := buildCredential(request.Uid, request.Gid)\n\tif err != nil {\n\t\tlog.Error(\"failed to build credentials: %v\", err)\n\t}\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tSetpgid:    true,\n\t\tCredential: cred,\n\t}\n\n\tcmd.Stdout = pipe\n\tcmd.Stderr = pipe\n\textraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs)\n\tcmd.Env = mergeEnvs(os.Environ(), extraEnv)\n\n\t// use DevNull as stdin so interactive programs exit immediately.\n\tdevNull, err := os.Open(os.DevNull)\n\tif err == nil {\n\t\tcmd.Stdin = devNull\n\t\tdefer devNull.Close()\n\t}\n\n\terr = cmd.Start()\n\tkernel := &commandKernel{\n\t\tpid:          -1,\n\t\tstdoutPath:   stdoutPath,\n\t\tstderrPath:   stderrPath,\n\t\tstartedAt:    startAt,\n\t\trunning:      true,\n\t\tcontent:      request.Code,\n\t\tisBackground: true,\n\t}\n\tif err != nil {\n\t\tcancel()\n\t\tlog.Error(\"CommandExecError: error starting commands: %v\", err)\n\t\tkernel.running = false\n\t\tc.storeCommandKernel(session, kernel)\n\t\tc.markCommandFinished(session, 255, err.Error())\n\t\treturn fmt.Errorf(\"failed to start commands: %w\", err)\n\t}\n\n\tsafego.Go(func() {\n\t\tdefer pipe.Close()\n\n\t\tkernel.running = true\n\t\tkernel.pid = cmd.Process.Pid\n\t\tc.storeCommandKernel(session, kernel)\n\n\t\terr = cmd.Wait()\n\t\tcancel()\n\t\tif err != nil {\n\t\t\tlog.Error(\"CommandExecError: error running commands: %v\", err)\n\t\t\texitCode := 1\n\t\t\tvar exitError *exec.ExitError\n\t\t\tif errors.As(err, &exitError) {\n\t\t\t\texitCode = exitError.ExitCode()\n\t\t\t}\n\t\t\tc.markCommandFinished(session, exitCode, err.Error())\n\t\t\treturn\n\t\t}\n\t\tc.markCommandFinished(session, 0, \"\")\n\t})\n\n\t// ensure we kill the whole process group if the context is cancelled (e.g., timeout).\n\tsafego.Go(func() {\n\t\t<-ctx.Done()\n\t\tif cmd.Process != nil {\n\t\t\t_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // best-effort\n\t\t}\n\t})\n\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command_common.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"time\"\n)\n\n// tailStdPipe streams appended log data until the process finishes.\nfunc (c *Controller) tailStdPipe(file string, onExecute func(text string), done <-chan struct{}) {\n\tlastPos := int64(0)\n\tticker := time.NewTicker(100 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tmutex := &sync.Mutex{}\n\tfor {\n\t\tselect {\n\t\tcase <-done:\n\t\t\tc.readFromPos(mutex, file, lastPos, onExecute, true)\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tnewPos := c.readFromPos(mutex, file, lastPos, onExecute, false)\n\t\t\tlastPos = newPos\n\t\t}\n\t}\n}\n\n// getCommandKernel retrieves a command execution context.\nfunc (c *Controller) getCommandKernel(sessionID string) *commandKernel {\n\tif v, ok := c.commandClientMap.Load(sessionID); ok {\n\t\tif kernel, ok := v.(*commandKernel); ok {\n\t\t\treturn kernel\n\t\t}\n\t}\n\treturn nil\n}\n\n// storeCommandKernel registers a command execution context.\nfunc (c *Controller) storeCommandKernel(sessionID string, kernel *commandKernel) {\n\tc.commandClientMap.Store(sessionID, kernel)\n}\n\n// stdLogDescriptor creates temporary files for capturing command output.\n// It ensures the temp directory exists before opening files, so that commands\n// continue to work even after the /tmp directory has been removed and recreated.\nfunc (c *Controller) stdLogDescriptor(session string) (io.WriteCloser, io.WriteCloser, error) {\n\tlogDir := os.TempDir()\n\tif err := os.MkdirAll(logDir, 0o755); err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to create temp dir %s: %w\", logDir, err)\n\t}\n\n\tstdout, err := os.OpenFile(c.stdoutFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tstderr, err := os.OpenFile(c.stderrFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)\n\tif err != nil {\n\t\tstdout.Close()\n\t\treturn nil, nil, err\n\t}\n\n\treturn stdout, stderr, nil\n}\n\nfunc (c *Controller) combinedOutputDescriptor(session string) (io.WriteCloser, error) {\n\tlogDir := os.TempDir()\n\tif err := os.MkdirAll(logDir, 0o755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create temp dir %s: %w\", logDir, err)\n\t}\n\treturn os.OpenFile(c.combinedOutputFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm)\n}\n\n// stdoutFileName constructs the stdout log path.\nfunc (c *Controller) stdoutFileName(session string) string {\n\treturn filepath.Join(os.TempDir(), session+\".stdout\")\n}\n\n// stderrFileName constructs the stderr log path.\nfunc (c *Controller) stderrFileName(session string) string {\n\treturn filepath.Join(os.TempDir(), session+\".stderr\")\n}\n\nfunc (c *Controller) combinedOutputFileName(session string) string {\n\treturn filepath.Join(os.TempDir(), session+\".output\")\n}\n\n// readFromPos streams new content from a file starting at startPos.\nfunc (c *Controller) readFromPos(mutex *sync.Mutex, filepath string, startPos int64, onExecute func(string), flushIncomplete bool) int64 {\n\tif !mutex.TryLock() {\n\t\treturn -1\n\t}\n\tdefer mutex.Unlock()\n\n\tfile, err := os.Open(filepath)\n\tif err != nil {\n\t\treturn startPos\n\t}\n\tdefer file.Close()\n\n\t_, _ = file.Seek(startPos, 0) //nolint:errcheck\n\n\treader := bufio.NewReader(file)\n\tvar buffer bytes.Buffer\n\tvar currentPos int64 = startPos\n\n\tfor {\n\t\tb, err := reader.ReadByte()\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\t// If buffer has content but no newline, flush if needed, otherwise wait for next read\n\t\t\t\tif flushIncomplete && buffer.Len() > 0 {\n\t\t\t\t\tonExecute(buffer.String())\n\t\t\t\t\tbuffer.Reset()\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tcurrentPos++\n\n\t\t// Check if it's a line terminator (\\n or \\r)\n\t\tif b == '\\n' || b == '\\r' {\n\t\t\t// If buffer has content, output this line\n\t\t\tif buffer.Len() > 0 {\n\t\t\t\tonExecute(buffer.String())\n\t\t\t\tbuffer.Reset()\n\t\t\t}\n\t\t\t// Skip line terminator\n\t\t\tcontinue\n\t\t}\n\n\t\tbuffer.WriteByte(b)\n\t}\n\n\tendPos, _ := file.Seek(0, 1)\n\t// If the last read position doesn't end with a newline, return buffer start position and wait for next flush\n\tif !flushIncomplete && buffer.Len() > 0 {\n\t\treturn currentPos - int64(buffer.Len())\n\t}\n\treturn endPos\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command_status.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"time\"\n)\n\n// CommandStatus describes the lifecycle state of a command.\ntype CommandStatus struct {\n\tSession    string     `json:\"session\"`\n\tRunning    bool       `json:\"running\"`\n\tExitCode   *int       `json:\"exit_code,omitempty\"`\n\tError      string     `json:\"error,omitempty\"`\n\tStartedAt  time.Time  `json:\"started_at,omitempty\"`\n\tFinishedAt *time.Time `json:\"finished_at,omitempty\"`\n\tContent    string     `json:\"content,omitempty\"`\n}\n\n// CommandOutput contains non-streamed stdout/stderr plus status.\ntype CommandOutput struct {\n\tCommandStatus\n\tStdout string `json:\"stdout\"`\n\tStderr string `json:\"stderr\"`\n}\n\nfunc (c *Controller) commandSnapshot(session string) *commandKernel {\n\tvar kernel *commandKernel\n\tif v, ok := c.commandClientMap.Load(session); ok {\n\t\tkernel, _ = v.(*commandKernel)\n\t}\n\tif kernel == nil {\n\t\treturn nil\n\t}\n\n\tcp := *kernel\n\treturn &cp\n}\n\n// GetCommandStatus returns the execution status for a command session.\nfunc (c *Controller) GetCommandStatus(session string) (*CommandStatus, error) {\n\tkernel := c.commandSnapshot(session)\n\tif kernel == nil {\n\t\treturn nil, fmt.Errorf(\"command not found: %s\", session)\n\t}\n\n\tstatus := &CommandStatus{\n\t\tSession:    session,\n\t\tRunning:    kernel.running,\n\t\tExitCode:   kernel.exitCode,\n\t\tError:      kernel.errMsg,\n\t\tStartedAt:  kernel.startedAt,\n\t\tFinishedAt: kernel.finishedAt,\n\t\tContent:    kernel.content,\n\t}\n\treturn status, nil\n}\n\n// SeekBackgroundCommandOutput returns accumulated stdout/stderr and status for a session.\nfunc (c *Controller) SeekBackgroundCommandOutput(session string, cursor int64) ([]byte, int64, error) {\n\tkernel := c.commandSnapshot(session)\n\tif kernel == nil {\n\t\treturn nil, -1, fmt.Errorf(\"command not found: %s\", session)\n\t}\n\n\tif !kernel.isBackground {\n\t\treturn nil, -1, fmt.Errorf(\"command %s is not running in background\", session)\n\t}\n\n\tfile, err := os.Open(kernel.stdoutPath)\n\tif err != nil {\n\t\treturn nil, -1, fmt.Errorf(\"error open combined output file for command %s: %w\", session, err)\n\t}\n\tdefer file.Close()\n\n\t// Seek to the cursor position\n\t_, err = file.Seek(cursor, 0)\n\tif err != nil {\n\t\treturn nil, -1, fmt.Errorf(\"error seek file: %w\", err)\n\t}\n\n\t// Read all content from cursor to end\n\tdata, err := io.ReadAll(file)\n\tif err != nil {\n\t\treturn nil, -1, fmt.Errorf(\"error read file: %w\", err)\n\t}\n\n\t// Get current file position (end of file)\n\tcurrentPos, err := file.Seek(0, 1)\n\tif err != nil {\n\t\treturn nil, -1, fmt.Errorf(\"error get current position: %w\", err)\n\t}\n\n\treturn data, currentPos, nil\n}\n\n// markCommandFinished updates bookkeeping when a command exits.\nfunc (c *Controller) markCommandFinished(session string, exitCode int, errMsg string) {\n\tnow := time.Now()\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tvar kernel *commandKernel\n\tif v, ok := c.commandClientMap.Load(session); ok {\n\t\tkernel, _ = v.(*commandKernel)\n\t}\n\tif kernel == nil {\n\t\treturn\n\t}\n\n\tkernel.exitCode = &exitCode\n\tkernel.errMsg = errMsg\n\tkernel.running = false\n\tkernel.finishedAt = &now\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command_status_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetCommandStatus_NotFound(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\n\t_, err := c.GetCommandStatus(\"missing\")\n\trequire.Error(t, err, \"expected error for missing session\")\n}\n\nfunc TestGetCommandStatus_Running(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\n\tvar session string\n\treq := &ExecuteCodeRequest{\n\t\tLanguage: BackgroundCommand,\n\t\tCode:     \"sleep 2\",\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteInit:     func(id string) { session = id },\n\t\t\tOnExecuteComplete: func(time.Duration) {},\n\t\t},\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\trequire.NoError(t, c.runBackgroundCommand(ctx, cancel, req))\n\trequire.NotEmpty(t, session, \"session should be set by OnExecuteInit\")\n\n\t// Poll until status is registered (runBackgroundCommand stores kernel asynchronously).\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar (\n\t\tstatus *CommandStatus\n\t\terr    error\n\t)\n\tfor time.Now().Before(deadline) {\n\t\tstatus, err = c.GetCommandStatus(session)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\tif strings.Contains(err.Error(), \"not found\") {\n\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\trequire.NoError(t, err, \"GetCommandStatus unexpected error\")\n\t}\n\trequire.NoError(t, err, \"GetCommandStatus error after retry\")\n\n\trequire.NotNil(t, status)\n\trequire.True(t, status.Running, \"expected running=true\")\n\trequire.Nil(t, status.ExitCode, \"expected exitCode to be nil while running\")\n\trequire.Nil(t, status.FinishedAt, \"expected finishedAt to be nil while running\")\n\trequire.False(t, status.StartedAt.IsZero(), \"expected startedAt to be set\")\n\tt.Log(status)\n}\n\nfunc TestSeekBackgroundCommandOutput_Completed(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\n\ttmpDir := t.TempDir()\n\tsession := \"sess-done\"\n\tstdoutPath := filepath.Join(tmpDir, session+\".stdout\")\n\n\tstdoutContent := \"hello stdout\"\n\trequire.NoError(t, os.WriteFile(stdoutPath, []byte(stdoutContent), 0o644))\n\n\tstarted := time.Now().Add(-2 * time.Second)\n\tfinished := time.Now()\n\texitCode := 0\n\tkernel := &commandKernel{\n\t\tpid:          456,\n\t\tstdoutPath:   stdoutPath,\n\t\tisBackground: true,\n\t\tstartedAt:    started,\n\t\tfinishedAt:   &finished,\n\t\texitCode:     &exitCode,\n\t\terrMsg:       \"\",\n\t\trunning:      false,\n\t}\n\tc.storeCommandKernel(session, kernel)\n\n\toutput, cursor, err := c.SeekBackgroundCommandOutput(session, 0)\n\trequire.NoError(t, err, \"GetCommandOutput error\")\n\n\trequire.Greater(t, cursor, int64(0), \"expected cursor>=0\")\n\trequire.Equal(t, stdoutContent, string(output))\n}\n\nfunc TestSeekBackgroundCommandOutput_WithRunBackgroundCommand(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\n\texpected := \"line1\\nline2\\n\"\n\tvar session string\n\treq := &ExecuteCodeRequest{\n\t\tLanguage: BackgroundCommand,\n\t\tCode:     \"printf 'line1\\nline2\\n'\",\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteInit:     func(id string) { session = id },\n\t\t\tOnExecuteComplete: func(executionTime time.Duration) {},\n\t\t\t// other hooks unused in this test\n\t\t},\n\t}\n\n\tctx, cancel := context.WithCancel(context.Background())\n\trequire.NoError(t, c.runBackgroundCommand(ctx, cancel, req))\n\trequire.NotEmpty(t, session, \"session should be set by OnExecuteInit\")\n\n\tvar (\n\t\toutput []byte\n\t\tcursor int64\n\t\terr    error\n\t)\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\toutput, cursor, err = c.SeekBackgroundCommandOutput(session, 0)\n\t\tif err == nil && len(output) > 0 {\n\t\t\tbreak\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\trequire.NoError(t, err, \"SeekBackgroundCommandOutput error\")\n\trequire.Equal(t, expected, string(output))\n\trequire.GreaterOrEqual(t, cursor, int64(len(expected)), \"cursor should advance to end of file\")\n\n\t// incremental seek from current cursor should return empty data and same-or-higher cursor\n\toutput2, cursor2, err := c.SeekBackgroundCommandOutput(session, cursor)\n\trequire.NoError(t, err, \"SeekBackgroundCommandOutput (second call) error\")\n\trequire.Empty(t, output2, \"expected no new output\")\n\trequire.GreaterOrEqual(t, cursor2, cursor, \"cursor should not move backwards\")\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\tgoruntime \"runtime\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestReadFromPos_SplitsOnCRAndLF(t *testing.T) {\n\ttmp := t.TempDir()\n\tlogFile := filepath.Join(tmp, \"stdout.log\")\n\n\tmutex := &sync.Mutex{}\n\n\tinitial := \"line1\\nprog 10%\\rprog 20%\\rprog 30%\\nlast\\n\"\n\trequire.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644))\n\n\tvar got []string\n\tc := &Controller{}\n\tnextPos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false)\n\n\twant := []string{\"line1\", \"prog 10%\", \"prog 20%\", \"prog 30%\", \"last\"}\n\trequire.Len(t, got, len(want))\n\tfor i := range want {\n\t\trequire.Equal(t, want[i], got[i], \"token[%d] mismatch\", i)\n\t}\n\n\t// append more content and ensure incremental read only yields the new part\n\tappendPart := \"tail1\\r\\ntail2\\n\"\n\tf, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644)\n\trequire.NoError(t, err)\n\t_, err = f.WriteString(appendPart)\n\trequire.NoError(t, err, \"append write\")\n\t_ = f.Close()\n\n\tgot = got[:0]\n\tc.readFromPos(mutex, logFile, nextPos, func(s string) { got = append(got, s) }, false)\n\twant = []string{\"tail1\", \"tail2\"}\n\trequire.Len(t, got, len(want))\n\tfor i := range want {\n\t\trequire.Equal(t, want[i], got[i], \"incremental token[%d] mismatch\", i)\n\t}\n}\n\nfunc TestReadFromPos_LongLine(t *testing.T) {\n\ttmp := t.TempDir()\n\tlogFile := filepath.Join(tmp, \"stdout.log\")\n\n\t// construct a single line larger than the default 64KB, but under 5MB\n\tlongLine := strings.Repeat(\"x\", 256*1024) + \"\\n\" // 256KB\n\trequire.NoError(t, os.WriteFile(logFile, []byte(longLine), 0o644))\n\n\tvar got []string\n\tc := &Controller{}\n\tc.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false)\n\n\trequire.Len(t, got, 1, \"expected one token\")\n\trequire.Equal(t, strings.TrimSuffix(longLine, \"\\n\"), got[0], \"long line mismatch\")\n}\n\nfunc TestReadFromPos_FlushesTrailingLine(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tfile := filepath.Join(tmpDir, \"stdout.log\")\n\tcontent := []byte(\"line1\\nlastline-without-newline\")\n\terr := os.WriteFile(file, content, 0o644)\n\tassert.NoError(t, err)\n\n\tc := NewController(\"\", \"\")\n\tmutex := &sync.Mutex{}\n\tvar lines []string\n\tonExecute := func(text string) {\n\t\tlines = append(lines, text)\n\t}\n\n\t// First read: should only get complete lines with newlines\n\tpos := c.readFromPos(mutex, file, 0, onExecute, false)\n\tassert.GreaterOrEqual(t, pos, int64(0))\n\tassert.Equal(t, []string{\"line1\"}, lines)\n\n\t// Flush at end: should output the last line (without newline)\n\tc.readFromPos(mutex, file, pos, onExecute, true)\n\tassert.Equal(t, []string{\"line1\", \"lastline-without-newline\"}, lines)\n}\n\nfunc TestRunCommand_Echo(t *testing.T) {\n\tif goruntime.GOOS == \"windows\" {\n\t\tt.Skip(\"bash not available on windows\")\n\t}\n\tif _, err := exec.LookPath(\"bash\"); err != nil {\n\t\tt.Skip(\"bash not found in PATH\")\n\t}\n\n\tc := NewController(\"\", \"\")\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tvar (\n\t\tsessionID   string\n\t\tstdoutLines []string\n\t\tstderrLines []string\n\t\tcompleteCh  = make(chan struct{}, 1)\n\t)\n\n\treq := &ExecuteCodeRequest{\n\t\tCode:    `echo \"hello\"; echo \"errline\" 1>&2`,\n\t\tCwd:     t.TempDir(),\n\t\tTimeout: 5 * time.Second,\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteInit: func(s string) { sessionID = s },\n\t\t\tOnExecuteStdout: func(s string) {\n\t\t\t\tstdoutLines = append(stdoutLines, s)\n\t\t\t},\n\t\t\tOnExecuteStderr: func(s string) {\n\t\t\t\tstderrLines = append(stderrLines, s)\n\t\t\t},\n\t\t\tOnExecuteError: func(err *execute.ErrorOutput) {\n\t\t\t\trequire.Failf(t, \"unexpected error hook\", \"%+v\", err)\n\t\t\t},\n\t\t\tOnExecuteComplete: func(_ time.Duration) {\n\t\t\t\tcompleteCh <- struct{}{}\n\t\t\t},\n\t\t},\n\t}\n\n\trequire.NoError(t, c.runCommand(ctx, req))\n\n\tselect {\n\tcase <-completeCh:\n\tcase <-time.After(2 * time.Second):\n\t\trequire.Fail(t, \"timeout waiting for completion hook\")\n\t}\n\n\trequire.NotEmpty(t, sessionID, \"expected session id to be set\")\n\trequire.Equal(t, []string{\"hello\"}, stdoutLines)\n\trequire.Equal(t, []string{\"errline\"}, stderrLines)\n}\n\nfunc TestRunCommand_Error(t *testing.T) {\n\tif goruntime.GOOS == \"windows\" {\n\t\tt.Skip(\"bash not available on windows\")\n\t}\n\tif _, err := exec.LookPath(\"bash\"); err != nil {\n\t\tt.Skip(\"bash not found in PATH\")\n\t}\n\n\tc := NewController(\"\", \"\")\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tvar (\n\t\tsessionID   string\n\t\tgotErr      *execute.ErrorOutput\n\t\tcompleteCh  = make(chan struct{}, 2)\n\t\tstdoutLines []string\n\t\tstderrLines []string\n\t)\n\n\treq := &ExecuteCodeRequest{\n\t\tCode:    `echo \"before\"; exit 3`,\n\t\tCwd:     t.TempDir(),\n\t\tTimeout: 5 * time.Second,\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteInit:   func(s string) { sessionID = s },\n\t\t\tOnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) },\n\t\t\tOnExecuteStderr: func(s string) { stderrLines = append(stderrLines, s) },\n\t\t\tOnExecuteError: func(err *execute.ErrorOutput) {\n\t\t\t\tgotErr = err\n\t\t\t\tcompleteCh <- struct{}{}\n\t\t\t},\n\t\t\tOnExecuteComplete: func(_ time.Duration) {\n\t\t\t\tcompleteCh <- struct{}{}\n\t\t\t},\n\t\t},\n\t}\n\n\trequire.NoError(t, c.runCommand(ctx, req))\n\n\tselect {\n\tcase <-completeCh:\n\tcase <-time.After(2 * time.Second):\n\t\trequire.Fail(t, \"timeout waiting for completion hook\")\n\t}\n\n\trequire.NotEmpty(t, sessionID, \"expected session id to be set\")\n\trequire.Equal(t, []string{\"before\"}, stdoutLines)\n\trequire.Empty(t, stderrLines, \"expected no stderr\")\n\trequire.NotNil(t, gotErr, \"expected error hook to be called\")\n\trequire.Equal(t, \"CommandExecError\", gotErr.EName)\n\trequire.Equal(t, \"3\", gotErr.EValue)\n}\n\n// TestStdLogDescriptor_AutoCreatesTempDir verifies that stdLogDescriptor\n// recreates the temp directory when it has been deleted, rather than failing.\n// Regression test for https://github.com/alibaba/OpenSandbox/issues/400.\nfunc TestStdLogDescriptor_AutoCreatesTempDir(t *testing.T) {\n\tif goruntime.GOOS == \"windows\" {\n\t\tt.Skip(\"TMPDIR env var has no effect on Windows\")\n\t}\n\n\t// Point os.TempDir() at a path that does not yet exist.\n\tmissingDir := filepath.Join(t.TempDir(), \"deleted_tmp\")\n\tt.Setenv(\"TMPDIR\", missingDir)\n\n\tc := NewController(\"\", \"\")\n\tstdout, stderr, err := c.stdLogDescriptor(\"test-session\")\n\trequire.NoError(t, err)\n\tstdout.Close()\n\tstderr.Close()\n\n\t// The directory must have been created.\n\tinfo, err := os.Stat(missingDir)\n\trequire.NoError(t, err, \"expected temp dir to be created, stat error\")\n\trequire.True(t, info.IsDir(), \"expected %s to be a directory\", missingDir)\n}\n\n// TestCombinedOutputDescriptor_AutoCreatesTempDir verifies that\n// combinedOutputDescriptor also recreates the temp directory when missing.\n// Regression test for https://github.com/alibaba/OpenSandbox/issues/400.\nfunc TestCombinedOutputDescriptor_AutoCreatesTempDir(t *testing.T) {\n\tif goruntime.GOOS == \"windows\" {\n\t\tt.Skip(\"TMPDIR env var has no effect on Windows\")\n\t}\n\n\tmissingDir := filepath.Join(t.TempDir(), \"deleted_tmp\")\n\tt.Setenv(\"TMPDIR\", missingDir)\n\n\tc := NewController(\"\", \"\")\n\tf, err := c.combinedOutputDescriptor(\"test-session\")\n\trequire.NoError(t, err)\n\tf.Close()\n\n\tinfo, err := os.Stat(missingDir)\n\trequire.NoError(t, err, \"expected temp dir to be created, stat error\")\n\trequire.True(t, info.IsDir(), \"expected %s to be a directory\", missingDir)\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/command_windows.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build windows\n// +build windows\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/util/safego\"\n)\n\n// runCommand executes shell commands and streams their output on Windows.\nfunc (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error {\n\tsession := c.newContextID()\n\trequest.Hooks.OnExecuteInit(session)\n\n\tstdout, stderr, err := c.stdLogDescriptor(session)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get stdlog descriptor: %w\", err)\n\t}\n\n\tstartAt := time.Now()\n\tlog.Info(\"received command: %v\", request.Code)\n\tcmd := exec.CommandContext(ctx, \"cmd\", \"/C\", request.Code)\n\n\tcmd.Stdout = stdout\n\tcmd.Stderr = stderr\n\tcmd.Dir = request.Cwd\n\textraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs)\n\tcmd.Env = mergeEnvs(os.Environ(), extraEnv)\n\n\tdone := make(chan struct{}, 1)\n\tsafego.Go(func() {\n\t\tc.tailStdPipe(c.stdoutFileName(session), request.Hooks.OnExecuteStdout, done)\n\t})\n\tsafego.Go(func() {\n\t\tc.tailStdPipe(c.stderrFileName(session), request.Hooks.OnExecuteStderr, done)\n\t})\n\n\terr = cmd.Start()\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"CommandExecError\", EValue: err.Error()})\n\t\tlog.Error(\"CommandExecError: error starting commands: %v\", err)\n\t\treturn nil\n\t}\n\n\tkernel := &commandKernel{\n\t\tpid:          cmd.Process.Pid,\n\t\tcontent:      request.Code,\n\t\tisBackground: false,\n\t}\n\tc.storeCommandKernel(session, kernel)\n\n\terr = cmd.Wait()\n\tclose(done)\n\tif err != nil {\n\t\tvar eName, eValue string\n\t\tvar traceback []string\n\n\t\tvar exitError *exec.ExitError\n\t\tif errors.As(err, &exitError) {\n\t\t\texitCode := exitError.ExitCode()\n\t\t\teName = \"CommandExecError\"\n\t\t\teValue = strconv.Itoa(exitCode)\n\t\t} else {\n\t\t\teName = \"CommandExecError\"\n\t\t\teValue = err.Error()\n\t\t}\n\t\ttraceback = []string{err.Error()}\n\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{\n\t\t\tEName:     eName,\n\t\t\tEValue:    eValue,\n\t\t\tTraceback: traceback,\n\t\t})\n\n\t\tlog.Error(\"CommandExecError: error running commands: %v\", err)\n\t\treturn nil\n\t}\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n\n// runBackgroundCommand executes shell commands in detached mode on Windows.\nfunc (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error {\n\tsession := c.newContextID()\n\trequest.Hooks.OnExecuteInit(session)\n\n\tpipe, err := c.combinedOutputDescriptor(session)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get combined output descriptor: %w\", err)\n\t}\n\tstdoutPath := c.combinedOutputFileName(session)\n\tstderrPath := c.combinedOutputFileName(session)\n\n\tstartAt := time.Now()\n\tlog.Info(\"received command: %v\", request.Code)\n\tcmd := exec.CommandContext(ctx, \"cmd\", \"/C\", request.Code)\n\n\tcmd.Dir = request.Cwd\n\tcmd.Stdout = pipe\n\tcmd.Stderr = pipe\n\textraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs)\n\tcmd.Env = mergeEnvs(os.Environ(), extraEnv)\n\n\tdevNull, _ := os.OpenFile(os.DevNull, os.O_RDWR, 0) // best-effort, ignore error\n\tcmd.Stdin = devNull\n\n\tsafego.Go(func() {\n\t\terr := cmd.Start()\n\t\tif err != nil {\n\t\t\tlog.Error(\"CommandExecError: error starting commands: %v\", err)\n\t\t\tpipe.Close() // best-effort\n\t\t\tcancel()\n\t\t\treturn\n\t\t}\n\n\t\tkernel := &commandKernel{\n\t\t\tpid:          cmd.Process.Pid,\n\t\t\tcontent:      request.Code,\n\t\t\tstdoutPath:   stdoutPath,\n\t\t\tstderrPath:   stderrPath,\n\t\t\tstartedAt:    startAt,\n\t\t\trunning:      true,\n\t\t\tisBackground: true,\n\t\t}\n\t\tc.storeCommandKernel(session, kernel)\n\n\t\tsafego.Go(func() {\n\t\t\t<-ctx.Done()\n\t\t\tif cmd.Process != nil {\n\t\t\t\t_ = cmd.Process.Kill() // best-effort\n\t\t\t}\n\t\t})\n\n\t\terr = cmd.Wait()\n\t\tcancel()\n\t\tpipe.Close()    // best-effort\n\t\tdevNull.Close() // best-effort\n\n\t\tif err != nil {\n\t\t\tlog.Error(\"CommandExecError: error running commands: %v\", err)\n\t\t\texitCode := 1\n\t\t\tvar exitError *exec.ExitError\n\t\t\tif errors.As(err, &exitError) {\n\t\t\t\texitCode = exitError.ExitCode()\n\t\t\t}\n\t\t\tc.markCommandFinished(session, exitCode, err.Error())\n\t\t\treturn\n\t\t}\n\t\tc.markCommandFinished(session, 0, \"\")\n\t})\n\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/context.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"k8s.io/client-go/util/retry\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter\"\n\tjupytersession \"github.com/alibaba/opensandbox/execd/pkg/jupyter/session\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// CreateContext provisions a kernel-backed session and returns its ID.\n// Bash language uses Jupyter kernel like other languages; for pipe-based bash sessions use CreateBashSession (session API).\nfunc (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {\n\t// Create a new Jupyter session.\n\tvar (\n\t\tclient  *jupyter.Client\n\t\tsession *jupytersession.Session\n\t\terr     error\n\t)\n\n\terr = retry.OnError(kernelWaitingBackoff, func(err error) bool {\n\t\tlog.Error(\"failed to create session, retrying: %v\", err)\n\t\treturn err != nil\n\t}, func() error {\n\t\tclient, session, err = c.createJupyterContext(*req)\n\t\treturn err\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tkernel := &jupyterKernel{\n\t\tkernelID: session.Kernel.ID,\n\t\tclient:   client,\n\t\tlanguage: req.Language,\n\t}\n\tc.storeJupyterKernel(session.ID, kernel)\n\n\terr = c.setWorkingDir(kernel, req)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to setup working dir: %w\", err)\n\t}\n\n\treturn session.ID, nil\n}\n\nfunc (c *Controller) DeleteContext(session string) error {\n\treturn c.deleteSessionAndCleanup(session)\n}\n\nfunc (c *Controller) GetContext(session string) (CodeContext, error) {\n\tkernel := c.getJupyterKernel(session)\n\tif kernel == nil {\n\t\treturn CodeContext{}, ErrContextNotFound\n\t}\n\treturn CodeContext{\n\t\tID:       session,\n\t\tLanguage: kernel.language,\n\t}, nil\n}\n\nfunc (c *Controller) ListContext(language string) ([]CodeContext, error) {\n\tswitch language {\n\tcase Command.String(), BackgroundCommand.String(), SQL.String():\n\t\treturn nil, fmt.Errorf(\"unsupported language context operation: %s\", language)\n\tcase \"\":\n\t\treturn c.listAllContexts()\n\tdefault:\n\t\treturn c.listLanguageContexts(Language(language))\n\t}\n}\n\nfunc (c *Controller) DeleteLanguageContext(language Language) error {\n\tcontexts, err := c.listLanguageContexts(language)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tseen := make(map[string]struct{})\n\tfor _, context := range contexts {\n\t\tif _, ok := seen[context.ID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[context.ID] = struct{}{}\n\n\t\tif err := c.deleteSessionAndCleanup(context.ID); err != nil {\n\t\t\treturn fmt.Errorf(\"error deleting context %s: %w\", context.ID, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *Controller) deleteSessionAndCleanup(session string) error {\n\tif c.getJupyterKernel(session) == nil {\n\t\treturn ErrContextNotFound\n\t}\n\tif err := c.jupyterClient().DeleteSession(session); err != nil {\n\t\treturn err\n\t}\n\tc.jupyterClientMap.Delete(session)\n\tc.deleteDefaultSessionByID(session)\n\treturn nil\n}\n\nfunc (c *Controller) newContextID() string {\n\treturn strings.ReplaceAll(uuid.New().String(), \"-\", \"\")\n}\n\nfunc (c *Controller) newIpynbPath(sessionID, cwd string) (string, error) {\n\tif cwd != \"\" {\n\t\terr := os.MkdirAll(cwd, os.ModePerm)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\n\treturn filepath.Join(cwd, fmt.Sprintf(\"%s.ipynb\", sessionID)), nil\n}\n\n// createDefaultLanguageJupyterContext prewarms a session for stateless execution.\nfunc (c *Controller) createDefaultLanguageJupyterContext(language Language) error {\n\tif c.getDefaultLanguageSession(language) != \"\" {\n\t\treturn nil\n\t}\n\n\tvar (\n\t\tclient  *jupyter.Client\n\t\tsession *jupytersession.Session\n\t\terr     error\n\t)\n\terr = retry.OnError(kernelWaitingBackoff, func(err error) bool {\n\t\tlog.Error(\"failed to create context, retrying: %v\", err)\n\t\treturn err != nil\n\t}, func() error {\n\t\tclient, session, err = c.createJupyterContext(CreateContextRequest{\n\t\t\tLanguage: language,\n\t\t\tCwd:      \"\",\n\t\t})\n\t\treturn err\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.setDefaultLanguageSession(language, session.ID)\n\tc.jupyterClientMap.Store(session.ID, &jupyterKernel{\n\t\tkernelID: session.Kernel.ID,\n\t\tclient:   client,\n\t\tlanguage: language,\n\t})\n\treturn nil\n}\n\n// createJupyterContext performs the actual context creation workflow.\nfunc (c *Controller) createJupyterContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) {\n\tclient := c.jupyterClient()\n\n\tkernel, err := c.searchKernel(client, request.Language)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tsessionID := c.newContextID()\n\tipynb, err := c.newIpynbPath(sessionID, request.Cwd)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tjupyterSession, err := client.CreateSession(sessionID, ipynb, kernel)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tkernels, err := client.ListKernels()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tfound := false\n\tfor _, k := range kernels {\n\t\tif k.ID == jupyterSession.Kernel.ID {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\treturn nil, nil, errors.New(\"kernel not found\")\n\t}\n\n\treturn client, jupyterSession, nil\n}\n\n// storeJupyterKernel caches a session -> kernel mapping.\nfunc (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel) {\n\tc.jupyterClientMap.Store(sessionID, kernel)\n}\n\nfunc (c *Controller) jupyterClient() *jupyter.Client {\n\thttpClient := &http.Client{\n\t\tTransport: &jupyter.AuthTransport{\n\t\t\tToken: c.token,\n\t\t\tBase:  http.DefaultTransport,\n\t\t},\n\t}\n\n\treturn jupyter.NewClient(c.baseURL,\n\t\tjupyter.WithToken(c.token),\n\t\tjupyter.WithHTTPClient(httpClient))\n}\n\nfunc (c *Controller) getDefaultLanguageSession(language Language) string {\n\tif v, ok := c.defaultLanguageSessions.Load(language); ok {\n\t\tif session, ok := v.(string); ok {\n\t\t\treturn session\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (c *Controller) setDefaultLanguageSession(language Language, sessionID string) {\n\tc.defaultLanguageSessions.Store(language, sessionID)\n}\n\nfunc (c *Controller) deleteDefaultSessionByID(sessionID string) {\n\tc.defaultLanguageSessions.Range(func(key, value any) bool {\n\t\tif s, ok := value.(string); ok && s == sessionID {\n\t\t\tc.defaultLanguageSessions.Delete(key)\n\t\t}\n\t\treturn true\n\t})\n}\n\nfunc (c *Controller) listAllContexts() ([]CodeContext, error) {\n\tcontexts := make([]CodeContext, 0)\n\tc.jupyterClientMap.Range(func(key, value any) bool {\n\t\tsession, _ := key.(string)\n\t\tif kernel, ok := value.(*jupyterKernel); ok && kernel != nil {\n\t\t\tcontexts = append(contexts, CodeContext{ID: session, Language: kernel.language})\n\t\t}\n\t\treturn true\n\t})\n\n\tc.defaultLanguageSessions.Range(func(key, value any) bool {\n\t\tlang, _ := key.(Language)\n\t\tsession, _ := value.(string)\n\t\tif session == \"\" {\n\t\t\treturn true\n\t\t}\n\t\tcontexts = append(contexts, CodeContext{ID: session, Language: lang})\n\t\treturn true\n\t})\n\n\treturn contexts, nil\n}\n\nfunc (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) {\n\tcontexts := make([]CodeContext, 0)\n\tc.jupyterClientMap.Range(func(key, value any) bool {\n\t\tsession, _ := key.(string)\n\t\tif kernel, ok := value.(*jupyterKernel); ok && kernel != nil && kernel.language == language {\n\t\t\tcontexts = append(contexts, CodeContext{ID: session, Language: language})\n\t\t}\n\t\treturn true\n\t})\n\n\tif defaultContext := c.getDefaultLanguageSession(language); defaultContext != \"\" {\n\t\tcontexts = append(contexts, CodeContext{ID: defaultContext, Language: language})\n\t}\n\n\treturn contexts, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/context_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestListContextsAndNewIpynbPath(t *testing.T) {\n\tc := NewController(\"http://example\", \"token\")\n\tc.jupyterClientMap.Store(\"session-python\", &jupyterKernel{language: Python})\n\tc.defaultLanguageSessions.Store(Go, \"session-go-default\")\n\n\tpyContexts, err := c.listLanguageContexts(Python)\n\trequire.NoError(t, err)\n\trequire.Len(t, pyContexts, 1)\n\trequire.Equal(t, \"session-python\", pyContexts[0].ID)\n\trequire.Equal(t, Python, pyContexts[0].Language)\n\n\tallContexts, err := c.listAllContexts()\n\trequire.NoError(t, err)\n\trequire.Len(t, allContexts, 2)\n\n\ttmpDir := filepath.Join(t.TempDir(), \"nested\")\n\tpath, err := c.newIpynbPath(\"abc123\", tmpDir)\n\trequire.NoError(t, err)\n\t_, statErr := os.Stat(tmpDir)\n\trequire.NoError(t, statErr, \"expected directory to be created\")\n\texpected := filepath.Join(tmpDir, \"abc123.ipynb\")\n\trequire.Equal(t, expected, path)\n}\n\nfunc TestNewContextID_UniqueAndLength(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\tid1 := c.newContextID()\n\tid2 := c.newContextID()\n\n\trequire.NotEmpty(t, id1)\n\trequire.NotEmpty(t, id2)\n\trequire.NotEqual(t, id1, id2, \"expected unique ids\")\n\trequire.Len(t, id1, 32)\n\trequire.Len(t, id2, 32)\n}\n\nfunc TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\ttmpFile := filepath.Join(t.TempDir(), \"file.txt\")\n\trequire.NoError(t, os.WriteFile(tmpFile, []byte(\"x\"), 0o644))\n\n\t_, err := c.newIpynbPath(\"abc\", tmpFile)\n\trequire.Error(t, err, \"expected error when cwd is a file\")\n}\n\nfunc TestListContextUnsupportedLanguage(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\t_, err := c.ListContext(Command.String())\n\trequire.Error(t, err, \"expected error for command language\")\n\t_, err = c.ListContext(BackgroundCommand.String())\n\trequire.Error(t, err, \"expected error for background-command language\")\n\t_, err = c.ListContext(SQL.String())\n\trequire.Error(t, err, \"expected error for sql language\")\n}\n\nfunc TestDeleteContext_NotFound(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\terr := c.DeleteContext(\"missing\")\n\trequire.Error(t, err, \"expected ErrContextNotFound\")\n\trequire.ErrorIs(t, err, ErrContextNotFound)\n}\n\nfunc TestGetContext_NotFound(t *testing.T) {\n\tc := NewController(\"\", \"\")\n\n\t_, err := c.GetContext(\"missing\")\n\trequire.Error(t, err, \"expected ErrContextNotFound\")\n\trequire.ErrorIs(t, err, ErrContextNotFound)\n}\n\nfunc TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) {\n\tsessionID := \"sess-123\"\n\n\t// mock jupyter server that accepts DELETE\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, http.MethodDelete, r.Method, \"unexpected method\")\n\t\trequire.True(t, strings.HasSuffix(r.URL.Path, \"/api/sessions/\"+sessionID), \"unexpected path: %s\", r.URL.Path)\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tc := NewController(server.URL, \"token\")\n\tc.jupyterClientMap.Store(sessionID, &jupyterKernel{language: Python})\n\tc.defaultLanguageSessions.Store(Python, sessionID)\n\n\trequire.NoError(t, c.DeleteContext(sessionID))\n\n\trequire.Nil(t, c.getJupyterKernel(sessionID), \"expected cache to be cleared\")\n\t_, ok := c.defaultLanguageSessions.Load(Python)\n\trequire.False(t, ok, \"expected default session entry to be removed\")\n}\n\nfunc TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) {\n\tlang := Python\n\tsession1 := \"sess-1\"\n\tsession2 := \"sess-2\"\n\n\t// mock jupyter server to accept two deletes\n\tdeleteCalls := make(map[string]int)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequire.Equal(t, http.MethodDelete, r.Method, \"unexpected method\")\n\t\tif strings.Contains(r.URL.Path, session1) {\n\t\t\tdeleteCalls[session1]++\n\t\t} else if strings.Contains(r.URL.Path, session2) {\n\t\t\tdeleteCalls[session2]++\n\t\t} else {\n\t\t\trequire.Failf(t, \"unexpected path\", \"%s\", r.URL.Path)\n\t\t}\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}))\n\tdefer server.Close()\n\n\tc := NewController(server.URL, \"token\")\n\tc.jupyterClientMap.Store(session1, &jupyterKernel{language: lang})\n\tc.jupyterClientMap.Store(session2, &jupyterKernel{language: lang})\n\tc.defaultLanguageSessions.Store(lang, session2)\n\n\trequire.NoError(t, c.DeleteLanguageContext(lang))\n\n\t_, ok := c.jupyterClientMap.Load(session1)\n\trequire.False(t, ok, \"expected session1 removed from cache\")\n\t_, ok = c.jupyterClientMap.Load(session2)\n\trequire.False(t, ok, \"expected session2 removed from cache\")\n\t_, ok = c.defaultLanguageSessions.Load(lang)\n\trequire.False(t, ok, \"expected default entry removed\")\n\trequire.Equal(t, 1, deleteCalls[session1])\n\trequire.Equal(t, 1, deleteCalls[session2])\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/ctrl.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter\"\n)\n\nvar kernelWaitingBackoff = wait.Backoff{\n\tSteps:    60,\n\tDuration: 500 * time.Millisecond,\n\tFactor:   1.5,\n\tJitter:   0.1,\n}\n\n// Controller manages code execution across runtimes.\ntype Controller struct {\n\tbaseURL                 string\n\ttoken                   string\n\tmu                      sync.RWMutex\n\tjupyterClientMap        sync.Map // map[sessionID]*jupyterKernel\n\tdefaultLanguageSessions sync.Map // map[Language]string\n\tcommandClientMap        sync.Map // map[sessionID]*commandKernel\n\tbashSessionClientMap    sync.Map // map[sessionID]*bashSession\n\tdb                      *sql.DB\n\tdbOnce                  sync.Once\n}\n\ntype jupyterKernel struct {\n\tmu       sync.Mutex\n\tkernelID string\n\tclient   *jupyter.Client\n\tlanguage Language\n}\n\ntype commandKernel struct {\n\tpid          int\n\tstdoutPath   string\n\tstderrPath   string\n\tstartedAt    time.Time\n\tfinishedAt   *time.Time\n\texitCode     *int\n\terrMsg       string\n\trunning      bool\n\tisBackground bool\n\tcontent      string\n}\n\n// NewController creates a runtime controller.\nfunc NewController(baseURL, token string) *Controller {\n\treturn &Controller{\n\t\tbaseURL: baseURL,\n\t\ttoken:   token,\n\t}\n}\n\n// Execute dispatches a request to the correct backend.\nfunc (c *Controller) Execute(request *ExecuteCodeRequest) error {\n\tvar cancel context.CancelFunc\n\tvar ctx context.Context\n\tif request.Timeout > 0 {\n\t\tctx, cancel = context.WithTimeout(context.Background(), request.Timeout)\n\t} else {\n\t\tctx, cancel = context.WithCancel(context.Background())\n\t}\n\n\tswitch request.Language {\n\tcase Command:\n\t\tdefer cancel()\n\t\treturn c.runCommand(ctx, request)\n\tcase BackgroundCommand:\n\t\treturn c.runBackgroundCommand(ctx, cancel, request)\n\tcase Bash, Python, Java, JavaScript, TypeScript, Go:\n\t\tdefer cancel()\n\t\treturn c.runJupyter(ctx, request)\n\tcase SQL:\n\t\tdefer cancel()\n\t\treturn c.runSQL(ctx, request)\n\tdefault:\n\t\tdefer cancel()\n\t\treturn fmt.Errorf(\"unknown language: %s\", request.Language)\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/env.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// loadExtraEnvFromFile reads key=value lines from EXECD_ENVS (if set).\n// Empty lines and lines starting with '#' are ignored.\nfunc loadExtraEnvFromFile() map[string]string {\n\tpath := os.Getenv(\"EXECD_ENVS\")\n\tif path == \"\" {\n\t\treturn nil\n\t}\n\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\tlog.Warn(\"EXECD_ENVS: failed to read file %s: %v\", path, err)\n\t\treturn nil\n\t}\n\n\tenvs := make(map[string]string)\n\tlines := strings.Split(string(data), \"\\n\")\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" || strings.HasPrefix(line, \"#\") {\n\t\t\tcontinue\n\t\t}\n\t\tkv := strings.SplitN(line, \"=\", 2)\n\t\tif len(kv) != 2 {\n\t\t\tlog.Warn(\"EXECD_ENVS: skip malformed line: %s\", line)\n\t\t\tcontinue\n\t\t}\n\t\tenvs[kv[0]] = os.ExpandEnv(kv[1])\n\t}\n\n\treturn envs\n}\n\n// mergeEnvs overlays extra into base and returns a merged slice.\nfunc mergeEnvs(base []string, extra map[string]string) []string {\n\tif len(extra) == 0 {\n\t\treturn base\n\t}\n\n\tmerged := make(map[string]string, len(base)+len(extra))\n\tfor _, kv := range base {\n\t\tpair := strings.SplitN(kv, \"=\", 2)\n\t\tif len(pair) == 2 {\n\t\t\tmerged[pair[0]] = pair[1]\n\t\t}\n\t}\n\n\tfor k, v := range extra {\n\t\tmerged[k] = v\n\t}\n\n\tout := make([]string, 0, len(merged))\n\tfor k, v := range merged {\n\t\tout = append(out, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\n\treturn out\n}\n\n// mergeExtraEnvs merges environment maps from file and request-level overrides.\nfunc mergeExtraEnvs(fromFile, fromRequest map[string]string) map[string]string {\n\tif len(fromRequest) == 0 {\n\t\treturn fromFile\n\t}\n\n\tmerged := make(map[string]string, len(fromFile)+len(fromRequest))\n\tfor k, v := range fromFile {\n\t\tmerged[k] = v\n\t}\n\tfor k, v := range fromRequest {\n\t\tmerged[k] = v\n\t}\n\n\treturn merged\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/env_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestLoadExtraEnvFromFileUnset(t *testing.T) {\n\tt.Setenv(\"EXECD_ENVS\", \"\")\n\trequire.Nil(t, loadExtraEnvFromFile(), \"expected nil when EXECD_ENVS unset\")\n}\n\nfunc TestLoadExtraEnvFromFileParsesAndExpands(t *testing.T) {\n\tdir := t.TempDir()\n\tenvFile := filepath.Join(dir, \"env\")\n\n\tt.Setenv(\"EXECD_ENVS\", envFile)\n\tt.Setenv(\"BASE_DIR\", \"/opt/base\")\n\n\tcontent := strings.Join([]string{\n\t\t\"# comment\",\n\t\t\"FOO=bar\",\n\t\t\"PATH=$BASE_DIR/bin\",\n\t\t\"MALFORMED\",\n\t\t\"EMPTY=\",\n\t\t\"\",\n\t}, \"\\n\")\n\n\trequire.NoError(t, os.WriteFile(envFile, []byte(content), 0o644))\n\n\tgot := loadExtraEnvFromFile()\n\trequire.Len(t, got, 3)\n\trequire.Equal(t, \"bar\", got[\"FOO\"])\n\trequire.Equal(t, \"/opt/base/bin\", got[\"PATH\"])\n\tval, ok := got[\"EMPTY\"]\n\trequire.True(t, ok)\n\trequire.Equal(t, \"\", val)\n}\n\nfunc TestLoadExtraEnvFromFileMissingFile(t *testing.T) {\n\tdir := t.TempDir()\n\tenvFile := filepath.Join(dir, \"does-not-exist\")\n\tt.Setenv(\"EXECD_ENVS\", envFile)\n\n\trequire.Nil(t, loadExtraEnvFromFile(), \"expected nil for missing file\")\n}\n\nfunc TestMergeEnvsOverlaysExtra(t *testing.T) {\n\tbase := []string{\"A=1\", \"B=2\"}\n\textra := map[string]string{\"B\": \"override\", \"C\": \"3\"}\n\n\tmerged := mergeEnvs(base, extra)\n\tgot := make(map[string]string)\n\tfor _, kv := range merged {\n\t\tparts := strings.SplitN(kv, \"=\", 2)\n\t\tif len(parts) == 2 {\n\t\t\tgot[parts[0]] = parts[1]\n\t\t}\n\t}\n\n\trequire.Len(t, got, 3)\n\trequire.Equal(t, \"1\", got[\"A\"])\n\trequire.Equal(t, \"override\", got[\"B\"])\n\trequire.Equal(t, \"3\", got[\"C\"])\n}\n\nfunc TestMergeExtraEnvsMergesAndOverrides(t *testing.T) {\n\tfromFile := map[string]string{\"A\": \"1\", \"B\": \"2\"}\n\tfromRequest := map[string]string{\"B\": \"override\", \"C\": \"3\"}\n\n\tgot := mergeExtraEnvs(fromFile, fromRequest)\n\n\trequire.Len(t, got, 3)\n\trequire.Equal(t, \"1\", got[\"A\"])\n\trequire.Equal(t, \"override\", got[\"B\"])\n\trequire.Equal(t, \"3\", got[\"C\"])\n}\n\nfunc TestMergeExtraEnvsHandlesNilFromFile(t *testing.T) {\n\tfromRequest := map[string]string{\"ONLY\": \"request\"}\n\n\tgot := mergeExtraEnvs(nil, fromRequest)\n\n\trequire.Len(t, got, 1)\n\trequire.Equal(t, \"request\", got[\"ONLY\"])\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/errors.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport \"errors\"\n\nvar ErrContextNotFound = errors.New(\"context not found\")\n"
  },
  {
    "path": "components/execd/pkg/runtime/helpers_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"database/sql/driver\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype stubDriver struct {\n\tcolumns          []string\n\trows             [][]driver.Value\n\texecRowsAffected int64\n\tqueryErr         error\n\texecErr          error\n\tpingErr          error\n\texecCalled       int32\n\tqueryCalled      int32\n}\n\ntype stubConn struct {\n\td *stubDriver\n}\n\nfunc (c *stubConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New(\"not implemented\") }\nfunc (c *stubConn) Close() error                        { return nil }\nfunc (c *stubConn) Begin() (driver.Tx, error)           { return nil, errors.New(\"not implemented\") }\n\nfunc (c *stubConn) Ping(context.Context) error {\n\treturn c.d.pingErr\n}\n\nfunc (c *stubConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) {\n\tatomic.AddInt32(&c.d.execCalled, 1)\n\tif c.d.execErr != nil {\n\t\treturn nil, c.d.execErr\n\t}\n\treturn driver.RowsAffected(c.d.execRowsAffected), nil\n}\n\nfunc (c *stubConn) QueryContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) {\n\tatomic.AddInt32(&c.d.queryCalled, 1)\n\tif c.d.queryErr != nil {\n\t\treturn nil, c.d.queryErr\n\t}\n\treturn &stubRows{\n\t\tcolumns: c.d.columns,\n\t\trows:    c.d.rows,\n\t}, nil\n}\n\ntype stubRows struct {\n\tcolumns []string\n\trows    [][]driver.Value\n\tidx     int\n}\n\nfunc (r *stubRows) Columns() []string { return r.columns }\nfunc (r *stubRows) Close() error      { return nil }\nfunc (r *stubRows) Next(dest []driver.Value) error {\n\tif r.idx >= len(r.rows) {\n\t\treturn io.EOF\n\t}\n\trow := r.rows[r.idx]\n\tr.idx++\n\tfor i, v := range row {\n\t\tdest[i] = v\n\t}\n\treturn nil\n}\n\ntype stubConnector struct {\n\td *stubDriver\n}\n\nfunc (c *stubConnector) Connect(context.Context) (driver.Conn, error) {\n\treturn &stubConn{d: c.d}, nil\n}\n\nfunc (c *stubConnector) Driver() driver.Driver {\n\treturn c\n}\n\nfunc (c *stubConnector) Open(string) (driver.Conn, error) {\n\treturn &stubConn{d: c.d}, nil\n}\n\nfunc newStubDB(t *testing.T, d *stubDriver) *sql.DB {\n\tt.Helper()\n\tdriverName := fmt.Sprintf(\"stub-%d\", time.Now().UnixNano())\n\tsql.Register(driverName, &stubConnector{d: d})\n\tdb, err := sql.Open(driverName, \"\")\n\trequire.NoError(t, err)\n\treturn db\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/interrupt.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage runtime\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// Interrupt stops execution in the specified session.\nfunc (c *Controller) Interrupt(sessionID string) error {\n\tswitch {\n\tcase c.getJupyterKernel(sessionID) != nil:\n\t\tkernel := c.getJupyterKernel(sessionID)\n\t\tlog.Warning(\"Interrupting Jupyter kernel %s\", kernel.kernelID)\n\t\treturn kernel.client.InterruptKernel(kernel.kernelID)\n\tcase c.getCommandKernel(sessionID) != nil:\n\t\tkernel := c.getCommandKernel(sessionID)\n\t\treturn c.killPid(kernel.pid)\n\tcase c.getBashSession(sessionID) != nil:\n\t\treturn c.closeBashSession(sessionID)\n\tdefault:\n\t\treturn errors.New(\"no such session\")\n\t}\n}\n\n// killPid sends SIGTERM followed by SIGKILL if needed.\nfunc (c *Controller) killPid(pid int) error {\n\tprocess, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Warning(\"Attempting to terminate process %d\", pid)\n\n\tif err := process.Signal(syscall.SIGTERM); err != nil {\n\t\tif strings.Contains(err.Error(), \"already finished\") {\n\t\t\treturn nil\n\t\t}\n\t\tlog.Warning(\"SIGTERM failed for pid %d: %v, trying SIGKILL\", pid, err)\n\t} else {\n\t\tdone := make(chan error, 1)\n\t\tgo func() {\n\t\t\t_, err := process.Wait()\n\t\t\tdone <- err\n\t\t}()\n\n\t\tselect {\n\t\tcase err := <-done:\n\t\t\tif err == nil {\n\t\t\t\tlog.Info(\"Process %d terminated gracefully\", pid)\n\t\t\t\treturn nil\n\t\t\t}\n\t\tcase <-time.After(3 * time.Second):\n\t\t\tlog.Warning(\"Process %d did not terminate after SIGTERM, using SIGKILL\", pid)\n\t\t}\n\t}\n\n\tif err := process.Signal(syscall.SIGKILL); err != nil {\n\t\tif strings.Contains(err.Error(), \"already finished\") {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"failed to kill process %d: %w\", pid, err)\n\t}\n\n\tfor range 3 {\n\t\tif err := process.Signal(syscall.Signal(0)); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"already finished\") ||\n\t\t\t\tstrings.Contains(err.Error(), \"no such process\") {\n\t\t\t\tlog.Info(\"Process %d confirmed terminated\", pid)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\n\treturn fmt.Errorf(\"process %d might still be running\", pid)\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/interrupt_windows.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build windows\n// +build windows\n\npackage runtime\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// Interrupt stops execution in the specified session.\nfunc (c *Controller) Interrupt(sessionID string) error {\n\tswitch {\n\tcase c.getJupyterKernel(sessionID) != nil:\n\t\tkernel := c.getJupyterKernel(sessionID)\n\t\tlog.Warning(\"Interrupting Jupyter kernel %s\", kernel.kernelID)\n\t\treturn kernel.client.InterruptKernel(kernel.kernelID)\n\tcase c.getCommandKernel(sessionID) != nil:\n\t\tkernel := c.getCommandKernel(sessionID)\n\t\treturn c.killPid(kernel.pid)\n\tdefault:\n\t\treturn errors.New(\"no such session\")\n\t}\n}\n\n// killPid terminates a process on Windows.\nfunc (c *Controller) killPid(pid int) error {\n\tprocess, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Warning(\"Attempting to terminate process %d\", pid)\n\n\tif err := process.Kill(); err != nil {\n\t\treturn fmt.Errorf(\"failed to kill process %d: %w\", pid, err)\n\t}\n\n\t// Best-effort wait to reduce zombies; os.Process.Wait only works for child processes.\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\t_, err := process.Wait()\n\t\tdone <- err\n\t}()\n\n\tselect {\n\tcase <-done:\n\tcase <-time.After(3 * time.Second):\n\t\tlog.Warning(\"Process %d kill wait timed out\", pid)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/jupyter.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// runJupyter executes code through a Jupyter kernel.\nfunc (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest) error {\n\tif c.baseURL == \"\" || c.token == \"\" {\n\t\treturn errors.New(\"language runtime server not configured, please check your image runtime\")\n\t}\n\tif request.Context == \"\" {\n\t\tif c.getDefaultLanguageSession(request.Language) == \"\" {\n\t\t\tif err := c.createDefaultLanguageJupyterContext(request.Language); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\tvar targetSessionID string\n\tif request.Context == \"\" {\n\t\ttargetSessionID = c.getDefaultLanguageSession(request.Language)\n\t} else {\n\t\ttargetSessionID = request.Context\n\t}\n\n\tkernel := c.getJupyterKernel(targetSessionID)\n\tif kernel == nil {\n\t\treturn ErrContextNotFound\n\t}\n\n\trequest.SetDefaultHooks()\n\trequest.Hooks.OnExecuteInit(targetSessionID)\n\n\treturn c.runJupyterCode(ctx, kernel, request)\n}\n\n// runJupyterCode streams execution results for a single kernel.\n//\n//nolint:gocognit // complex due to hook handling; refactor later\nfunc (c *Controller) runJupyterCode(ctx context.Context, kernel *jupyterKernel, request *ExecuteCodeRequest) error {\n\tif !kernel.mu.TryLock() {\n\t\treturn errors.New(\"session is busy\")\n\t}\n\tdefer kernel.mu.Unlock()\n\n\terr := kernel.client.ConnectToKernel(kernel.kernelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer kernel.client.DisconnectFromKernel(kernel.kernelID)\n\n\tresults := make(chan *execute.ExecutionResult, 10)\n\n\terr = kernel.client.ExecuteCodeStream(kernel.kernelID, request.Code, results)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase result := <-results:\n\t\t\tif result == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tif result.ExecutionCount > 0 || len(result.ExecutionData) > 0 {\n\t\t\t\trequest.Hooks.OnExecuteResult(result.ExecutionData, result.ExecutionCount)\n\t\t\t}\n\n\t\t\tif result.Status != \"\" {\n\t\t\t\trequest.Hooks.OnExecuteStatus(result.Status)\n\t\t\t}\n\n\t\t\tif result.ExecutionTime > 0 {\n\t\t\t\trequest.Hooks.OnExecuteComplete(result.ExecutionTime)\n\t\t\t}\n\n\t\t\tif result.Error != nil {\n\t\t\t\trequest.Hooks.OnExecuteError(result.Error)\n\t\t\t}\n\n\t\t\tif len(result.Stream) > 0 {\n\t\t\t\tfor _, stream := range result.Stream {\n\t\t\t\t\tswitch stream.Name {\n\t\t\t\t\tcase execute.StreamStdout:\n\t\t\t\t\t\trequest.Hooks.OnExecuteStdout(stream.Text)\n\t\t\t\t\tcase execute.StreamStderr:\n\t\t\t\t\t\trequest.Hooks.OnExecuteStderr(stream.Text)\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase <-ctx.Done():\n\t\t\tlog.Warning(\"context cancelled, try to interrupt kernel\")\n\t\t\terr = kernel.client.InterruptKernel(kernel.kernelID)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(\"interrupt kernel failed: %v\", err)\n\t\t\t}\n\n\t\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{\n\t\t\t\tEName:  \"ContextCancelled\",\n\t\t\t\tEValue: \"Interrupt kernel\",\n\t\t\t})\n\t\t\treturn errors.New(\"context cancelled, interrupt kernel\")\n\t\t}\n\t}\n}\n\n// setWorkingDir configures the working directory for a kernel session.\nfunc (c *Controller) setWorkingDir(_ *jupyterKernel, _ *CreateContextRequest) error {\n\treturn nil\n}\n\n// getJupyterKernel retrieves a kernel connection from the session map.\nfunc (c *Controller) getJupyterKernel(sessionID string) *jupyterKernel {\n\tif v, ok := c.jupyterClientMap.Load(sessionID); ok {\n\t\tif kernel, ok := v.(*jupyterKernel); ok {\n\t\t\treturn kernel\n\t\t}\n\t}\n\treturn nil\n}\n\n// searchKernel finds a kernel spec name for the given language.\nfunc (c *Controller) searchKernel(client *jupyter.Client, language Language) (string, error) {\n\tspecs, err := client.GetKernelSpecs()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif len(specs.Kernelspecs) == 0 {\n\t\treturn \"\", errors.New(\"no kernel specs found\")\n\t}\n\n\tvar kernelName string\n\tfor name, spec := range specs.Kernelspecs {\n\t\tif name == \"python3\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tif spec.Spec.Language == language.String() {\n\t\t\tkernelName = name\n\t\t}\n\t}\n\tif kernelName == \"\" {\n\t\treturn \"\", errors.New(\"no kernel specs found\")\n\t}\n\n\treturn kernelName, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/language.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\n// Language represents the programming language or execution mode\ntype Language string\n\nconst (\n\tCommand           Language = \"command\"\n\tBash              Language = \"bash\"\n\tPython            Language = \"python\"\n\tJava              Language = \"java\"\n\tJavaScript        Language = \"javascript\"\n\tTypeScript        Language = \"typescript\"\n\tGo                Language = \"go\"\n\tSQL               Language = \"sql\"\n\tBackgroundCommand Language = \"background-command\"\n)\n\n// String returns the string representation of the language\nfunc (l Language) String() string {\n\treturn string(l)\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/sql.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\n\t_ \"github.com/go-sql-driver/mysql\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\n// QueryResult represents a SQL query response.\ntype QueryResult struct {\n\tColumns []string `json:\"columns,omitempty\"`\n\tRows    [][]any  `json:\"rows,omitempty\"`\n\tError   string   `json:\"error,omitempty\"`\n}\n\n// runSQL executes SQL queries based on their type.\nfunc (c *Controller) runSQL(ctx context.Context, request *ExecuteCodeRequest) error {\n\trequest.Hooks.OnExecuteInit(uuid.New().String())\n\terr := c.initDB()\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"DBInitError\", EValue: err.Error()})\n\t\tlog.Error(\"DBInitError: error initializing db server: %v\", err)\n\t\treturn err\n\t}\n\n\terr = c.db.PingContext(ctx)\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"DBPingError\", EValue: err.Error()})\n\t\tlog.Error(\"DBPingError: error pinging db server: %v\", err)\n\t\treturn err\n\t}\n\n\tswitch c.getQueryType(request.Code) {\n\tcase \"SELECT\":\n\t\treturn c.executeSelectSQLQuery(ctx, request)\n\tdefault:\n\t\treturn c.executeUpdateSQLQuery(ctx, request)\n\t}\n}\n\n// executeSelectSQLQuery handles SELECT statements.\nfunc (c *Controller) executeSelectSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error {\n\tstartAt := time.Now()\n\n\trows, err := c.db.QueryContext(ctx, request.Code)\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"DBQueryError\", EValue: err.Error()})\n\t\treturn nil\n\t}\n\tdefer rows.Close()\n\n\tcolumns, err := rows.Columns()\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"DBQueryError\", EValue: err.Error()})\n\t\treturn nil\n\t}\n\n\tvar result [][]any\n\tvalues := make([]any, len(columns))\n\tscanArgs := make([]any, len(columns))\n\tfor i := range values {\n\t\tscanArgs[i] = &values[i]\n\t}\n\n\tfor rows.Next() {\n\t\terr := rows.Scan(scanArgs...)\n\t\tif err != nil {\n\t\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"RowScanError\", EValue: err.Error()})\n\t\t\treturn nil\n\t\t}\n\t\trow := make([]any, len(columns))\n\t\tfor i, v := range values {\n\t\t\tif v == nil {\n\t\t\t\trow[i] = nil\n\t\t\t} else {\n\t\t\t\trow[i] = fmt.Sprintf(\"%v\", v)\n\t\t\t}\n\t\t}\n\t\tresult = append(result, row)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"RowIterationError\", EValue: err.Error()})\n\t\treturn nil\n\t}\n\n\tqueryResult := QueryResult{\n\t\tColumns: columns,\n\t\tRows:    result,\n\t}\n\tbytes, err := json.Marshal(queryResult)\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"JSONMarshalError\", EValue: err.Error()})\n\t\treturn nil\n\t}\n\trequest.Hooks.OnExecuteResult(\n\t\tmap[string]any{\n\t\t\t\"text/plain\": string(bytes),\n\t\t},\n\t\t1,\n\t)\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n\n// executeUpdateSQLQuery handles non-SELECT statements.\nfunc (c *Controller) executeUpdateSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error {\n\tstartAt := time.Now()\n\n\tresult, err := c.db.ExecContext(ctx, request.Code)\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"DBExecError\", EValue: err.Error()})\n\t\treturn err\n\t}\n\n\taffected, _ := result.RowsAffected()\n\tqueryResult := QueryResult{\n\t\tRows:    [][]any{{affected}},\n\t\tColumns: []string{\"affected_rows\"},\n\t}\n\tbytes, err := json.Marshal(queryResult)\n\tif err != nil {\n\t\trequest.Hooks.OnExecuteError(&execute.ErrorOutput{EName: \"JSONMarshalError\", EValue: err.Error()})\n\t\treturn err\n\t}\n\trequest.Hooks.OnExecuteResult(\n\t\tmap[string]any{\n\t\t\t\"text/plain\": string(bytes),\n\t\t},\n\t\t1,\n\t)\n\trequest.Hooks.OnExecuteComplete(time.Since(startAt))\n\treturn nil\n}\n\n// getQueryType extracts the first token to decide which executor to use.\nfunc (c *Controller) getQueryType(query string) string {\n\tfields := strings.Fields(query)\n\tif len(fields) == 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.ToUpper(fields[0])\n}\n\n// initDB lazily opens the local sandbox database.\nfunc (c *Controller) initDB() error {\n\tvar initErr error\n\tc.dbOnce.Do(func() {\n\t\tdsn := \"root:@tcp(127.0.0.1:3306)/\"\n\t\tdb, err := sql.Open(\"mysql\", dsn)\n\t\tif err != nil {\n\t\t\tinitErr = err\n\t\t\treturn\n\t\t}\n\n\t\terr = db.Ping()\n\t\tif err != nil {\n\t\t\tinitErr = err\n\t\t\treturn\n\t\t}\n\n\t\t_, err = db.Exec(\"CREATE DATABASE IF NOT EXISTS sandbox\")\n\t\tif err != nil {\n\t\t\tinitErr = err\n\t\t\treturn\n\t\t}\n\n\t\t_, err = db.Exec(\"USE sandbox\")\n\t\tif err != nil {\n\t\t\tinitErr = err\n\t\t\treturn\n\t\t}\n\n\t\tc.db = db\n\t})\n\n\tif initErr != nil {\n\t\treturn initErr\n\t}\n\tif c.db == nil {\n\t\treturn errors.New(\"db is not initialized\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/sql_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"database/sql/driver\"\n\t\"encoding/json\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestExecuteSelectSQLQuery_Success(t *testing.T) {\n\tdriver := &stubDriver{\n\t\tcolumns: []string{\"id\", \"name\"},\n\t\trows: [][]driver.Value{\n\t\t\t{int64(1), \"alice\"},\n\t\t\t{int64(2), \"bob\"},\n\t\t},\n\t}\n\tdb := newStubDB(t, driver)\n\n\tc := NewController(\"\", \"\")\n\tc.db = db\n\n\tvar (\n\t\tgotResult map[string]any\n\t\tgotError  *execute.ErrorOutput\n\t\tcompleted bool\n\t)\n\n\treq := &ExecuteCodeRequest{\n\t\tCode: \"SELECT * FROM users\",\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteResult: func(result map[string]any, _ int) {\n\t\t\t\tgotResult = result\n\t\t\t},\n\t\t\tOnExecuteError: func(err *execute.ErrorOutput) {\n\t\t\t\tgotError = err\n\t\t\t},\n\t\t\tOnExecuteComplete: func(time.Duration) {\n\t\t\t\tcompleted = true\n\t\t\t},\n\t\t},\n\t}\n\n\trequire.NoError(t, c.executeSelectSQLQuery(context.Background(), req))\n\n\trequire.Nil(t, gotError, \"unexpected error hook\")\n\trequire.True(t, completed, \"expected completion hook to be triggered\")\n\n\traw, ok := gotResult[\"text/plain\"]\n\trequire.True(t, ok, \"expected text/plain payload\")\n\tvar qr QueryResult\n\trequire.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr))\n\n\trequire.Equal(t, []string{\"id\", \"name\"}, qr.Columns, \"unexpected columns\")\n\trequire.Len(t, qr.Rows, 2, \"unexpected rows\")\n\trequire.Equal(t, \"1\", qr.Rows[0][0])\n\trequire.Equal(t, \"bob\", qr.Rows[1][1])\n}\n\nfunc TestExecuteUpdateSQLQuery_Success(t *testing.T) {\n\tdriver := &stubDriver{\n\t\texecRowsAffected: 3,\n\t}\n\tdb := newStubDB(t, driver)\n\n\tc := NewController(\"\", \"\")\n\tc.db = db\n\n\tvar (\n\t\tgotResult map[string]any\n\t\tgotError  *execute.ErrorOutput\n\t\tcompleted bool\n\t)\n\n\treq := &ExecuteCodeRequest{\n\t\tCode: \"UPDATE users SET name='alice' WHERE id=1\",\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteResult: func(result map[string]any, _ int) {\n\t\t\t\tgotResult = result\n\t\t\t},\n\t\t\tOnExecuteError: func(err *execute.ErrorOutput) {\n\t\t\t\tgotError = err\n\t\t\t},\n\t\t\tOnExecuteComplete: func(time.Duration) {\n\t\t\t\tcompleted = true\n\t\t\t},\n\t\t},\n\t}\n\n\trequire.NoError(t, c.executeUpdateSQLQuery(context.Background(), req))\n\n\trequire.Nil(t, gotError, \"unexpected error hook\")\n\trequire.True(t, completed, \"expected completion hook to be triggered\")\n\n\traw, ok := gotResult[\"text/plain\"]\n\trequire.True(t, ok, \"expected text/plain payload\")\n\tvar qr QueryResult\n\trequire.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr))\n\n\trequire.Equal(t, []string{\"affected_rows\"}, qr.Columns, \"unexpected columns\")\n\trequire.Len(t, qr.Rows, 1, \"unexpected rows length\")\n\trequire.Len(t, qr.Rows[0], 1, \"unexpected row entry length\")\n\trequire.Equal(t, float64(3), qr.Rows[0][0])\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n)\n\n// ExecuteResultHook groups execution callbacks.\ntype ExecuteResultHook struct {\n\tOnExecuteInit     func(context string)\n\tOnExecuteResult   func(result map[string]any, count int)\n\tOnExecuteStatus   func(status string)\n\tOnExecuteStdout   func(stdout string) //nolint:predeclared\n\tOnExecuteStderr   func(stderr string) //nolint:predeclared\n\tOnExecuteError    func(err *execute.ErrorOutput)\n\tOnExecuteComplete func(executionTime time.Duration)\n}\n\n// ExecuteCodeRequest represents a code execution request with context and hooks.\ntype ExecuteCodeRequest struct {\n\tLanguage Language          `json:\"language\"`\n\tCode     string            `json:\"code\"`\n\tContext  string            `json:\"context\"`\n\tTimeout  time.Duration     `json:\"timeout\"`\n\tCwd      string            `json:\"cwd\"`\n\tEnvs     map[string]string `json:\"envs\"`\n\tUid      *uint32           `json:\"uid,omitempty\"`\n\tGid      *uint32           `json:\"gid,omitempty\"`\n\tHooks    ExecuteResultHook\n}\n\n// SetDefaultHooks installs stdout logging fallbacks for unset hooks.\nfunc (req *ExecuteCodeRequest) SetDefaultHooks() {\n\tif req.Hooks.OnExecuteResult == nil {\n\t\treq.Hooks.OnExecuteResult = func(result map[string]any, count int) { fmt.Printf(\"OnExecuteResult: %d, %++v\\n\", count, result) }\n\t}\n\tif req.Hooks.OnExecuteStatus == nil {\n\t\treq.Hooks.OnExecuteStatus = func(status string) { fmt.Printf(\"OnExecuteStatus: %s\\n\", status) }\n\t}\n\tif req.Hooks.OnExecuteStdout == nil {\n\t\treq.Hooks.OnExecuteStdout = func(stdout string) { fmt.Printf(\"OnExecuteStdout: %s\\n\", stdout) }\n\t}\n\tif req.Hooks.OnExecuteStderr == nil {\n\t\treq.Hooks.OnExecuteStderr = func(stderr string) { fmt.Printf(\"OnExecuteStderr: %s\\n\", stderr) }\n\t}\n\tif req.Hooks.OnExecuteError == nil {\n\t\treq.Hooks.OnExecuteError = func(err *execute.ErrorOutput) { fmt.Printf(\"OnExecuteError: %++v\\n\", err) }\n\t}\n\tif req.Hooks.OnExecuteComplete == nil {\n\t\treq.Hooks.OnExecuteComplete = func(executionTime time.Duration) {\n\t\t\tfmt.Printf(\"OnExecuteComplete: %v\\n\", executionTime)\n\t\t}\n\t}\n\tif req.Hooks.OnExecuteInit == nil {\n\t\treq.Hooks.OnExecuteInit = func(session string) { fmt.Printf(\"OnExecuteInit: %s\\n\", session) }\n\t}\n}\n\n// CreateContextRequest represents a stateful session creation request.\ntype CreateContextRequest struct {\n\tLanguage Language `json:\"language\"`\n\tCwd      string   `json:\"cwd\"`\n}\n\ntype CodeContext struct {\n\tID       string   `json:\"id,omitempty\"`\n\tLanguage Language `json:\"language\"`\n}\n\n// bashSessionConfig holds bash session configuration.\ntype bashSessionConfig struct {\n\t// StartupSource is a list of scripts sourced on startup.\n\tStartupSource []string\n\t// Session is the session identifier.\n\tSession string\n\t// StartupTimeout is the startup timeout.\n\tStartupTimeout time.Duration\n\t// Cwd is the working directory.\n\tCwd string\n}\n\n// bashSession represents a bash session.\ntype bashSession struct {\n\tconfig  *bashSessionConfig\n\tmu      sync.Mutex\n\tstarted bool\n\tenv     map[string]string\n\tcwd     string\n\n\t// currentProcessPid is the pid of the active run's process group leader (bash).\n\t// Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group.\n\tcurrentProcessPid int\n}\n"
  },
  {
    "path": "components/execd/pkg/runtime/types_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestExecuteCodeRequest_SetDefaultHooks(t *testing.T) {\n\tcustomResult := func(map[string]any, int) {}\n\n\treq := &ExecuteCodeRequest{\n\t\tHooks: ExecuteResultHook{\n\t\t\tOnExecuteResult: customResult,\n\t\t},\n\t}\n\n\treq.SetDefaultHooks()\n\n\trequire.NotNil(t, req.Hooks.OnExecuteStdout)\n\trequire.NotNil(t, req.Hooks.OnExecuteStderr)\n\trequire.NotNil(t, req.Hooks.OnExecuteError)\n\trequire.NotNil(t, req.Hooks.OnExecuteResult, \"expected OnExecuteResult to remain set\")\n\trequire.Equal(t, reflect.ValueOf(customResult).Pointer(), reflect.ValueOf(req.Hooks.OnExecuteResult).Pointer(),\n\t\t\"default hooks should not override existing ones\")\n}\n"
  },
  {
    "path": "components/execd/pkg/util/glob/index.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage glob\n\nfunc findUnescapedByteIndex(s string, c byte, allowEscaping bool) int {\n\tl := len(s)\n\tfor i := 0; i < l; i++ {\n\t\tif allowEscaping && s[i] == '\\\\' {\n\t\t\t// skip next byte\n\t\t\ti++\n\t\t} else if s[i] == c {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\n// findMatchedClosingAltIndex finds the matching `}` for a `{`.\nfunc findMatchedClosingAltIndex(s string, allowEscaping bool) int {\n\treturn findMatchedClosingSymbolsIndex(s, allowEscaping, '{', '}', 1)\n}\n\n// findMatchedClosingBracketIndex finds the matching `)` for a `(`.\nfunc findMatchedClosingBracketIndex(s string, allowEscaping bool) int {\n\treturn findMatchedClosingSymbolsIndex(s, allowEscaping, '(', ')', 0)\n}\n\n// findNextCommaIndex returns the next comma outside nested braces.\nfunc findNextCommaIndex(s string, allowEscaping bool) int {\n\talts := 1\n\tl := len(s)\n\tfor i := 0; i < l; i++ {\n\t\tif allowEscaping && s[i] == '\\\\' {\n\t\t\ti++\n\t\t} else if s[i] == '{' {\n\t\t\talts++\n\t\t} else if s[i] == '}' {\n\t\t\talts--\n\t\t} else if s[i] == ',' && alts == 1 {\n\t\t\treturn i\n\t\t}\n\t}\n\treturn -1\n}\n\nfunc findMatchedClosingSymbolsIndex(s string, allowEscaping bool, left, right uint8, begin int) int {\n\tl := len(s)\n\tfor i := 0; i < l; i++ {\n\t\tif allowEscaping && s[i] == '\\\\' {\n\t\t\ti++\n\t\t} else if s[i] == left {\n\t\t\tbegin++\n\t\t} else if s[i] == right {\n\t\t\tif begin--; begin == 0 {\n\t\t\t\treturn i\n\t\t\t}\n\t\t}\n\t}\n\treturn -1\n}\n"
  },
  {
    "path": "components/execd/pkg/util/glob/match.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\n// This code is based on or derived from doublestar\n// Copyright (c) 2014 Bob Matcuk\n// Licensed under MIT License\n// https://github.com/bmatcuk/doublestar/blob/master/LICENSE\n\npackage glob\n\nimport (\n\t\"path/filepath\"\n\t\"unicode/utf8\"\n\n\tglobutil \"github.com/bmatcuk/doublestar/v4\"\n)\n\n// PathMatch is filepath.Match compatible but honors doublestar semantics.\nfunc PathMatch(pattern, name string) (bool, error) {\n\treturn matchWithSeparator(pattern, name, filepath.Separator, true)\n}\n\nfunc matchWithSeparator(pattern, name string, separator rune, validate bool) (matched bool, err error) {\n\treturn doMatchWithSeparator(pattern, name, separator, validate, -1, -1, -1, -1, 0, 0)\n}\n\n//nolint:gocognit,nestif,gocyclo,maintidx\nfunc doMatchWithSeparator(pattern, name string, separator rune, validate bool, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, patIdx, nameIdx int) (matched bool, err error) {\n\tpatLen := len(pattern)\n\tnameLen := len(name)\n\tstartOfSegment := true\nMATCH:\n\tfor nameIdx < nameLen {\n\t\tif patIdx < patLen {\n\t\t\tswitch pattern[patIdx] {\n\t\t\tcase '*':\n\t\t\t\tif patIdx++; patIdx < patLen && pattern[patIdx] == '*' {\n\t\t\t\t\t// doublestar - must begin with a path separator, otherwise we'll\n\t\t\t\t\tpatIdx++\n\t\t\t\t\tif startOfSegment {\n\t\t\t\t\t\tif patIdx >= patLen {\n\t\t\t\t\t\t\t// pattern ends in `/**`: return true\n\t\t\t\t\t\t\treturn true, nil\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// doublestar must also end with a path separator, otherwise we're\n\t\t\t\t\t\tpatRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:])\n\t\t\t\t\t\tif patRune == separator {\n\t\t\t\t\t\t\tpatIdx += patRuneLen\n\n\t\t\t\t\t\t\tdoublestarPatternBacktrack = patIdx\n\t\t\t\t\t\t\tdoublestarNameBacktrack = nameIdx\n\t\t\t\t\t\t\tstarPatternBacktrack = -1\n\t\t\t\t\t\t\tstarNameBacktrack = -1\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstartOfSegment = false\n\n\t\t\t\tstarPatternBacktrack = patIdx\n\t\t\t\tstarNameBacktrack = nameIdx\n\t\t\t\tcontinue\n\n\t\t\tcase '?':\n\t\t\t\tstartOfSegment = false\n\t\t\t\tnameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:])\n\t\t\t\tif nameRune == separator {\n\t\t\t\t\t// `?` cannot match the separator\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tpatIdx++\n\t\t\t\tnameIdx += nameRuneLen\n\t\t\t\tcontinue\n\n\t\t\tcase '[':\n\t\t\t\tstartOfSegment = false\n\t\t\t\tif patIdx++; patIdx >= patLen {\n\t\t\t\t\t// class didn't end\n\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t}\n\t\t\t\tnameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:])\n\n\t\t\t\tmatched := false\n\t\t\t\tnegate := pattern[patIdx] == '!' || pattern[patIdx] == '^'\n\t\t\t\tif negate {\n\t\t\t\t\tpatIdx++\n\t\t\t\t}\n\n\t\t\t\tif patIdx >= patLen || pattern[patIdx] == ']' {\n\t\t\t\t\t// class didn't end or empty character class\n\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t}\n\n\t\t\t\tlast := utf8.MaxRune\n\t\t\t\tfor patIdx < patLen && pattern[patIdx] != ']' {\n\t\t\t\t\tpatRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:])\n\t\t\t\t\tpatIdx += patRuneLen\n\n\t\t\t\t\t// match a range\n\t\t\t\t\tif last < utf8.MaxRune && patRune == '-' && patIdx < patLen && pattern[patIdx] != ']' {\n\t\t\t\t\t\tif pattern[patIdx] == '\\\\' {\n\t\t\t\t\t\t\t// next character is escaped\n\t\t\t\t\t\t\tpatIdx++\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpatRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:])\n\t\t\t\t\t\tpatIdx += patRuneLen\n\n\t\t\t\t\t\tif last <= nameRune && nameRune <= patRune {\n\t\t\t\t\t\t\tmatched = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// didn't match range - reset `last`\n\t\t\t\t\t\tlast = utf8.MaxRune\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\t// not a range - check if the next rune is escaped\n\t\t\t\t\tif patRune == '\\\\' {\n\t\t\t\t\t\tpatRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:])\n\t\t\t\t\t\tpatIdx += patRuneLen\n\t\t\t\t\t}\n\n\t\t\t\t\t// check if the rune matches\n\t\t\t\t\tif patRune == nameRune {\n\t\t\t\t\t\tmatched = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\n\t\t\t\t\t// no matches yet\n\t\t\t\t\tlast = patRune\n\t\t\t\t}\n\n\t\t\t\tif matched == negate {\n\t\t\t\t\t// failed to match - if we reached the end of the pattern, that means\n\t\t\t\t\tif patIdx >= patLen {\n\t\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tclosingIdx := findUnescapedByteIndex(pattern[patIdx:], ']', true)\n\t\t\t\tif closingIdx == -1 {\n\t\t\t\t\t// no closing `]`\n\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t}\n\n\t\t\t\tpatIdx += closingIdx + 1\n\t\t\t\tnameIdx += nameRuneLen\n\t\t\t\tcontinue\n\t\t\tcase '!':\n\t\t\t\tnegateIdx := patIdx\n\t\t\t\t// begin index of (\n\t\t\t\tpatIdx++\n\t\t\t\tclosingIdx := findMatchedClosingBracketIndex(pattern[patIdx:], separator != '\\\\')\n\t\t\t\tif closingIdx == -1 {\n\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t}\n\t\t\t\tclosingIdx += patIdx\n\n\t\t\t\tresult, err := doMatchWithSeparator(pattern[:negateIdx]+pattern[patIdx+1:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, negateIdx, nameIdx)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn false, err\n\t\t\t\t} else if !result {\n\t\t\t\t\treturn true, nil\n\t\t\t\t} else {\n\t\t\t\t\treturn false, nil\n\t\t\t\t}\n\t\t\tcase '{':\n\t\t\t\tstartOfSegment = false //nolint:ineffassign\n\t\t\t\tbeforeIdx := patIdx\n\t\t\t\tpatIdx++\n\t\t\t\tclosingIdx := findMatchedClosingAltIndex(pattern[patIdx:], separator != '\\\\')\n\t\t\t\tif closingIdx == -1 {\n\t\t\t\t\t// no closing `}`\n\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t}\n\t\t\t\tclosingIdx += patIdx\n\n\t\t\t\tfor {\n\t\t\t\t\tcommaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\\\')\n\t\t\t\t\tif commaIdx == -1 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tcommaIdx += patIdx\n\n\t\t\t\t\tresult, err := doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:commaIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)\n\t\t\t\t\tif result || err != nil {\n\t\t\t\t\t\treturn result, err\n\t\t\t\t\t}\n\n\t\t\t\t\tpatIdx = commaIdx + 1\n\t\t\t\t}\n\t\t\t\treturn doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx)\n\n\t\t\tcase '\\\\':\n\t\t\t\tif separator != '\\\\' {\n\t\t\t\t\t// next rune is \"escaped\" in the pattern - literal match\n\t\t\t\t\tif patIdx++; patIdx >= patLen {\n\t\t\t\t\t\t// pattern ended\n\t\t\t\t\t\treturn false, globutil.ErrBadPattern\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tfallthrough\n\n\t\t\tdefault:\n\t\t\t\tpatRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:])\n\t\t\t\tnameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:])\n\t\t\t\tif patRune != nameRune {\n\t\t\t\t\tif separator != '\\\\' && patIdx > 0 && pattern[patIdx-1] == '\\\\' {\n\t\t\t\t\t\t// if this rune was meant to be escaped, we need to move patIdx\n\t\t\t\t\t\tpatIdx--\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\n\t\t\t\tpatIdx += patRuneLen\n\t\t\t\tnameIdx += nameRuneLen\n\t\t\t\tstartOfSegment = patRune == separator\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif starPatternBacktrack >= 0 {\n\t\t\t// `*` backtrack, but only if the `name` rune isn't the separator\n\t\t\tnameRune, nameRuneLen := utf8.DecodeRuneInString(name[starNameBacktrack:])\n\t\t\tif nameRune != separator {\n\t\t\t\tstarNameBacktrack += nameRuneLen\n\t\t\t\tpatIdx = starPatternBacktrack\n\t\t\t\tnameIdx = starNameBacktrack\n\t\t\t\tstartOfSegment = false\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif doublestarPatternBacktrack >= 0 {\n\t\t\t// `**` backtrack, advance `name` past next separator\n\t\t\tnameIdx = doublestarNameBacktrack\n\t\t\tfor nameIdx < nameLen {\n\t\t\t\tnameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:])\n\t\t\t\tnameIdx += nameRuneLen\n\t\t\t\tif nameRune == separator {\n\t\t\t\t\tdoublestarNameBacktrack = nameIdx\n\t\t\t\t\tpatIdx = doublestarPatternBacktrack\n\t\t\t\t\tstartOfSegment = true\n\t\t\t\t\tcontinue MATCH\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif validate && patIdx < patLen && !isValidPattern(pattern[patIdx:], separator) {\n\t\t\treturn false, globutil.ErrBadPattern\n\t\t}\n\t\treturn false, nil\n\t}\n\n\tif nameIdx < nameLen {\n\t\t// we reached the end of `pattern` before the end of `name`\n\t\treturn false, nil\n\t}\n\n\t// we've reached the end of `name`; we've successfully matched if we've also\n\treturn isZeroLengthPattern(pattern[patIdx:], separator)\n}\n\n// nolint:nakedret\nfunc isZeroLengthPattern(pattern string, separator rune) (ret bool, err error) {\n\t// `/**` is a special case - a pattern such as `path/to/a/**` *should* match\n\tif pattern == \"\" || pattern == \"*\" || pattern == \"**\" || pattern == string(separator)+\"**\" {\n\t\treturn true, nil\n\t}\n\n\tif pattern[0] == '{' {\n\t\tclosingIdx := findMatchedClosingAltIndex(pattern[1:], separator != '\\\\')\n\t\tif closingIdx == -1 {\n\t\t\t// no closing '}'\n\t\t\treturn false, globutil.ErrBadPattern\n\t\t}\n\t\tclosingIdx += 1\n\n\t\tpatIdx := 1\n\t\tfor {\n\t\t\tcommaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\\\')\n\t\t\tif commaIdx == -1 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcommaIdx += patIdx\n\n\t\t\tret, err = isZeroLengthPattern(pattern[patIdx:commaIdx]+pattern[closingIdx+1:], separator)\n\t\t\tif ret || err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tpatIdx = commaIdx + 1\n\t\t}\n\t\treturn isZeroLengthPattern(pattern[patIdx:closingIdx]+pattern[closingIdx+1:], separator)\n\t}\n\n\t// no luck - validate the rest of the pattern\n\tif !isValidPattern(pattern, separator) {\n\t\treturn false, globutil.ErrBadPattern\n\t}\n\treturn false, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/util/glob/match_benchmark_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage glob\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc BenchmarkPathMatch(b *testing.B) {\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\tfor _, tt := range matchTests {\n\t\t\tif tt.isStandard && tt.testOnDisk {\n\t\t\t\tpattern := filepath.FromSlash(tt.pattern)\n\t\t\t\ttestPath := filepath.FromSlash(tt.testPath)\n\t\t\t\tPathMatch(pattern, testPath)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/util/glob/match_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\n// This code is based on or derived from doublestar\n// Copyright (c) 2014 Bob Matcuk\n// Licensed under MIT License\n// https://github.com/bmatcuk/doublestar/blob/master/LICENSE\n\npackage glob\n\nimport (\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\tglobutil \"github.com/bmatcuk/doublestar/v4\"\n)\n\ntype MatchTest struct {\n\tpattern, testPath     string\n\tshouldMatch           bool\n\tshouldMatchGlob       bool\n\texpectedErr           error\n\texpectIOErr           bool\n\texpectPatternNotExist bool\n\tisStandard            bool\n\ttestOnDisk            bool\n\tnumResults            int\n\twinNumResults         int\n}\n\n// Tests which contain escapes and symlinks will not work on Windows\nvar onWindows = runtime.GOOS == \"windows\"\n\nvar matchTests = []MatchTest{\n\t{\"\", \"\", true, false, nil, true, false, true, true, 0, 0},\n\t{\"*\", \"\", true, true, nil, false, false, true, false, 0, 0},\n\t{\"*\", \"/\", false, false, nil, false, false, true, false, 0, 0},\n\t{\"/*\", \"/\", true, true, nil, false, false, true, false, 0, 0},\n\t{\"/*\", \"/debug/\", false, false, nil, false, false, true, false, 0, 0},\n\t{\"/*\", \"//\", false, false, nil, false, false, true, false, 0, 0},\n\t{\"abc\", \"abc\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"*\", \"abc\", true, true, nil, false, false, true, true, 22, 17},\n\t{\"*c\", \"abc\", true, true, nil, false, false, true, true, 2, 2},\n\t{\"*/\", \"a/\", true, true, nil, false, false, true, false, 0, 0},\n\t{\"a*\", \"a\", true, true, nil, false, false, true, true, 9, 9},\n\t{\"a*\", \"abc\", true, true, nil, false, false, true, true, 9, 9},\n\t{\"a*\", \"ab/c\", false, false, nil, false, false, true, true, 9, 9},\n\t{\"a*/b\", \"abc/b\", true, true, nil, false, false, true, true, 2, 2},\n\t{\"a*/b\", \"a/c/b\", false, false, nil, false, false, true, true, 2, 2},\n\t{\"a*/c/\", \"a/b\", false, false, nil, false, false, false, true, 1, 1},\n\t{\"a*b*c*d*e*\", \"axbxcxdxe\", true, true, nil, false, false, true, true, 3, 3},\n\t{\"a*b*c*d*e*/f\", \"axbxcxdxe/f\", true, true, nil, false, false, true, true, 2, 2},\n\t{\"a*b*c*d*e*/f\", \"axbxcxdxexxx/f\", true, true, nil, false, false, true, true, 2, 2},\n\t{\"a*b*c*d*e*/f\", \"axbxcxdxe/xxx/f\", false, false, nil, false, false, true, true, 2, 2},\n\t{\"a*b*c*d*e*/f\", \"axbxcxdxexxx/fff\", false, false, nil, false, false, true, true, 2, 2},\n\t{\"a*b?c*x\", \"abxbbxdbxebxczzx\", true, true, nil, false, false, true, true, 2, 2},\n\t{\"a*b?c*x\", \"abxbbxdbxebxczzy\", false, false, nil, false, false, true, true, 2, 2},\n\t{\"ab[c]\", \"abc\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"ab[b-d]\", \"abc\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"ab[e-g]\", \"abc\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"ab[^c]\", \"abc\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"ab[^b-d]\", \"abc\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"ab[^e-g]\", \"abc\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"a\\\\*b\", \"ab\", false, false, nil, false, true, true, !onWindows, 0, 0},\n\t{\"a?b\", \"a☺b\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"a[^a]b\", \"a☺b\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"a[!a]b\", \"a☺b\", true, true, nil, false, false, false, true, 1, 1},\n\t{\"a???b\", \"a☺b\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"a[^a][^a][^a]b\", \"a☺b\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"[a-ζ]*\", \"α\", true, true, nil, false, false, true, true, 20, 17},\n\t{\"*[a-ζ]\", \"A\", false, false, nil, false, false, true, true, 20, 17},\n\t{\"a?b\", \"a/b\", false, false, nil, false, false, true, true, 1, 1},\n\t{\"a*b\", \"a/b\", false, false, nil, false, false, true, true, 1, 1},\n\t{\"[\\\\]a]\", \"]\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[\\\\-]\", \"-\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"[x\\\\-]\", \"x\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[x\\\\-]\", \"-\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[x\\\\-]\", \"z\", false, false, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[\\\\-x]\", \"x\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[\\\\-x]\", \"-\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[\\\\-x]\", \"a\", false, false, nil, false, false, true, !onWindows, 2, 2},\n\t{\"[]a]\", \"]\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t// doublestar, like bash, allows these when path.Match() does not\n\t{\"[-]\", \"-\", true, true, nil, false, false, false, !onWindows, 1, 0},\n\t{\"[x-]\", \"x\", true, true, nil, false, false, false, true, 2, 1},\n\t{\"[x-]\", \"-\", true, true, nil, false, false, false, !onWindows, 2, 1},\n\t{\"[x-]\", \"z\", false, false, nil, false, false, false, true, 2, 1},\n\t{\"[-x]\", \"x\", true, true, nil, false, false, false, true, 2, 1},\n\t{\"[-x]\", \"-\", true, true, nil, false, false, false, !onWindows, 2, 1},\n\t{\"[-x]\", \"a\", false, false, nil, false, false, false, true, 2, 1},\n\t{\"[a-b-d]\", \"a\", true, true, nil, false, false, false, true, 3, 2},\n\t{\"[a-b-d]\", \"b\", true, true, nil, false, false, false, true, 3, 2},\n\t{\"[a-b-d]\", \"-\", true, true, nil, false, false, false, !onWindows, 3, 2},\n\t{\"[a-b-d]\", \"c\", false, false, nil, false, false, false, true, 3, 2},\n\t{\"[a-b-x]\", \"x\", true, true, nil, false, false, false, true, 4, 3},\n\t{\"\\\\\", \"a\", false, false, globutil.ErrBadPattern, false, false, true, !onWindows, 0, 0},\n\t{\"[\", \"a\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"[^\", \"a\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"[^bc\", \"a\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"a[\", \"a\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"a[\", \"ab\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"ad[\", \"ab\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"*x\", \"xxx\", true, true, nil, false, false, true, true, 4, 4},\n\t{\"[abc]\", \"b\", true, true, nil, false, false, true, true, 3, 3},\n\t{\"**\", \"\", true, true, nil, false, false, false, false, 38, 38},\n\t{\"a/**\", \"a\", true, false, nil, false, false, false, true, 7, 7},\n\t{\"a/**\", \"a/\", true, true, nil, false, false, false, false, 7, 7},\n\t{\"a/**/\", \"a/\", true, true, nil, false, false, false, false, 4, 4},\n\t{\"a/**\", \"a/b\", true, true, nil, false, false, false, true, 7, 7},\n\t{\"a/**\", \"a/b/c\", true, true, nil, false, false, false, true, 7, 7},\n\t{\"**/c\", \"c\", true, true, nil, !onWindows, false, false, true, 5, 4},\n\t{\"**/c\", \"b/c\", true, true, nil, !onWindows, false, false, true, 5, 4},\n\t{\"**/c\", \"a/b/c\", true, true, nil, !onWindows, false, false, true, 5, 4},\n\t{\"**/c\", \"a/b\", false, false, nil, !onWindows, false, false, true, 5, 4},\n\t{\"**/c\", \"abcd\", false, false, nil, !onWindows, false, false, true, 5, 4},\n\t{\"**/c\", \"a/abc\", false, false, nil, !onWindows, false, false, true, 5, 4},\n\t{\"a/**/b\", \"a/b\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a/**/c\", \"a/b/c\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a/**/d\", \"a/b/c/d\", true, true, nil, false, false, false, true, 1, 1},\n\t{\"a/\\\\**\", \"a/b/c\", false, false, nil, false, false, false, !onWindows, 0, 0},\n\t{\"a/\\\\[*\\\\]\", \"a/bc\", false, false, nil, false, false, true, !onWindows, 0, 0},\n\t// this fails the FilepathGlob test on Windows\n\t{\"a/b/c\", \"a/b//c\", false, false, nil, false, false, true, !onWindows, 1, 1},\n\t// odd: Glob + filepath.Glob return results\n\t{\"a/\", \"a\", false, false, nil, false, false, true, false, 0, 0},\n\t{\"ab{c,d}\", \"abc\", true, true, nil, false, true, false, true, 1, 1},\n\t{\"ab{c,d,*}\", \"abcde\", true, true, nil, false, true, false, true, 5, 5},\n\t{\"ab{c,d}[\", \"abcd\", false, false, globutil.ErrBadPattern, false, false, false, true, 0, 0},\n\t{\"a{,bc}\", \"a\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a{,bc}\", \"abc\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a/{b/c,c/b}\", \"a/b/c\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a/{b/c,c/b}\", \"a/c/b\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"a/a*{b,c}\", \"a/abc\", true, true, nil, false, false, false, true, 1, 1},\n\t{\"{a/{b,c},abc}\", \"a/b\", true, true, nil, false, false, false, true, 3, 3},\n\t{\"{a/{b,c},abc}\", \"a/c\", true, true, nil, false, false, false, true, 3, 3},\n\t{\"{a/{b,c},abc}\", \"abc\", true, true, nil, false, false, false, true, 3, 3},\n\t{\"{a/{b,c},abc}\", \"a/b/c\", false, false, nil, false, false, false, true, 3, 3},\n\t{\"{a/ab*}\", \"a/abc\", true, true, nil, false, false, false, true, 1, 1},\n\t{\"{a/*}\", \"a/b\", true, true, nil, false, false, false, true, 3, 3},\n\t{\"{a/abc}\", \"a/abc\", true, true, nil, false, false, false, true, 1, 1},\n\t{\"{a/b,a/c}\", \"a/c\", true, true, nil, false, false, false, true, 2, 2},\n\t{\"abc/**\", \"abc/b\", true, true, nil, false, false, false, true, 3, 3},\n\t{\"**/abc\", \"abc\", true, true, nil, !onWindows, false, false, true, 2, 2},\n\t{\"abc**\", \"abc/b\", false, false, nil, false, false, false, true, 3, 3},\n\t{\"**/*.txt\", \"abc/ßtestß.txt\", true, true, nil, !onWindows, false, false, true, 1, 1},\n\t{\"**/ß*\", \"abc/ßtestß.txt\", true, true, nil, !onWindows, false, false, true, 1, 1},\n\t{\"**/{a,b}\", \"a/b\", true, true, nil, !onWindows, false, false, true, 5, 5},\n\t// unfortunately, io/fs can't handle this, so neither can Glob =(\n\t{\"broken-symlink\", \"broken-symlink\", true, true, nil, false, false, true, false, 1, 1},\n\t{\"broken-symlink/*\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"broken*/*\", \"a\", false, false, nil, false, false, true, true, 0, 0},\n\t{\"working-symlink/c/*\", \"working-symlink/c/d\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"working-sym*/*\", \"working-symlink/c\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"b/**/f\", \"b/symlink-dir/f\", true, true, nil, false, false, false, !onWindows, 2, 2},\n\t{\"*/symlink-dir/*\", \"b/symlink-dir/f\", true, true, nil, !onWindows, false, true, !onWindows, 2, 2},\n\t{\"e/**\", \"e/**\", true, true, nil, false, false, false, !onWindows, 11, 6},\n\t{\"e/**\", \"e/*\", true, true, nil, false, false, false, !onWindows, 11, 6},\n\t{\"e/**\", \"e/?\", true, true, nil, false, false, false, !onWindows, 11, 6},\n\t{\"e/**\", \"e/[\", true, true, nil, false, false, false, true, 11, 6},\n\t{\"e/**\", \"e/]\", true, true, nil, false, false, false, true, 11, 6},\n\t{\"e/**\", \"e/[]\", true, true, nil, false, false, false, true, 11, 6},\n\t{\"e/**\", \"e/{\", true, true, nil, false, false, false, true, 11, 6},\n\t{\"e/**\", \"e/}\", true, true, nil, false, false, false, true, 11, 6},\n\t{\"e/**\", \"e/\\\\\", true, true, nil, false, false, false, !onWindows, 11, 6},\n\t{\"e/*\", \"e/*\", true, true, nil, false, false, true, !onWindows, 10, 5},\n\t{\"e/?\", \"e/?\", true, true, nil, false, false, true, !onWindows, 7, 4},\n\t{\"e/?\", \"e/*\", true, true, nil, false, false, true, !onWindows, 7, 4},\n\t{\"e/?\", \"e/[\", true, true, nil, false, false, true, true, 7, 4},\n\t{\"e/?\", \"e/]\", true, true, nil, false, false, true, true, 7, 4},\n\t{\"e/?\", \"e/{\", true, true, nil, false, false, true, true, 7, 4},\n\t{\"e/?\", \"e/}\", true, true, nil, false, false, true, true, 7, 4},\n\t{\"e/\\\\[\", \"e/[\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/[\", \"e/[\", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0},\n\t{\"e/]\", \"e/]\", true, true, nil, false, false, true, true, 1, 1},\n\t{\"e/\\\\]\", \"e/]\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/\\\\{\", \"e/{\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/\\\\}\", \"e/}\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/[\\\\*\\\\?]\", \"e/*\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"e/[\\\\*\\\\?]\", \"e/?\", true, true, nil, false, false, true, !onWindows, 2, 2},\n\t{\"e/[\\\\*\\\\?]\", \"e/**\", false, false, nil, false, false, true, !onWindows, 2, 2},\n\t{\"e/[\\\\*\\\\?]?\", \"e/**\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/{\\\\*,\\\\?}\", \"e/*\", true, true, nil, false, false, false, !onWindows, 2, 2},\n\t{\"e/{\\\\*,\\\\?}\", \"e/?\", true, true, nil, false, false, false, !onWindows, 2, 2},\n\t{\"e/\\\\*\", \"e/*\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/\\\\?\", \"e/?\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"e/\\\\?\", \"e/**\", false, false, nil, false, false, true, !onWindows, 1, 1},\n\t{\"*\\\\}\", \"}\", true, true, nil, false, false, true, !onWindows, 1, 1},\n\t{\"nonexistent-path\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"nonexistent-path/\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"nonexistent-path/file\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"nonexistent-path/*\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"nonexistent-path/**\", \"a\", false, false, nil, false, true, true, true, 0, 0},\n\t{\"nopermission/*\", \"nopermission/file\", true, false, nil, true, false, true, !onWindows, 0, 0},\n\t{\"nopermission/dir/\", \"nopermission/dir\", false, false, nil, true, false, true, !onWindows, 0, 0},\n\t{\"nopermission/file\", \"nopermission/file\", true, false, nil, true, false, true, !onWindows, 0, 0},\n\t{\"node_modules/!(.cache)/**\", \"node_modules/others/file.txt\", true, true, nil, false, false, false, !onWindows, 0, 0},\n\t{\"node_modules/!(.cache)/**\", \"node_modules/.cache/file.txt\", false, false, nil, false, false, false, !onWindows, 0, 0},\n\t{\"node_modules/!(.cache)/**\", \"node_modules/file.txt\", true, false, nil, false, false, false, !onWindows, 0, 0},\n\t{\"node_modules/!(.cache)/**\", \"node_modules/others/others/file.txt\", true, true, nil, false, false, false, !onWindows, 0, 0},\n}\n\n// numResultsFilesOnly memoizes results with WithFilesOnly.\nvar numResultsFilesOnly []int\n\n// numResultsNoFollow memoizes results with WithNoFollow.\nvar numResultsNoFollow []int\n\n// numResultsAllOpts memoizes counts with every option enabled.\nvar numResultsAllOpts []int\n\nfunc TestValidatePattern(t *testing.T) {\n\tfor idx, tt := range matchTests {\n\t\ttestValidatePatternWith(t, idx, tt)\n\t}\n}\n\nfunc testValidatePatternWith(t *testing.T, idx int, tt MatchTest) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tt.Errorf(\"#%v. Validate(%#q) panicked: %#v\", idx, tt.pattern, r)\n\t\t}\n\t}()\n\n\tresult := isValidPattern(tt.pattern, '/')\n\tif result != (tt.expectedErr == nil) {\n\t\tt.Errorf(\"#%v. ValidatePattern(%#q) = %v want %v\", idx, tt.pattern, result, !result)\n\t}\n}\n\nfunc TestPathMatch(t *testing.T) {\n\tfor idx, tt := range matchTests {\n\t\t// Even though we aren't actually matching paths on disk, we are using\n\t\tif tt.testOnDisk {\n\t\t\ttestPathMatchWith(t, idx, tt)\n\t\t}\n\t}\n}\n\nfunc testPathMatchWith(t *testing.T, idx int, tt MatchTest) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tt.Errorf(\"#%v. Match(%#q, %#q) panicked: %#v\", idx, tt.pattern, tt.testPath, r)\n\t\t}\n\t}()\n\n\tpattern := filepath.FromSlash(tt.pattern)\n\ttestPath := filepath.FromSlash(tt.testPath)\n\tok, err := PathMatch(pattern, testPath)\n\tif ok != tt.shouldMatch || err != tt.expectedErr {\n\t\tt.Errorf(\"#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v\", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr)\n\t}\n\n\tif tt.isStandard {\n\t\tstdOk, stdErr := filepath.Match(pattern, testPath)\n\t\tif ok != stdOk || !compareErrors(err, stdErr) {\n\t\t\tt.Errorf(\"#%v. PathMatch(%#q, %#q) != filepath.Match(...). Got %v, %v want %v, %v\", idx, pattern, testPath, ok, err, stdOk, stdErr)\n\t\t}\n\t}\n}\n\nfunc TestPathMatchFake(t *testing.T) {\n\t// This test fakes that our path separator is `\\\\` so we can test what it\n\tif onWindows {\n\t\treturn\n\t}\n\n\tfor idx, tt := range matchTests {\n\t\t// Even though we aren't actually matching paths on disk, we are using\n\t\tif tt.testOnDisk && !strings.Contains(tt.pattern, \"\\\\\") {\n\t\t\ttestPathMatchFakeWith(t, idx, tt)\n\t\t}\n\t}\n}\n\nfunc testPathMatchFakeWith(t *testing.T, idx int, tt MatchTest) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tt.Errorf(\"#%v. Match(%#q, %#q) panicked: %#v\", idx, tt.pattern, tt.testPath, r)\n\t\t}\n\t}()\n\n\tpattern := strings.ReplaceAll(tt.pattern, \"/\", \"\\\\\")\n\ttestPath := strings.ReplaceAll(tt.testPath, \"/\", \"\\\\\")\n\tok, err := matchWithSeparator(pattern, testPath, '\\\\', true)\n\tif ok != tt.shouldMatch || err != tt.expectedErr {\n\t\tt.Errorf(\"#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v\", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr)\n\t}\n}\n\nfunc compareErrors(a, b error) bool {\n\tif a == nil {\n\t\treturn b == nil\n\t}\n\treturn b != nil\n}\n"
  },
  {
    "path": "components/execd/pkg/util/glob/pattern.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage glob\n\n// isValidPattern checks whether a glob pattern is well-formed.\n//\n//nolint:gocognit\nfunc isValidPattern(s string, separator rune) bool {\n\taltDepth := 0\n\tl := len(s)\nVALIDATE:\n\tfor i := 0; i < l; i++ {\n\t\tswitch s[i] {\n\t\tcase '\\\\':\n\t\t\tif separator != '\\\\' {\n\t\t\t\tif i++; i >= l {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\n\t\tcase '[':\n\t\t\tif i++; i >= l {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif s[i] == '^' || s[i] == '!' {\n\t\t\t\ti++\n\t\t\t}\n\t\t\tif i >= l || s[i] == ']' {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tfor ; i < l; i++ {\n\t\t\t\tif separator != '\\\\' && s[i] == '\\\\' {\n\t\t\t\t\ti++\n\t\t\t\t} else if s[i] == ']' {\n\t\t\t\t\tcontinue VALIDATE\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn false\n\n\t\tcase '{':\n\t\t\taltDepth++\n\t\t\tcontinue\n\n\t\tcase '}':\n\t\t\tif altDepth == 0 {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\taltDepth--\n\t\t\tcontinue\n\t\t}\n\t}\n\n\treturn altDepth == 0\n}\n"
  },
  {
    "path": "components/execd/pkg/util/safego/safe.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage safego\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"net/http\"\n\t\"runtime\"\n\n\truntimeutil \"k8s.io/apimachinery/pkg/util/runtime\"\n)\n\nfunc InitPanicLogger(_ context.Context) {\n\truntimeutil.PanicHandlers = []func(context.Context, any){\n\t\tfunc(_ context.Context, r any) {\n\t\t\tif r == http.ErrAbortHandler { // nolint:errorlint\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst size = 64 << 10\n\t\t\tstacktrace := make([]byte, size)\n\t\t\tstacktrace = stacktrace[:runtime.Stack(stacktrace, false)]\n\t\t\tif _, ok := r.(string); ok {\n\t\t\t\tlog.Printf(\"Observed a panic: %s\\n%s\", r, stacktrace)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"Observed a panic: %#v (%v)\\n%s\", r, r, stacktrace)\n\t\t\t}\n\t\t},\n\t}\n}\n\nfunc init() {\n\truntimeutil.ReallyCrash = false\n}\n\nfunc Go(f func()) {\n\tgo func() {\n\t\tdefer runtimeutil.HandleCrash()\n\n\t\tf()\n\t}()\n}\n"
  },
  {
    "path": "components/execd/pkg/util/safego/safe_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage safego\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc Test_Go(t *testing.T) {\n\tctx, cancelFunc := context.WithCancel(context.Background())\n\tdefer cancelFunc()\n\n\tInitPanicLogger(ctx)\n\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tGo(func() {\n\t\tdefer wg.Done()\n\t\tpanic(\"I'm done\")\n\t})\n\twg.Wait()\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/basic.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\ntype basicController struct {\n\tctx *gin.Context\n}\n\nfunc newBasicController(ctx *gin.Context) *basicController {\n\treturn &basicController{ctx: ctx}\n}\n\nfunc (c *basicController) RespondError(status int, code model.ErrorCode, message ...string) {\n\tresp := model.ErrorResponse{\n\t\tCode:    code,\n\t\tMessage: \"\",\n\t}\n\tif len(message) > 0 {\n\t\tresp.Message = message[0]\n\t}\n\tc.ctx.JSON(status, resp)\n}\n\nfunc (c *basicController) RespondSuccess(data any) {\n\tif data == nil {\n\t\tc.ctx.Status(http.StatusOK)\n\t\treturn\n\t}\n\tc.ctx.JSON(http.StatusOK, data)\n}\n\nfunc (c *basicController) QueryInt64(query string, defaultValue int64) int64 {\n\tval, err := strconv.ParseInt(query, 10, 64)\n\tif err != nil {\n\t\treturn defaultValue\n\t}\n\treturn val\n}\n\nfunc (c *basicController) bindJSON(target any) error {\n\tdecoder := json.NewDecoder(c.ctx.Request.Body)\n\treturn decoder.Decode(target)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/basic_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBasicControllerRespondSuccess(t *testing.T) {\n\tctx, rec := newTestContext(http.MethodGet, \"/\", nil)\n\tctrl := &basicController{ctx: ctx}\n\n\tpayload := map[string]string{\"status\": \"ok\"}\n\tctrl.RespondSuccess(payload)\n\n\trequire.Equal(t, http.StatusOK, rec.Code)\n\tvar resp map[string]string\n\trequire.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))\n\trequire.Equal(t, \"ok\", resp[\"status\"])\n}\n\nfunc TestBasicControllerRespondError(t *testing.T) {\n\tctx, rec := newTestContext(http.MethodGet, \"/\", nil)\n\tctrl := &basicController{ctx: ctx}\n\n\tctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, \"boom\")\n\n\trequire.Equal(t, http.StatusBadRequest, rec.Code)\n\tvar resp model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))\n\trequire.Equal(t, model.ErrorCodeInvalidRequest, resp.Code)\n\trequire.Equal(t, \"boom\", resp.Message)\n}\n\nfunc setupBasicController(method string) (*basicController, *httptest.ResponseRecorder) {\n\tctx, w := newTestContext(method, \"/\", nil)\n\tctrl := &basicController{ctx: ctx}\n\treturn ctrl, w\n}\n\nfunc TestRespondSuccessWritesPayload(t *testing.T) {\n\tctrl, w := setupBasicController(http.MethodGet)\n\n\tpayload := map[string]string{\"status\": \"ok\"}\n\tctrl.RespondSuccess(payload)\n\n\trequire.Equal(t, http.StatusOK, w.Code)\n\tvar got map[string]string\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))\n\trequire.Equal(t, \"ok\", got[\"status\"])\n}\n\nfunc TestRespondErrorAddsCodeAndMessage(t *testing.T) {\n\tctrl, w := setupBasicController(http.MethodGet)\n\n\tctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, \"invalid payload\")\n\n\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\tvar got model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))\n\trequire.Equal(t, model.ErrorCodeInvalidRequest, got.Code)\n\trequire.Equal(t, \"invalid payload\", got.Message)\n}\n\nfunc TestQueryInt64(t *testing.T) {\n\tctrl := &basicController{}\n\n\ttests := []struct {\n\t\tname     string\n\t\tquery    string\n\t\tdef      int64\n\t\texpected int64\n\t}{\n\t\t{name: \"valid number\", query: \"42\", def: 0, expected: 42},\n\t\t{name: \"empty uses default\", query: \"\", def: 5, expected: 5},\n\t\t{name: \"invalid uses default\", query: \"not-a-number\", def: -1, expected: -1},\n\t\t{name: \"negative number\", query: \"-10\", def: 0, expected: -10},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ctrl.QueryInt64(tt.query, tt.def)\n\t\t\trequire.Equalf(t, tt.expected, got, \"QueryInt64(%q, %d)\", tt.query, tt.def)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/codeinterpreting.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/flag\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\nvar codeRunner *runtime.Controller\n\nfunc InitCodeRunner() {\n\tcodeRunner = runtime.NewController(flag.JupyterServerHost, flag.JupyterServerToken)\n}\n\n// CodeInterpretingController handles code execution entrypoints.\ntype CodeInterpretingController struct {\n\t*basicController\n\n\t// chunkWriter serializes SSE event writes to prevent interleaved output.\n\tchunkWriter sync.Mutex\n}\n\nfunc NewCodeInterpretingController(ctx *gin.Context) *CodeInterpretingController {\n\treturn &CodeInterpretingController{\n\t\tbasicController: newBasicController(ctx),\n\t}\n}\n\n// CreateContext creates a new code execution context.\nfunc (c *CodeInterpretingController) CreateContext() {\n\tvar request model.CodeContextRequest\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tsession, err := codeRunner.CreateContext(&runtime.CreateContextRequest{\n\t\tLanguage: runtime.Language(request.Language),\n\t\tCwd:      request.Cwd,\n\t})\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error creating code context. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tresp := model.CodeContext{\n\t\tID:                 session,\n\t\tCodeContextRequest: request,\n\t}\n\tc.RespondSuccess(resp)\n}\n\n// InterruptCode interrupts the execution of running code in a session.\nfunc (c *CodeInterpretingController) InterruptCode() {\n\tc.interrupt()\n}\n\n// RunCode executes code in a context and streams output via SSE.\nfunc (c *CodeInterpretingController) RunCode() {\n\tvar request model.RunCodeRequest\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\terr := request.Validate()\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"invalid request, validation error %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithCancel(c.ctx.Request.Context())\n\tdefer cancel()\n\trunCodeRequest := c.buildExecuteCodeRequest(request)\n\teventsHandler := c.setServerEventsHandler(ctx)\n\trunCodeRequest.Hooks = eventsHandler\n\n\tc.setupSSEResponse()\n\terr = codeRunner.Execute(runCodeRequest)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error running codes %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\ttime.Sleep(flag.ApiGracefulShutdownTimeout)\n}\n\n// GetContext returns a specific code context by id.\nfunc (c *CodeInterpretingController) GetContext() {\n\tcontextID := c.ctx.Param(\"contextId\")\n\tif contextID == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing path parameter 'contextId'\",\n\t\t)\n\t\treturn\n\t}\n\n\tcodeContext, err := codeRunner.GetContext(contextID)\n\tif err != nil {\n\t\tif errors.Is(err, runtime.ErrContextNotFound) {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusNotFound,\n\t\t\t\tmodel.ErrorCodeContextNotFound,\n\t\t\t\tfmt.Sprintf(\"context %s not found\", contextID),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error getting code context %s. %v\", contextID, err),\n\t\t)\n\t\treturn\n\t}\n\tc.RespondSuccess(codeContext)\n}\n\n// ListContexts returns active code contexts, optionally filtered by language.\nfunc (c *CodeInterpretingController) ListContexts() {\n\tlanguage := c.ctx.Query(\"language\")\n\n\tcontexts, err := codeRunner.ListContext(language)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\terr.Error(),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(contexts)\n}\n\n// DeleteContextsByLanguage deletes all contexts for a given language.\nfunc (c *CodeInterpretingController) DeleteContextsByLanguage() {\n\tlanguage := c.ctx.Query(\"language\")\n\tif language == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing query parameter 'language'\",\n\t\t)\n\t\treturn\n\t}\n\n\terr := codeRunner.DeleteLanguageContext(runtime.Language(language))\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error deleting code context %s. %v\", language, err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// DeleteContext deletes a specific code context by id.\nfunc (c *CodeInterpretingController) DeleteContext() {\n\tcontextID := c.ctx.Param(\"contextId\")\n\tif contextID == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing path parameter 'contextId'\",\n\t\t)\n\t\treturn\n\t}\n\n\terr := codeRunner.DeleteContext(contextID)\n\tif err != nil {\n\t\tif errors.Is(err, runtime.ErrContextNotFound) {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusNotFound,\n\t\t\t\tmodel.ErrorCodeContextNotFound,\n\t\t\t\tfmt.Sprintf(\"context %s not found\", contextID),\n\t\t\t)\n\t\t\treturn\n\t\t} else {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error deleting code context %s. %v\", contextID, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// CreateSession creates a new bash session (create_session API).\n// An empty body is allowed and is treated as default options (no cwd override).\nfunc (c *CodeInterpretingController) CreateSession() {\n\tvar request model.CreateSessionRequest\n\tif err := c.bindJSON(&request); err != nil && !errors.Is(err, io.EOF) {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tsessionID, err := codeRunner.CreateBashSession(&runtime.CreateContextRequest{\n\t\tCwd: request.Cwd,\n\t})\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error creating session. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(model.CreateSessionResponse{SessionID: sessionID})\n}\n\n// RunInSession runs code in an existing bash session and streams output via SSE (run_in_session API).\nfunc (c *CodeInterpretingController) RunInSession() {\n\tsessionID := c.ctx.Param(\"sessionId\")\n\tif sessionID == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing path parameter 'sessionId'\",\n\t\t)\n\t\treturn\n\t}\n\n\tvar request model.RunInSessionRequest\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\tif err := request.Validate(); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"invalid request. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\ttimeout := time.Duration(request.TimeoutMs) * time.Millisecond\n\trunReq := &runtime.ExecuteCodeRequest{\n\t\tLanguage: runtime.Bash,\n\t\tContext:  sessionID,\n\t\tCode:     request.Code,\n\t\tCwd:      request.Cwd,\n\t\tTimeout:  timeout,\n\t}\n\tctx, cancel := context.WithCancel(c.ctx.Request.Context())\n\tdefer cancel()\n\trunReq.Hooks = c.setServerEventsHandler(ctx)\n\n\tc.setupSSEResponse()\n\terr := codeRunner.RunInBashSession(ctx, runReq)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error running in session. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\ttime.Sleep(flag.ApiGracefulShutdownTimeout)\n}\n\n// DeleteSession deletes a bash session (delete_session API).\nfunc (c *CodeInterpretingController) DeleteSession() {\n\tsessionID := c.ctx.Param(\"sessionId\")\n\tif sessionID == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing path parameter 'sessionId'\",\n\t\t)\n\t\treturn\n\t}\n\n\terr := codeRunner.DeleteBashSession(sessionID)\n\tif err != nil {\n\t\tif errors.Is(err, runtime.ErrContextNotFound) {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusNotFound,\n\t\t\t\tmodel.ErrorCodeContextNotFound,\n\t\t\t\tfmt.Sprintf(\"session %s not found\", sessionID),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error deleting session %s. %v\", sessionID, err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// buildExecuteCodeRequest converts a RunCodeRequest to runtime format.\nfunc (c *CodeInterpretingController) buildExecuteCodeRequest(request model.RunCodeRequest) *runtime.ExecuteCodeRequest {\n\treq := &runtime.ExecuteCodeRequest{\n\t\tLanguage: runtime.Language(request.Context.Language),\n\t\tCode:     request.Code,\n\t\tContext:  request.Context.ID,\n\t}\n\n\tif req.Language == \"\" {\n\t\treq.Language = runtime.Command\n\t}\n\n\treturn req\n}\n\nfunc (c *CodeInterpretingController) interrupt() {\n\tsession := c.ctx.Query(\"id\")\n\tif session == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing query parameter 'id'\",\n\t\t)\n\t\treturn\n\t}\n\n\terr := codeRunner.Interrupt(session)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error interruptting code context. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(nil)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/codeinterpreting_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBuildExecuteCodeRequestDefaultsToCommand(t *testing.T) {\n\tctrl := &CodeInterpretingController{}\n\treq := model.RunCodeRequest{\n\t\tCode: \"echo 1\",\n\t\tContext: model.CodeContext{\n\t\t\tID:                 \"session-1\",\n\t\t\tCodeContextRequest: model.CodeContextRequest{},\n\t\t},\n\t}\n\n\texecReq := ctrl.buildExecuteCodeRequest(req)\n\n\trequire.Equal(t, runtime.Command, execReq.Language, \"expected default language\")\n\trequire.Equal(t, \"session-1\", execReq.Context)\n\trequire.Equal(t, \"echo 1\", execReq.Code)\n}\n\nfunc TestBuildExecuteCodeRequestRespectsLanguage(t *testing.T) {\n\tctrl := &CodeInterpretingController{}\n\treq := model.RunCodeRequest{\n\t\tCode: \"print(1)\",\n\t\tContext: model.CodeContext{\n\t\t\tID: \"session-2\",\n\t\t\tCodeContextRequest: model.CodeContextRequest{\n\t\t\t\tLanguage: \"python\",\n\t\t\t},\n\t\t},\n\t}\n\n\texecReq := ctrl.buildExecuteCodeRequest(req)\n\n\trequire.Equal(t, runtime.Language(\"python\"), execReq.Language)\n}\n\nfunc TestGetContext_NotFoundReturns404(t *testing.T) {\n\tctx, w := newTestContext(http.MethodGet, \"/code/contexts/missing\", nil)\n\tctx.Params = append(ctx.Params, gin.Param{Key: \"contextId\", Value: \"missing\"})\n\tctrl := NewCodeInterpretingController(ctx)\n\n\tprevious := codeRunner\n\tcodeRunner = runtime.NewController(\"\", \"\")\n\tt.Cleanup(func() { codeRunner = previous })\n\n\tctrl.GetContext()\n\n\trequire.Equal(t, http.StatusNotFound, w.Code)\n\n\tvar resp model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))\n\trequire.Equal(t, model.ErrorCodeContextNotFound, resp.Code)\n\trequire.Equal(t, \"context missing not found\", resp.Message)\n}\n\nfunc TestGetContext_MissingIDReturns400(t *testing.T) {\n\tctx, w := newTestContext(http.MethodGet, \"/code/contexts/\", nil)\n\tctrl := NewCodeInterpretingController(ctx)\n\n\tctrl.GetContext()\n\n\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\n\tvar resp model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))\n\trequire.Equal(t, model.ErrorCodeMissingQuery, resp.Code)\n\trequire.Equal(t, \"missing path parameter 'contextId'\", resp.Message)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/command.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/flag\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// RunCommand executes a shell command and streams the output via SSE.\nfunc (c *CodeInterpretingController) RunCommand() {\n\tvar request model.RunCommandRequest\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\terr := request.Validate()\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"invalid request, validation error %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithCancel(c.ctx.Request.Context())\n\tdefer cancel()\n\n\trunCodeRequest := c.buildExecuteCommandRequest(request)\n\teventsHandler := c.setServerEventsHandler(ctx)\n\trunCodeRequest.Hooks = eventsHandler\n\n\tc.setupSSEResponse()\n\terr = codeRunner.Execute(runCodeRequest)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error running commands %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\ttime.Sleep(flag.ApiGracefulShutdownTimeout)\n}\n\n// InterruptCommand stops a running shell command session.\nfunc (c *CodeInterpretingController) InterruptCommand() {\n\tc.interrupt()\n}\n\n// GetCommandStatus returns command status by id.\nfunc (c *CodeInterpretingController) GetCommandStatus() {\n\tcommandID := c.ctx.Param(\"id\")\n\tif commandID == \"\" {\n\t\tc.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, \"missing command execution id\")\n\t\treturn\n\t}\n\n\tstatus, err := codeRunner.GetCommandStatus(commandID)\n\tif err != nil {\n\t\tc.RespondError(http.StatusNotFound, model.ErrorCodeInvalidRequest, err.Error())\n\t\treturn\n\t}\n\n\tresp := model.CommandStatusResponse{\n\t\tID:       status.Session,\n\t\tRunning:  status.Running,\n\t\tExitCode: status.ExitCode,\n\t\tError:    status.Error,\n\t\tContent:  status.Content,\n\t}\n\tif !status.StartedAt.IsZero() {\n\t\tresp.StartedAt = status.StartedAt\n\t}\n\tif status.FinishedAt != nil {\n\t\tresp.FinishedAt = status.FinishedAt\n\t}\n\n\tc.RespondSuccess(resp)\n}\n\n// GetBackgroundCommandOutput returns accumulated stdout/stderr for a command session as plain text.\nfunc (c *CodeInterpretingController) GetBackgroundCommandOutput() {\n\tid := c.ctx.Param(\"id\")\n\tif id == \"\" {\n\t\tc.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, \"missing command execution id\")\n\t\treturn\n\t}\n\n\tcursor := c.QueryInt64(c.ctx.Query(\"cursor\"), 0)\n\toutput, lastCursor, err := codeRunner.SeekBackgroundCommandOutput(id, cursor)\n\tif err != nil {\n\t\tc.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error())\n\t\treturn\n\t}\n\n\tc.ctx.Header(\"EXECD-COMMANDS-TAIL-CURSOR\", strconv.FormatInt(lastCursor, 10))\n\tc.ctx.Header(\"Content-Type\", \"text/plain; charset=utf-8\")\n\tc.ctx.String(http.StatusOK, \"%s\", output)\n}\n\nfunc (c *CodeInterpretingController) buildExecuteCommandRequest(request model.RunCommandRequest) *runtime.ExecuteCodeRequest {\n\ttimeout := time.Duration(request.TimeoutMs) * time.Millisecond\n\tif request.Background {\n\t\treturn &runtime.ExecuteCodeRequest{\n\t\t\tLanguage: runtime.BackgroundCommand,\n\t\t\tCode:     request.Command,\n\t\t\tCwd:      request.Cwd,\n\t\t\tTimeout:  timeout,\n\t\t\tGid:      request.Gid,\n\t\t\tUid:      request.Uid,\n\t\t\tEnvs:     request.Envs,\n\t\t}\n\t} else {\n\t\treturn &runtime.ExecuteCodeRequest{\n\t\t\tLanguage: runtime.Command,\n\t\t\tCode:     request.Command,\n\t\t\tCwd:      request.Cwd,\n\t\t\tTimeout:  timeout,\n\t\t\tGid:      request.Gid,\n\t\t\tUid:      request.Uid,\n\t\t\tEnvs:     request.Envs,\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/command_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBuildExecuteCommandRequestForwardsEnvs(t *testing.T) {\n\tctrl := &CodeInterpretingController{}\n\tenvs := map[string]string{\"FOO\": \"bar\", \"BAZ\": \"qux\"}\n\treq := model.RunCommandRequest{\n\t\tCommand: \"echo hi\",\n\t\tCwd:     \"/tmp\",\n\t\tEnvs:    envs,\n\t}\n\n\texecReq := ctrl.buildExecuteCommandRequest(req)\n\n\trequire.Equal(t, runtime.Command, execReq.Language)\n\trequire.True(t, reflect.DeepEqual(execReq.Envs, envs), \"expected envs to be forwarded\")\n\trequire.Equal(t, \"/tmp\", execReq.Cwd)\n}\n\nfunc TestBuildExecuteCommandRequestForwardsEnvsBackground(t *testing.T) {\n\tctrl := &CodeInterpretingController{}\n\tenvs := map[string]string{\"FOO\": \"bar\"}\n\treq := model.RunCommandRequest{\n\t\tCommand:    \"echo hi\",\n\t\tBackground: true,\n\t\tEnvs:       envs,\n\t}\n\n\texecReq := ctrl.buildExecuteCommandRequest(req)\n\n\trequire.Equal(t, runtime.BackgroundCommand, execReq.Language)\n\trequire.True(t, reflect.DeepEqual(execReq.Envs, envs), \"expected envs to be forwarded\")\n}\n\nfunc setupCommandController(method, path string) (*CodeInterpretingController, *httptest.ResponseRecorder) {\n\tctx, w := newTestContext(method, path, nil)\n\tctrl := NewCodeInterpretingController(ctx)\n\treturn ctrl, w\n}\n\nfunc TestGetCommandStatus_MissingID(t *testing.T) {\n\tctrl, w := setupCommandController(http.MethodGet, \"/command/status/\")\n\n\tctrl.GetCommandStatus()\n\n\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\n\tvar resp model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))\n\trequire.Equal(t, model.ErrorCodeInvalidRequest, resp.Code)\n\trequire.Equal(t, \"missing command execution id\", resp.Message)\n}\n\nfunc TestGetBackgroundCommandOutput_MissingID(t *testing.T) {\n\tctrl, w := setupCommandController(http.MethodGet, \"/command/logs/\")\n\n\tctrl.GetBackgroundCommandOutput()\n\n\trequire.Equal(t, http.StatusBadRequest, w.Code)\n\n\tvar resp model.ErrorResponse\n\trequire.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))\n\trequire.Equal(t, model.ErrorCodeMissingQuery, resp.Code)\n\trequire.Equal(t, \"missing command execution id\", resp.Message)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/filesystem.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage controller\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/util/glob\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// FilesystemController handles file system operations\ntype FilesystemController struct {\n\t*basicController\n}\n\nfunc NewFilesystemController(ctx *gin.Context) *FilesystemController {\n\treturn &FilesystemController{basicController: newBasicController(ctx)}\n}\n\nfunc (c *FilesystemController) handleFileError(err error) {\n\tif os.IsNotExist(err) {\n\t\tc.RespondError(\n\t\t\thttp.StatusNotFound,\n\t\t\tmodel.ErrorCodeFileNotFound,\n\t\t\tfmt.Sprintf(\"file not found. %v\", err),\n\t\t)\n\t} else {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error accessing file: %v\", err),\n\t\t)\n\t}\n}\n\n// GetFilesInfo retrieves metadata for specified file paths\nfunc (c *FilesystemController) GetFilesInfo() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tif len(paths) == 0 {\n\t\tc.RespondSuccess(make(map[string]model.FileInfo))\n\t\treturn\n\t}\n\n\tresp := make(map[string]model.FileInfo)\n\tfor _, filePath := range paths {\n\t\tfileInfo, err := GetFileInfo(filePath)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t\tresp[filePath] = fileInfo\n\t}\n\n\tc.RespondSuccess(resp)\n}\n\n// RemoveFiles deletes specified files\nfunc (c *FilesystemController) RemoveFiles() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tfor _, filePath := range paths {\n\t\tif err := DeleteFile(filePath); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error removing file %s. %v\", filePath, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// ChmodFiles changes file permissions for specified files\nfunc (c *FilesystemController) ChmodFiles() {\n\tvar request map[string]model.Permission\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor file, item := range request {\n\t\terr := ChmodFile(file, item)\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error changing permissions for %s. %v\", file, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// RenameFiles renames or moves files to new paths\nfunc (c *FilesystemController) RenameFiles() {\n\tvar request []model.RenameFileItem\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor _, renameItem := range request {\n\t\tif err := RenameFile(renameItem); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// MakeDirs creates directories with specified permissions\nfunc (c *FilesystemController) MakeDirs() {\n\tvar request map[string]model.Permission\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor dir, perm := range request {\n\t\tif err := MakeDir(dir, perm); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// RemoveDirs recursively removes directories\nfunc (c *FilesystemController) RemoveDirs() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tfor _, dir := range paths {\n\t\tif err := os.RemoveAll(dir); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error removing directory %s. %v\", dir, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// SearchFiles searches for files matching a pattern in a directory\nfunc (c *FilesystemController) SearchFiles() {\n\tpath := c.ctx.Query(\"path\")\n\tif path == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing query parameter 'path'\",\n\t\t)\n\t\treturn\n\t}\n\n\tpath, err := filepath.Abs(path)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error converting path %s to absolute. %v\", path, err),\n\t\t)\n\t\treturn\n\t}\n\n\t_, err = os.Stat(path)\n\tif err != nil {\n\t\tc.handleFileError(err)\n\t\treturn\n\t}\n\n\tpattern := c.ctx.Query(\"pattern\")\n\tif pattern == \"\" {\n\t\tpattern = \"**\"\n\t}\n\n\tfiles := make([]model.FileInfo, 0, 16)\n\terr = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error accessing path %s: %w\", filePath, err)\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tmatch, err := glob.PathMatch(pattern, info.Name())\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid pattern %s: %w\", pattern, err)\n\t\t}\n\n\t\tif match {\n\t\t\tsys := info.Sys().(*syscall.Stat_t)\n\n\t\t\towner, err := user.LookupId(strconv.FormatUint(uint64(sys.Uid), 10))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error lookup owner for file %s: %w\", filePath, err)\n\t\t\t}\n\n\t\t\tgroup, err := user.LookupGroupId(strconv.FormatUint(uint64(sys.Gid), 10))\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"error lookup group for file %s: %w\", filePath, err)\n\t\t\t}\n\n\t\t\tfiles = append(files, model.FileInfo{\n\t\t\t\tPath:       filePath,\n\t\t\t\tSize:       info.Size(),\n\t\t\t\tModifiedAt: info.ModTime(),\n\t\t\t\tCreatedAt:  getFileCreateTime(info),\n\t\t\t\tPermission: model.Permission{\n\t\t\t\t\tOwner: owner.Username,\n\t\t\t\t\tGroup: group.Name,\n\t\t\t\t\tMode: func() int {\n\t\t\t\t\t\tmode := strconv.FormatInt(int64(info.Mode().Perm()), 8)\n\t\t\t\t\t\ti, _ := strconv.Atoi(mode)\n\t\t\t\t\t\treturn i\n\t\t\t\t\t}(),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error searching files. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(files)\n}\n\n// ReplaceContent replaces text content in specified files\nfunc (c *FilesystemController) ReplaceContent() {\n\tvar request map[string]model.ReplaceFileContentItem\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor file, item := range request {\n\t\tfile, err := filepath.Abs(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err = os.Stat(file); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tcontent, err := os.ReadFile(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tfileInfo, err := os.Stat(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t\tmode := fileInfo.Mode()\n\n\t\tnewContent := strings.ReplaceAll(string(content), item.Old, item.New)\n\n\t\terr = os.WriteFile(file, []byte(newContent), mode)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/filesystem_download.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// DownloadFile serves a file for download with support for range requests.\nfunc (c *FilesystemController) DownloadFile() {\n\tfilePath := c.ctx.Query(\"path\")\n\tif filePath == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing query parameter 'path'\",\n\t\t)\n\t\treturn\n\t}\n\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\tc.handleFileError(err)\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\tfileInfo, err := file.Stat()\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error getting file stat info: %s. %v\", filePath, err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.ctx.Header(\"Content-Type\", \"application/octet-stream\")\n\tc.ctx.Header(\"Content-Disposition\", formatContentDisposition(filepath.Base(filePath)))\n\tc.ctx.Header(\"Content-Length\", strconv.FormatInt(fileInfo.Size(), 10))\n\n\tif rangeHeader := c.ctx.GetHeader(\"Range\"); rangeHeader != \"\" {\n\t\tranges, err := ParseRange(rangeHeader, fileInfo.Size())\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusRequestedRangeNotSatisfiable,\n\t\t\t\tmodel.ErrorCodeUnknown,\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t\tif len(ranges) > 0 {\n\t\t\tr := ranges[0]\n\t\t\tc.ctx.Status(http.StatusPartialContent)\n\t\t\tc.ctx.Header(\"Content-Range\", fmt.Sprintf(\"bytes %d-%d/%d\", r.start, r.start+r.length-1, fileInfo.Size()))\n\t\t\tc.ctx.Header(\"Content-Length\", strconv.FormatInt(r.length, 10))\n\n\t\t\t_, _ = file.Seek(r.start, io.SeekStart)\n\t\t\t_, _ = io.CopyN(c.ctx.Writer, file, r.length)\n\t\t\treturn\n\t\t}\n\t}\n\n\thttp.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(filePath), fileInfo.ModTime(), file)\n}\n\n// formatContentDisposition formats the Content-Disposition header value with proper\n// encoding for non-ASCII filenames according to RFC 6266 and RFC 5987.\nfunc formatContentDisposition(filename string) string {\n\t// Check if filename contains non-ASCII characters\n\tneedsEncoding := false\n\tfor _, r := range filename {\n\t\tif r > 127 {\n\t\t\tneedsEncoding = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !needsEncoding {\n\t\treturn \"attachment; filename=\\\"\" + filename + \"\\\"\"\n\t}\n\n\t// Use RFC 5987 encoding for non-ASCII filenames\n\t// Format: attachment; filename=\"fallback\"; filename*=UTF-8''encoded_name\n\tencodedFilename := url.PathEscape(filename)\n\treturn \"attachment; filename=\\\"\" + encodedFilename + \"\\\"; filename*=UTF-8''\" + encodedFilename\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/filesystem_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc newFilesystemController(t *testing.T, method, rawURL string, body []byte) (*FilesystemController, *httptest.ResponseRecorder) {\n\tt.Helper()\n\tctx, rec := newTestContext(method, rawURL, body)\n\tctrl := NewFilesystemController(ctx)\n\treturn ctrl, rec\n}\n\nfunc TestFilesystemControllerGetFilesInfo(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttarget := filepath.Join(tmpDir, \"foo.txt\")\n\trequire.NoError(t, os.WriteFile(target, []byte(\"demo\"), 0o644))\n\n\tquery := fmt.Sprintf(\"/files/info?path=%s\", url.QueryEscape(target))\n\tctrl, rec := newFilesystemController(t, http.MethodGet, query, nil)\n\n\tctrl.GetFilesInfo()\n\n\trequire.Equal(t, http.StatusOK, rec.Code)\n\tvar resp map[string]model.FileInfo\n\trequire.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))\n\tinfo, ok := resp[target]\n\trequire.True(t, ok, \"response missing entry for %s\", target)\n\trequire.NotEmpty(t, info.Path)\n\trequire.NotZero(t, info.Size)\n}\n\nfunc TestFilesystemControllerSearchFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ta := filepath.Join(tmpDir, \"alpha.txt\")\n\tb := filepath.Join(tmpDir, \"beta.log\")\n\trequire.NoError(t, os.WriteFile(a, []byte(\"alpha\"), 0o644))\n\trequire.NoError(t, os.WriteFile(b, []byte(\"beta\"), 0o644))\n\n\trawURL := fmt.Sprintf(\"/files/search?path=%s&pattern=%s\", url.QueryEscape(tmpDir), url.QueryEscape(\"*.txt\"))\n\tctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil)\n\n\tctrl.SearchFiles()\n\n\trequire.Equal(t, http.StatusOK, rec.Code)\n\tvar files []model.FileInfo\n\trequire.NoError(t, json.Unmarshal(rec.Body.Bytes(), &files))\n\trequire.Len(t, files, 1)\n\trequire.Equal(t, a, files[0].Path)\n}\n\nfunc TestFilesystemControllerReplaceContent(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttarget := filepath.Join(tmpDir, \"content.txt\")\n\trequire.NoError(t, os.WriteFile(target, []byte(\"hello world\"), 0o644))\n\n\tbody, err := json.Marshal(map[string]model.ReplaceFileContentItem{\n\t\ttarget: {\n\t\t\tOld: \"world\",\n\t\t\tNew: \"universe\",\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\tctrl, rec := newFilesystemController(t, http.MethodPost, \"/files/replace\", body)\n\n\tctrl.ReplaceContent()\n\n\trequire.Equal(t, http.StatusOK, rec.Code)\n\tdata, err := os.ReadFile(target)\n\trequire.NoError(t, err)\n\trequire.Equal(t, \"hello universe\", string(data))\n}\n\nfunc TestFilesystemControllerSearchFilesHandlesAbsentDir(t *testing.T) {\n\trawURL := \"/files/search?path=/not/exists\"\n\tctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil)\n\n\tctrl.SearchFiles()\n\n\trequire.Equal(t, http.StatusNotFound, rec.Code)\n}\n\nfunc TestReplaceContentFailsUnknownFile(t *testing.T) {\n\tpayload, _ := json.Marshal(map[string]model.ReplaceFileContentItem{\n\t\tfilepath.Join(t.TempDir(), \"missing.txt\"): {\n\t\t\tOld: \"old\",\n\t\t\tNew: \"new\",\n\t\t},\n\t})\n\tctrl, rec := newFilesystemController(t, http.MethodPost, \"/files/replace\", payload)\n\n\tctrl.ReplaceContent()\n\n\trequire.Contains(t, []int{http.StatusNotFound, http.StatusInternalServerError}, rec.Code, \"expected failure status\")\n}\n\nfunc TestFormatContentDisposition(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfilename string\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname:     \"ASCII filename\",\n\t\t\tfilename: \"test.txt\",\n\t\t\twant:     \"attachment; filename=\\\"test.txt\\\"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Chinese filename\",\n\t\t\tfilename: \"测试文件.txt\",\n\t\t\twant:     \"attachment; filename=\\\"%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt\\\"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Japanese filename\",\n\t\t\tfilename: \"テスト.txt\",\n\t\t\twant:     \"attachment; filename=\\\"%E3%83%86%E3%82%B9%E3%83%88.txt\\\"; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Special characters in filename\",\n\t\t\tfilename: \"file with spaces.txt\",\n\t\t\twant:     \"attachment; filename=\\\"file with spaces.txt\\\"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Mixed ASCII and non-ASCII\",\n\t\t\tfilename: \"report-报告.pdf\",\n\t\t\twant:     \"attachment; filename=\\\"report-%E6%8A%A5%E5%91%8A.pdf\\\"; filename*=UTF-8''report-%E6%8A%A5%E5%91%8A.pdf\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := formatContentDisposition(tt.filename)\n\t\t\trequire.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/filesystem_upload.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// UploadFile uploads files with metadata to specified paths\nfunc (c *FilesystemController) UploadFile() {\n\tform, err := c.ctx.MultipartForm()\n\tif err != nil || form == nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidFile,\n\t\t\t\"multipart form is empty\",\n\t\t)\n\t\treturn\n\t}\n\n\tmetadataParts := form.File[\"metadata\"]\n\tfileParts := form.File[\"file\"]\n\n\tif len(metadataParts) == 0 {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidFileMetadata,\n\t\t\t\"metadata file is missing\",\n\t\t)\n\t\treturn\n\t}\n\n\tif len(fileParts) == 0 {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidFileContent,\n\t\t\t\"file is missing\",\n\t\t)\n\t\treturn\n\t}\n\n\tif len(metadataParts) != len(fileParts) {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidFile,\n\t\t\tfmt.Sprintf(\"metadata and file count mismatch: %d vs %d\", len(metadataParts), len(fileParts)),\n\t\t)\n\t\treturn\n\t}\n\n\tfor i := range metadataParts {\n\t\tmetadataHeader := metadataParts[i]\n\t\tmetadataFile, err := metadataHeader.Open()\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusBadRequest,\n\t\t\t\tmodel.ErrorCodeInvalidFileMetadata,\n\t\t\t\tfmt.Sprintf(\"error opening metadata file. %v\", err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tmetaBytes, err := io.ReadAll(metadataFile)\n\t\tmetadataFile.Close()\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusBadRequest,\n\t\t\t\tmodel.ErrorCodeInvalidFileMetadata,\n\t\t\t\tfmt.Sprintf(\"error reading metadata content. %v\", err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tvar meta model.FileMetadata\n\t\tif err := json.Unmarshal(metaBytes, &meta); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusBadRequest,\n\t\t\t\tmodel.ErrorCodeInvalidFileMetadata,\n\t\t\t\tfmt.Sprintf(\"invalid metadata format. %v\", err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\ttargetPath := meta.Path\n\t\tif targetPath == \"\" {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusBadRequest,\n\t\t\t\tmodel.ErrorCodeInvalidFileMetadata,\n\t\t\t\t\"metadata path is empty\",\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\ttargetDir := filepath.Dir(targetPath)\n\t\tif err := os.MkdirAll(targetDir, os.ModePerm); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error creating target directory %s. %v\", targetDir, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tfileHeader := fileParts[i]\n\t\tfile, err := fileHeader.Open()\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error opening file %s. %v\", fileHeader.Filename, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tdst, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm)\n\t\tif err != nil {\n\t\t\tfile.Close()\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error opening destination file %s. %v\", targetPath, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err := io.Copy(dst, file); err != nil {\n\t\t\tdst.Close()\n\t\t\tfile.Close()\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error copying file %s. %v\", targetPath, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\n\t\tif err := dst.Sync(); err != nil {\n\t\t\tlog.Error(\"failed to sync target file: %v\", err)\n\t\t}\n\t\tif err := dst.Close(); err != nil {\n\t\t\tlog.Error(\"failed to close target file: %v\", err)\n\t\t}\n\t\tfile.Close()\n\n\t\tif err := ChmodFile(targetPath, meta.Permission); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error chmoding file %s. %v\", targetPath, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/filesystem_windows.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build windows\n// +build windows\n\npackage controller\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/util/glob\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// FilesystemController handles file system operations.\ntype FilesystemController struct {\n\t*basicController\n}\n\nfunc NewFilesystemController(ctx *gin.Context) *FilesystemController {\n\treturn &FilesystemController{basicController: newBasicController(ctx)}\n}\n\nfunc (c *FilesystemController) handleFileError(err error) {\n\tif os.IsNotExist(err) {\n\t\tc.RespondError(\n\t\t\thttp.StatusNotFound,\n\t\t\tmodel.ErrorCodeFileNotFound,\n\t\t\tfmt.Sprintf(\"file not found. %v\", err),\n\t\t)\n\t} else {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error accessing file: %v\", err),\n\t\t)\n\t}\n}\n\n// GetFilesInfo retrieves metadata for specified file paths\nfunc (c *FilesystemController) GetFilesInfo() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tif len(paths) == 0 {\n\t\tc.RespondSuccess(make(map[string]model.FileInfo))\n\t\treturn\n\t}\n\n\tresp := make(map[string]model.FileInfo)\n\tfor _, filePath := range paths {\n\t\tfileInfo, err := GetFileInfo(filePath)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t\tresp[filePath] = fileInfo\n\t}\n\n\tc.RespondSuccess(resp)\n}\n\n// RemoveFiles deletes specified files\nfunc (c *FilesystemController) RemoveFiles() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tfor _, filePath := range paths {\n\t\tif err := DeleteFile(filePath); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error removing file %s. %v\", filePath, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// ChmodFiles changes file permissions for specified files\nfunc (c *FilesystemController) ChmodFiles() {\n\tvar request map[string]model.Permission\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor file, item := range request {\n\t\terr := ChmodFile(file, item)\n\t\tif err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error changing permissions for %s. %v\", file, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// RenameFiles renames or moves files to new paths\nfunc (c *FilesystemController) RenameFiles() {\n\tvar request []model.RenameFileItem\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor _, renameItem := range request {\n\t\tif err := RenameFile(renameItem); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// MakeDirs creates directories with specified permissions\nfunc (c *FilesystemController) MakeDirs() {\n\tvar request map[string]model.Permission\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor dir, perm := range request {\n\t\tif err := MakeDir(dir, perm); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// RemoveDirs recursively removes directories\nfunc (c *FilesystemController) RemoveDirs() {\n\tpaths := c.ctx.QueryArray(\"path\")\n\tfor _, dir := range paths {\n\t\tif err := os.RemoveAll(dir); err != nil {\n\t\t\tc.RespondError(\n\t\t\t\thttp.StatusInternalServerError,\n\t\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\t\tfmt.Sprintf(\"error removing directory %s. %v\", dir, err),\n\t\t\t)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n\n// SearchFiles searches for files matching a pattern in a directory\nfunc (c *FilesystemController) SearchFiles() {\n\tpath := c.ctx.Query(\"path\")\n\tif path == \"\" {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeMissingQuery,\n\t\t\t\"missing query parameter 'path'\",\n\t\t)\n\t\treturn\n\t}\n\n\tpath, err := filepath.Abs(path)\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error converting path %s to absolute. %v\", path, err),\n\t\t)\n\t\treturn\n\t}\n\n\t_, err = os.Stat(path)\n\tif err != nil {\n\t\tc.handleFileError(err)\n\t\treturn\n\t}\n\n\tpattern := c.ctx.Query(\"pattern\")\n\tif pattern == \"\" {\n\t\tpattern = \"**\"\n\t}\n\n\tfiles := make([]model.FileInfo, 0, 16)\n\terr = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error accessing path %s: %w\", filePath, err)\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\n\t\tmatch, err := glob.PathMatch(pattern, info.Name())\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid pattern %s: %w\", pattern, err)\n\t\t}\n\n\t\tif match {\n\t\t\tfiles = append(files, model.FileInfo{\n\t\t\t\tPath:       filePath,\n\t\t\t\tSize:       info.Size(),\n\t\t\t\tModifiedAt: info.ModTime(),\n\t\t\t\tCreatedAt:  getFileCreateTime(info),\n\t\t\t\tPermission: model.Permission{\n\t\t\t\t\tOwner: \"\",\n\t\t\t\t\tGroup: \"\",\n\t\t\t\t\tMode: func() int {\n\t\t\t\t\t\tmode := strconv.FormatInt(int64(info.Mode().Perm()), 8)\n\t\t\t\t\t\ti, _ := strconv.Atoi(mode)\n\t\t\t\t\t\treturn i\n\t\t\t\t\t}(),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error searching files. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(files)\n}\n\n// ReplaceContent replaces text content in specified files\nfunc (c *FilesystemController) ReplaceContent() {\n\tvar request map[string]model.ReplaceFileContentItem\n\tif err := c.bindJSON(&request); err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusBadRequest,\n\t\t\tmodel.ErrorCodeInvalidRequest,\n\t\t\tfmt.Sprintf(\"error parsing request, MAYBE invalid body format. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tfor file, item := range request {\n\t\tfile, err := filepath.Abs(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tif _, err = os.Stat(file); err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tcontent, err := os.ReadFile(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\n\t\tfileInfo, err := os.Stat(file)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t\tmode := fileInfo.Mode()\n\n\t\tnewContent := strings.ReplaceAll(string(content), item.Old, item.New)\n\n\t\terr = os.WriteFile(file, []byte(newContent), mode)\n\t\tif err != nil {\n\t\t\tc.handleFileError(err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.RespondSuccess(nil)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/metric.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/shirou/gopsutil/cpu\"\n\t\"github.com/shirou/gopsutil/mem\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// MetricController handles system metrics requests\ntype MetricController struct {\n\t*basicController\n}\n\nfunc NewMetricController(ctx *gin.Context) *MetricController {\n\treturn &MetricController{basicController: newBasicController(ctx)}\n}\n\n// GetMetrics returns current system metrics\nfunc (c *MetricController) GetMetrics() {\n\tmetrics, err := c.readMetrics()\n\tif err != nil {\n\t\tc.RespondError(\n\t\t\thttp.StatusInternalServerError,\n\t\t\tmodel.ErrorCodeRuntimeError,\n\t\t\tfmt.Sprintf(\"error reading runtime metrics. %v\", err),\n\t\t)\n\t\treturn\n\t}\n\n\tc.RespondSuccess(metrics)\n}\n\n// WatchMetrics streams system metrics via SSE\nfunc (c *MetricController) WatchMetrics() {\n\tc.setupSSEResponse()\n\n\tfor {\n\t\tselect {\n\t\tcase <-c.ctx.Request.Context().Done():\n\t\t\treturn\n\t\tcase <-time.After(time.Second * 1):\n\t\t\tfunc() {\n\t\t\t\tif flusher, ok := c.ctx.Writer.(http.Flusher); ok {\n\t\t\t\t\tdefer flusher.Flush()\n\t\t\t\t}\n\t\t\t\tmetrics, err := c.readMetrics()\n\t\t\t\tif err != nil {\n\t\t\t\t\tmsg, _ := json.Marshal(map[string]string{ //nolint:errchkjson\n\t\t\t\t\t\t\"error\": err.Error(),\n\t\t\t\t\t})\n\t\t\t\t\t_, err = c.ctx.Writer.Write(append(msg, '\\n'))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Error(\"WatchMetrics write data %s error: %v\", string(msg), err)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tmsg, _ := json.Marshal(metrics) //nolint:errchkjson\n\t\t\t\t\t_, err = c.ctx.Writer.Write(append(msg, '\\n'))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Error(\"WatchMetrics write data %s error: %v\", string(msg), err)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t}\n}\n\n// readMetrics collects current CPU and memory metrics\nfunc (c *MetricController) readMetrics() (*model.Metrics, error) {\n\tmetric := model.NewMetrics()\n\n\tmetric.CpuCount = float64(runtime.GOMAXPROCS(-1))\n\tcpuPercent, err := cpu.Percent(time.Second, false)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get CPU percent: %w\", err)\n\t}\n\tif len(cpuPercent) > 0 {\n\t\tmetric.CpuUsedPct = cpuPercent[0]\n\t}\n\n\tvmStat, err := mem.VirtualMemory()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get memory info: %w\", err)\n\t}\n\tmetric.MemTotalMiB = float64(vmStat.Total) / 1024 / 1024\n\tmetric.MemUsedMiB = float64(vmStat.Used) / 1024 / 1024\n\n\treturn metric, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/metric_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\nfunc setupMetricController(method, path string) (*MetricController, *httptest.ResponseRecorder) {\n\tctx, w := newTestContext(method, path, nil)\n\tctrl := NewMetricController(ctx)\n\treturn ctrl, w\n}\n\n// TestReadMetrics exercises readMetrics end-to-end.\nfunc TestReadMetrics(t *testing.T) {\n\tctrl := &MetricController{}\n\n\tmetrics, err := ctrl.readMetrics()\n\n\tassert.NoError(t, err)\n\tassert.NotNil(t, metrics)\n\n\t// Validate CPU count\n\tassert.Greater(t, metrics.CpuCount, 0.0)\n\n\t// Validate CPU utilization\n\tassert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0)\n\tassert.Less(t, metrics.CpuUsedPct, 100.1) // CPU usage should be under 100% with small float tolerance\n\n\t// Validate memory information\n\tassert.Greater(t, metrics.MemTotalMiB, 0.0)\n\tassert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0)\n\tassert.LessOrEqual(t, metrics.MemUsedMiB, metrics.MemTotalMiB) // Used memory should not exceed total\n\n\t// Validate timestamps\n\tcurrentTime := time.Now().UnixMilli()\n\toneMinuteAgo := currentTime - 60*1000\n\tassert.GreaterOrEqual(t, metrics.Timestamp, oneMinuteAgo) // Should be within the last minute\n\tassert.LessOrEqual(t, metrics.Timestamp, currentTime)     // Should not be in the future\n}\n\n// TestGetMetricsEndpoint covers the happy path.\nfunc TestGetMetricsEndpoint(t *testing.T) {\n\tctrl, w := setupMetricController(\"GET\", \"/api/metrics\")\n\n\tctrl.GetMetrics()\n\n\tassert.Equal(t, http.StatusOK, w.Code)\n\n\tvar metrics model.Metrics\n\terr := json.Unmarshal(w.Body.Bytes(), &metrics)\n\tassert.NoError(t, err)\n\n\tassert.Greater(t, metrics.CpuCount, 0.0)\n\tassert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0)\n\tassert.Greater(t, metrics.MemTotalMiB, 0.0)\n\tassert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0)\n\tassert.NotZero(t, metrics.Timestamp)\n}\n\n// TestWatchMetricsHeaders verifies SSE header defaults.\nfunc TestWatchMetricsHeaders(t *testing.T) {\n\tctrl, w := setupMetricController(\"GET\", \"/api/watch-metrics\")\n\n\tctrl.setupSSEResponse()\n\n\tcontentType := w.Header().Get(\"Content-Type\")\n\tassert.Equal(t, \"text/event-stream\", contentType)\n\n\tcacheControl := w.Header().Get(\"Cache-Control\")\n\tassert.Equal(t, \"no-cache\", cacheControl)\n\n\tconnection := w.Header().Get(\"Connection\")\n\tassert.Equal(t, \"keep-alive\", connection)\n\n\tbuffering := w.Header().Get(\"X-Accel-Buffering\")\n\tassert.Equal(t, \"no\", buffering)\n}\n\n// TestMetricSerialization ensures metrics marshal and unmarshal cleanly.\nfunc TestMetricSerialization(t *testing.T) {\n\tmetrics := &model.Metrics{\n\t\tCpuCount:    4,\n\t\tCpuUsedPct:  25.5,\n\t\tMemTotalMiB: 8192,\n\t\tMemUsedMiB:  4096,\n\t\tTimestamp:   time.Now().UnixMilli(),\n\t}\n\n\tdata, err := json.Marshal(metrics)\n\tassert.NoError(t, err)\n\n\tvar decodedMetrics model.Metrics\n\terr = json.Unmarshal(data, &decodedMetrics)\n\tassert.NoError(t, err)\n\tassert.Equal(t, metrics.CpuCount, decodedMetrics.CpuCount)\n\tassert.Equal(t, metrics.CpuUsedPct, decodedMetrics.CpuUsedPct)\n\tassert.Equal(t, metrics.MemTotalMiB, decodedMetrics.MemTotalMiB)\n\tassert.Equal(t, metrics.MemUsedMiB, decodedMetrics.MemUsedMiB)\n\tassert.Equal(t, metrics.Timestamp, decodedMetrics.Timestamp)\n\n\terrorMsg := map[string]string{\"error\": \"test error\"}\n\terrorData, err := json.Marshal(errorMsg)\n\tassert.NoError(t, err)\n\n\tvar decodedError map[string]string\n\terr = json.Unmarshal(errorData, &decodedError)\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"test error\", decodedError[\"error\"])\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/mock_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n)\n\ntype mockOutput struct {\n\tbuffer     *bytes.Buffer\n\tstatusCode int\n\theader     http.Header\n}\n\nfunc (m *mockOutput) Header() http.Header {\n\tif m.header == nil {\n\t\tm.header = make(http.Header)\n\t}\n\treturn m.header\n}\n\nfunc (m *mockOutput) Write(b []byte) (int, error) {\n\treturn m.buffer.Write(b)\n}\n\nfunc (m *mockOutput) WriteHeader(code int) {\n\tm.statusCode = code\n}\n\nfunc (m *mockOutput) Status() int {\n\treturn m.statusCode\n}\n\nfunc (m *mockOutput) Body() []byte {\n\treturn m.buffer.Bytes()\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/ping.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport \"github.com/gin-gonic/gin\"\n\n// MainController handles basic server operations.\ntype MainController struct {\n\t*basicController\n}\n\nfunc NewMainController(ctx *gin.Context) *MainController {\n\treturn &MainController{basicController: newBasicController(ctx)}\n}\n\n// Ping checks if the server is alive.\nfunc (c *MainController) Ping() {\n\tc.RespondSuccess(nil)\n}\n\n// PingHandler is the Gin adapter.\nfunc PingHandler(ctx *gin.Context) {\n\tNewMainController(ctx).Ping()\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/sse.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/runtime\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/util/safego\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\nvar sseHeaders = map[string]string{\n\t\"Content-Type\":      \"text/event-stream\",\n\t\"Cache-Control\":     \"no-cache\",\n\t\"Connection\":        \"keep-alive\",\n\t\"X-Accel-Buffering\": \"no\",\n}\n\nfunc (c *basicController) setupSSEResponse() {\n\tfor key, value := range sseHeaders {\n\t\tc.ctx.Writer.Header().Set(key, value)\n\t}\n\tif flusher, ok := c.ctx.Writer.(http.Flusher); ok {\n\t\tflusher.Flush()\n\t}\n}\n\n// setServerEventsHandler adapts runtime callbacks to SSE events.\nfunc (c *CodeInterpretingController) setServerEventsHandler(ctx context.Context) runtime.ExecuteResultHook {\n\treturn runtime.ExecuteResultHook{\n\t\tOnExecuteInit: func(session string) {\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:      model.StreamEventTypeInit,\n\t\t\t\tText:      session,\n\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteInit\", payload, true, event.Summary())\n\n\t\t\tsafego.Go(func() { c.ping(ctx) })\n\t\t},\n\t\tOnExecuteResult: func(result map[string]any, count int) {\n\t\t\tvar mutated map[string]any\n\t\t\tif len(result) > 0 {\n\t\t\t\tmutated = make(map[string]any)\n\t\t\t\tfor k, v := range result {\n\t\t\t\t\tswitch k {\n\t\t\t\t\tcase \"text/plain\":\n\t\t\t\t\t\tmutated[\"text\"] = v\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tmutated[k] = v\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif count > 0 {\n\t\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\t\tType:           model.StreamEventTypeCount,\n\t\t\t\t\tExecutionCount: count,\n\t\t\t\t\tTimestamp:      time.Now().UnixMilli(),\n\t\t\t\t}\n\t\t\t\tpayload := event.ToJSON()\n\t\t\t\tc.writeSingleEvent(\"OnExecuteResult\", payload, true, event.Summary())\n\t\t\t}\n\t\t\tif len(mutated) > 0 {\n\t\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\t\tType:      model.StreamEventTypeResult,\n\t\t\t\t\tResults:   mutated,\n\t\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t\t}\n\t\t\t\tpayload := event.ToJSON()\n\t\t\t\tc.writeSingleEvent(\"OnExecuteResult\", payload, true, event.Summary())\n\t\t\t}\n\t\t},\n\t\tOnExecuteComplete: func(executionTime time.Duration) {\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:          model.StreamEventTypeComplete,\n\t\t\t\tExecutionTime: executionTime.Milliseconds(),\n\t\t\t\tTimestamp:     time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteComplete\", payload, true, event.Summary())\n\t\t},\n\t\tOnExecuteError: func(err *execute.ErrorOutput) {\n\t\t\tif err == nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:      model.StreamEventTypeError,\n\t\t\t\tError:     err,\n\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteError\", payload, true, event.Summary())\n\t\t},\n\t\tOnExecuteStatus: func(status string) {\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:      model.StreamEventTypeStatus,\n\t\t\t\tText:      status,\n\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteStatus\", payload, true, event.Summary())\n\t\t},\n\t\tOnExecuteStdout: func(text string) {\n\t\t\tif text == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:      model.StreamEventTypeStdout,\n\t\t\t\tText:      text,\n\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteStdout\", payload, true, event.Summary())\n\t\t},\n\t\tOnExecuteStderr: func(text string) {\n\t\t\tif text == \"\" {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tevent := model.ServerStreamEvent{\n\t\t\t\tType:      model.StreamEventTypeStderr,\n\t\t\t\tText:      text,\n\t\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t\t}\n\t\t\tpayload := event.ToJSON()\n\t\t\tc.writeSingleEvent(\"OnExecuteStderr\", payload, true, event.Summary())\n\t\t},\n\t}\n}\n\n// writeSingleEvent serializes one SSE frame.\nfunc (c *CodeInterpretingController) writeSingleEvent(handler string, data []byte, verbose bool, summary string) {\n\tif c == nil || c.ctx == nil || c.ctx.Writer == nil {\n\t\treturn\n\t}\n\n\tselect {\n\tcase <-c.ctx.Request.Context().Done():\n\t\tlog.Error(\"StreamEvent.%s: client disconnected\", handler)\n\t\treturn\n\tdefault:\n\t}\n\n\tc.chunkWriter.Lock()\n\tdefer c.chunkWriter.Unlock()\n\tdefer func() {\n\t\tif flusher, ok := c.ctx.Writer.(http.Flusher); ok {\n\t\t\tflusher.Flush()\n\t\t}\n\t}()\n\n\tpayload := append(data, '\\n', '\\n')\n\tn, err := c.ctx.Writer.Write(payload)\n\tif err == nil && n != len(payload) {\n\t\terr = io.ErrShortWrite\n\t}\n\n\tif err != nil {\n\t\tlog.Error(\"StreamEvent.%s write data %s error: %v\", handler, summary, err)\n\t} else {\n\t\tif verbose {\n\t\t\tlog.Info(\"StreamEvent.%s write data %s\", handler, summary)\n\t\t}\n\t}\n}\n\n// ping periodically keeps the SSE connection alive.\nfunc (c *CodeInterpretingController) ping(ctx context.Context) {\n\twait.Until(func() {\n\t\tif c.ctx.Writer == nil {\n\t\t\treturn\n\t\t}\n\t\tevent := model.ServerStreamEvent{\n\t\t\tType:      model.StreamEventTypePing,\n\t\t\tText:      \"pong\",\n\t\t\tTimestamp: time.Now().UnixMilli(),\n\t\t}\n\t\tpayload := event.ToJSON()\n\t\tc.writeSingleEvent(\"Ping\", payload, false, event.Summary())\n\t}, 3*time.Second, ctx.Done())\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/syscall_linux.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build linux\n// +build linux\n\npackage controller\n\nimport (\n\t\"os\"\n\t\"syscall\"\n\t\"time\"\n)\n\nfunc getFileCreateTime(fileInfo os.FileInfo) time.Time {\n\tstat, ok := fileInfo.Sys().(*syscall.Stat_t)\n\tif !ok || stat == nil {\n\t\treturn fileInfo.ModTime()\n\t}\n\n\treturn time.Unix(stat.Ctim.Sec, stat.Ctim.Nsec)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/syscall_others.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !linux\n// +build !linux\n\npackage controller\n\nimport (\n\t\"os\"\n\t\"time\"\n)\n\nfunc getFileCreateTime(_ os.FileInfo) time.Time {\n\treturn time.Now()\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/test_helpers.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"bytes\"\n\t\"net/http/httptest\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// nolint:unused\nfunc newTestContext(method, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {\n\tgin.SetMode(gin.TestMode)\n\tw := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(w)\n\treq := httptest.NewRequest(method, path, bytes.NewReader(body))\n\tctx.Request = req\n\treturn ctx, w\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/utils.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !windows\n// +build !windows\n\npackage controller\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\nfunc DeleteFile(filePath string) error {\n\tabsPath, err := filepath.Abs(filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid path: %w\", err)\n\t}\n\n\tfileInfo, err := os.Stat(absPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tif fileInfo.IsDir() {\n\t\treturn fmt.Errorf(\"path is a directory: %s\", filePath)\n\t}\n\n\tif err := os.Remove(absPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ChmodFile(file string, perms model.Permission) error {\n\tabs, err := filepath.Abs(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif perms.Mode != 0 {\n\t\tmode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = os.Chmod(abs, os.FileMode(mode))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn SetFileOwnership(abs, perms.Owner, perms.Group)\n}\n\nfunc SetFileOwnership(absPath string, owner string, group string) error {\n\tuid := -1\n\tif owner != \"\" {\n\t\tuserInfo, err := user.Lookup(owner)\n\t\tif err != nil {\n\t\t\tlog.Warning(\"Failed to lookup user %s: %v\", owner, err)\n\t\t} else {\n\t\t\tuid, err = strconv.Atoi(userInfo.Uid)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warning(\"Failed to convert uid for user %s: %v\", owner, err)\n\t\t\t\tuid = -1\n\t\t\t}\n\t\t}\n\t}\n\n\tgid := -1\n\tif group != \"\" {\n\t\tgroupInfo, err := user.LookupGroup(group)\n\t\tif err != nil {\n\t\t\tlog.Warning(\"Failed to lookup group %s: %v\", group, err)\n\t\t} else {\n\t\t\tgid, err = strconv.Atoi(groupInfo.Gid)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warning(\"Failed to convert gid for group %s: %v\", group, err)\n\t\t\t\tgid = -1\n\t\t\t}\n\t\t}\n\t}\n\n\tif uid == -1 && gid == -1 {\n\t\tuid = os.Getuid()\n\t\tgid = os.Getgid()\n\t}\n\n\tif err := os.Chown(absPath, uid, gid); err != nil {\n\t\treturn fmt.Errorf(\"failed to set owner/group for %s: %w\", absPath, err)\n\t}\n\n\treturn nil\n}\n\nfunc RenameFile(item model.RenameFileItem) error {\n\tsrcPath, err := filepath.Abs(item.Src)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid source path: %w\", err)\n\t}\n\n\tdstPath, err := filepath.Abs(item.Dest)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid destination path: %w\", err)\n\t}\n\n\tif _, err := os.Stat(srcPath); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"source path not found: %s\", item.Src)\n\t}\n\n\tdstDir := filepath.Dir(dstPath)\n\n\tif err := os.MkdirAll(dstDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create destination directory: %w\", err)\n\t}\n\n\tif _, err := os.Stat(dstPath); err == nil {\n\t\treturn fmt.Errorf(\"destination path already exists: %s\", item.Dest)\n\t}\n\n\tif err := os.Rename(srcPath, dstPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to rename file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc MakeDir(dir string, perm model.Permission) error {\n\tabs, err := filepath.Abs(dir)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = os.MkdirAll(abs, os.ModePerm)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ChmodFile(abs, perm)\n}\n\nfunc GetFileInfo(filePath string) (model.FileInfo, error) {\n\tabsPath, err := filepath.Abs(filePath)\n\tif err != nil {\n\t\treturn model.FileInfo{}, fmt.Errorf(\"invalid path %s: %w\", filePath, err)\n\t}\n\n\tfileInfo, err := os.Stat(absPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn model.FileInfo{}, fmt.Errorf(\"file not found: %s\", filePath)\n\t\t}\n\t\treturn model.FileInfo{}, fmt.Errorf(\"error accessing file %s: %w\", filePath, err)\n\t}\n\n\tstat := fileInfo.Sys().(*syscall.Stat_t)\n\n\towner := strconv.FormatUint(uint64(stat.Uid), 10)\n\tif ownerUser, err := user.LookupId(owner); err == nil {\n\t\towner = ownerUser.Username\n\t}\n\n\tgroup := strconv.FormatUint(uint64(stat.Gid), 10)\n\tif groupInfo, err := user.LookupGroupId(group); err == nil {\n\t\tgroup = groupInfo.Name\n\t}\n\n\tmode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8)\n\n\treturn model.FileInfo{\n\t\tPath:       absPath,\n\t\tSize:       fileInfo.Size(),\n\t\tModifiedAt: fileInfo.ModTime(),\n\t\tCreatedAt:  getFileCreateTime(fileInfo),\n\t\tPermission: model.Permission{\n\t\t\tOwner: owner,\n\t\t\tGroup: group,\n\t\t\tMode:  func() int { i, _ := strconv.Atoi(mode); return i }(),\n\t\t},\n\t}, nil\n}\n\nfunc SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) {\n\tbase := filepath.Base(filePath)\n\tfor path, info := range metadata {\n\t\tif filepath.Base(path) == base {\n\t\t\treturn path, info, true\n\t\t}\n\t}\n\n\treturn \"\", model.FileMetadata{}, false\n}\n\ntype httpRange struct {\n\tstart, length int64\n}\n\nfunc ParseRange(s string, size int64) ([]httpRange, error) {\n\tif !strings.HasPrefix(s, \"bytes=\") {\n\t\treturn nil, errors.New(\"invalid range\")\n\t}\n\n\tranges := strings.Split(s[6:], \",\")\n\tresult := make([]httpRange, 0, len(ranges))\n\n\tfor _, ra := range ranges {\n\t\tra = strings.TrimSpace(ra)\n\t\tif ra == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ti := strings.Index(ra, \"-\")\n\t\tif i < 0 {\n\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t}\n\t\tstart, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:])\n\t\tvar r httpRange\n\n\t\tif start == \"\" {\n\t\t\t// suffix-length\n\t\t\tn, err := strconv.ParseInt(end, 10, 64)\n\t\t\tif err != nil || n < 0 {\n\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t}\n\t\t\tif n > size {\n\t\t\t\tn = size\n\t\t\t}\n\t\t\tr.start = size - n\n\t\t\tr.length = size - r.start\n\t\t} else {\n\t\t\t// start-end\n\t\t\ti, err := strconv.ParseInt(start, 10, 64)\n\t\t\tif err != nil || i < 0 {\n\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t}\n\t\t\tif end == \"\" {\n\t\t\t\t// start-\n\t\t\t\tr.start = i\n\t\t\t\tr.length = size - i\n\t\t\t} else {\n\t\t\t\t// start-end\n\t\t\t\tj, err := strconv.ParseInt(end, 10, 64)\n\t\t\t\tif err != nil || j < i {\n\t\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t\t}\n\t\t\t\tr.start = i\n\t\t\t\tr.length = j - i + 1\n\t\t\t}\n\t\t}\n\t\tif r.start >= size {\n\t\t\tcontinue\n\t\t}\n\t\tif r.start+r.length > size {\n\t\t\tr.length = size - r.start\n\t\t}\n\t\tresult = append(result, r)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/utils_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestDeleteFile(t *testing.T) {\n\ttmp := t.TempDir()\n\tfile := filepath.Join(tmp, \"sample.txt\")\n\trequire.NoError(t, os.WriteFile(file, []byte(\"hello\"), 0o644))\n\n\trequire.NoError(t, DeleteFile(file))\n\t_, err := os.Stat(file)\n\trequire.True(t, os.IsNotExist(err), \"expected file removed, got err=%v\", err)\n\n\t// removing a non-existent file should be a no-op\n\trequire.NoError(t, DeleteFile(file), \"expected no error deleting missing file\")\n}\n\nfunc TestRenameFile(t *testing.T) {\n\ttmp := t.TempDir()\n\tsrc := filepath.Join(tmp, \"src.txt\")\n\trequire.NoError(t, os.WriteFile(src, []byte(\"data\"), 0o644))\n\n\tdst := filepath.Join(tmp, \"nested\", \"renamed.txt\")\n\trequire.NoError(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst}))\n\n\t_, err := os.Stat(dst)\n\trequire.NoError(t, err)\n\t_, err = os.Stat(src)\n\trequire.True(t, os.IsNotExist(err), \"expected source removed, got err=%v\", err)\n\n\t// destination exists -> expect error\n\trequire.NoError(t, os.WriteFile(src, []byte(\"data\"), 0o644))\n\trequire.Error(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst}), \"expected error when destination already exists\")\n}\n\nfunc TestSearchFileMetadata(t *testing.T) {\n\tmetadata := map[string]model.FileMetadata{\n\t\t\"/tmp/a/notes.txt\": {Path: \"/tmp/a/notes.txt\"},\n\t\t\"/tmp/b/readme.md\": {Path: \"/tmp/b/readme.md\"},\n\t}\n\n\tpath, info, ok := SearchFileMetadata(metadata, \"/any/notes.txt\")\n\trequire.True(t, ok, \"expected metadata entry\")\n\trequire.Equal(t, \"/tmp/a/notes.txt\", path)\n\trequire.Equal(t, \"/tmp/a/notes.txt\", info.Path)\n\n\t_, _, ok = SearchFileMetadata(metadata, \"/foo/unknown.txt\")\n\trequire.False(t, ok, \"expected no match\")\n}\n\nfunc TestParseRange(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\theader    string\n\t\tsize      int64\n\t\twant      []httpRange\n\t\texpectErr bool\n\t}{\n\t\t{\n\t\t\tname:   \"start-end\",\n\t\t\theader: \"bytes=0-9\",\n\t\t\tsize:   20,\n\t\t\twant:   []httpRange{{start: 0, length: 10}},\n\t\t},\n\t\t{\n\t\t\tname:   \"suffix\",\n\t\t\theader: \"bytes=-5\",\n\t\t\tsize:   10,\n\t\t\twant:   []httpRange{{start: 5, length: 5}},\n\t\t},\n\t\t{\n\t\t\tname:      \"invalid\",\n\t\t\theader:    \"bytes=foo\",\n\t\t\tsize:      10,\n\t\t\texpectErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := ParseRange(tt.header, tt.size)\n\t\t\tif tt.expectErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.True(t, reflect.DeepEqual(got, tt.want), \"got %+v want %+v\", got, tt.want)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/controller/utils_windows.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build windows\n// +build windows\n\npackage controller\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\nfunc DeleteFile(filePath string) error {\n\tabsPath, err := filepath.Abs(filePath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid path: %w\", err)\n\t}\n\n\tfileInfo, err := os.Stat(absPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tif fileInfo.IsDir() {\n\t\treturn fmt.Errorf(\"path is a directory: %s\", filePath)\n\t}\n\n\tif err := os.Remove(absPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to remove file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ChmodFile(file string, perms model.Permission) error {\n\tabs, err := filepath.Abs(file)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif perms.Mode != 0 {\n\t\tmode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\terr = os.Chmod(abs, os.FileMode(mode))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn SetFileOwnership(abs, perms.Owner, perms.Group)\n}\n\n// SetFileOwnership is a placeholder on Windows where POSIX ownership is not supported.\nfunc SetFileOwnership(_ string, _ string, _ string) error {\n\t// TODO: add Windows ACL support if needed.\n\treturn nil\n}\n\nfunc RenameFile(item model.RenameFileItem) error {\n\tsrcPath, err := filepath.Abs(item.Src)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid source path: %w\", err)\n\t}\n\n\tdstPath, err := filepath.Abs(item.Dest)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid destination path: %w\", err)\n\t}\n\n\tif _, err := os.Stat(srcPath); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"source path not found: %s\", item.Src)\n\t}\n\n\tdstDir := filepath.Dir(dstPath)\n\n\tif err := os.MkdirAll(dstDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create destination directory: %w\", err)\n\t}\n\n\tif _, err := os.Stat(dstPath); err == nil {\n\t\treturn fmt.Errorf(\"destination path already exists: %s\", item.Dest)\n\t}\n\n\tif err := os.Rename(srcPath, dstPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to rename file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc MakeDir(dir string, perm model.Permission) error {\n\tabs, err := filepath.Abs(dir)\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = os.MkdirAll(abs, os.ModePerm)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn ChmodFile(abs, perm)\n}\n\nfunc GetFileInfo(filePath string) (model.FileInfo, error) {\n\tabsPath, err := filepath.Abs(filePath)\n\tif err != nil {\n\t\treturn model.FileInfo{}, fmt.Errorf(\"invalid path %s: %w\", filePath, err)\n\t}\n\n\tfileInfo, err := os.Stat(absPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn model.FileInfo{}, fmt.Errorf(\"file not found: %s\", filePath)\n\t\t}\n\t\treturn model.FileInfo{}, fmt.Errorf(\"error accessing file %s: %w\", filePath, err)\n\t}\n\n\tcreatedAt := getFileCreateTime(fileInfo)\n\tif data, ok := fileInfo.Sys().(*syscall.Win32FileAttributeData); ok && data != nil {\n\t\tcreatedAt = time.Unix(0, data.CreationTime.Nanoseconds())\n\t}\n\n\tmode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8)\n\n\treturn model.FileInfo{\n\t\tPath:       absPath,\n\t\tSize:       fileInfo.Size(),\n\t\tModifiedAt: fileInfo.ModTime(),\n\t\tCreatedAt:  createdAt,\n\t\tPermission: model.Permission{\n\t\t\tOwner: \"\",\n\t\t\tGroup: \"\",\n\t\t\tMode: func() int {\n\t\t\t\ti, _ := strconv.Atoi(mode)\n\t\t\t\treturn i\n\t\t\t}(),\n\t\t},\n\t}, nil\n}\n\nfunc SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) {\n\tbase := filepath.Base(filePath)\n\tfor path, info := range metadata {\n\t\tif filepath.Base(path) == base {\n\t\t\treturn path, info, true\n\t\t}\n\t}\n\n\treturn \"\", model.FileMetadata{}, false\n}\n\ntype httpRange struct {\n\tstart, length int64\n}\n\nfunc ParseRange(s string, size int64) ([]httpRange, error) {\n\tif !strings.HasPrefix(s, \"bytes=\") {\n\t\treturn nil, errors.New(\"invalid range\")\n\t}\n\n\tranges := strings.Split(s[6:], \",\")\n\tresult := make([]httpRange, 0, len(ranges))\n\n\tfor _, ra := range ranges {\n\t\tra = strings.TrimSpace(ra)\n\t\tif ra == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ti := strings.Index(ra, \"-\")\n\t\tif i < 0 {\n\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t}\n\t\tstart, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:])\n\t\tvar r httpRange\n\n\t\tif start == \"\" {\n\t\t\t// suffix-length\n\t\t\tn, err := strconv.ParseInt(end, 10, 64)\n\t\t\tif err != nil || n < 0 {\n\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t}\n\t\t\tif n > size {\n\t\t\t\tn = size\n\t\t\t}\n\t\t\tr.start = size - n\n\t\t\tr.length = size - r.start\n\t\t} else {\n\t\t\t// start-end\n\t\t\ti, err := strconv.ParseInt(start, 10, 64)\n\t\t\tif err != nil || i < 0 {\n\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t}\n\t\t\tif end == \"\" {\n\t\t\t\t// start-\n\t\t\t\tr.start = i\n\t\t\t\tr.length = size - i\n\t\t\t} else {\n\t\t\t\t// start-end\n\t\t\t\tj, err := strconv.ParseInt(end, 10, 64)\n\t\t\t\tif err != nil || j < i {\n\t\t\t\t\treturn nil, errors.New(\"invalid range\")\n\t\t\t\t}\n\t\t\t\tr.start = i\n\t\t\t\tr.length = j - i + 1\n\t\t\t}\n\t\t}\n\t\tif r.start >= size {\n\t\t\tcontinue\n\t\t}\n\t\tif r.start+r.length > size {\n\t\t\tr.length = size - r.start\n\t\t}\n\t\tresult = append(result, r)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/codeinterpreting.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/go-playground/validator/v10\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n)\n\n// RunCodeRequest represents a code execution request.\ntype RunCodeRequest struct {\n\tContext CodeContext `json:\"context,omitempty\"`\n\tCode    string      `json:\"code\" validate:\"required\"`\n}\n\nfunc (r *RunCodeRequest) Validate() error {\n\tvalidate := validator.New()\n\treturn validate.Struct(r)\n}\n\n// CodeContext tracks session metadata.\ntype CodeContext struct {\n\tID                 string `json:\"id,omitempty\"`\n\tCodeContextRequest `json:\",inline\"`\n}\n\ntype CodeContextRequest struct {\n\tLanguage string `json:\"language,omitempty\"`\n\tCwd      string `json:\"cwd,omitempty\"`\n}\n\n// RunCommandRequest represents a shell command execution request.\ntype RunCommandRequest struct {\n\tCommand    string `json:\"command\" validate:\"required\"`\n\tCwd        string `json:\"cwd,omitempty\"`\n\tBackground bool   `json:\"background,omitempty\"`\n\t// TimeoutMs caps execution duration; 0 uses server default.\n\tTimeoutMs int64 `json:\"timeout,omitempty\" validate:\"omitempty,gte=1\"`\n\n\tUid  *uint32           `json:\"uid,omitempty\"`\n\tGid  *uint32           `json:\"gid,omitempty\"`\n\tEnvs map[string]string `json:\"envs,omitempty\"`\n}\n\nfunc (r *RunCommandRequest) Validate() error {\n\tvalidate := validator.New()\n\tif err := validate.Struct(r); err != nil {\n\t\treturn err\n\t}\n\tif r.Gid != nil && r.Uid == nil {\n\t\treturn errors.New(\"uid is required when gid is provided\")\n\t}\n\treturn nil\n}\n\ntype ServerStreamEventType string\n\nconst (\n\tStreamEventTypeInit     ServerStreamEventType = \"init\"\n\tStreamEventTypeStatus   ServerStreamEventType = \"status\"\n\tStreamEventTypeError    ServerStreamEventType = \"error\"\n\tStreamEventTypeStdout   ServerStreamEventType = \"stdout\"\n\tStreamEventTypeStderr   ServerStreamEventType = \"stderr\"\n\tStreamEventTypeResult   ServerStreamEventType = \"result\"\n\tStreamEventTypeComplete ServerStreamEventType = \"execution_complete\"\n\tStreamEventTypeCount    ServerStreamEventType = \"execution_count\"\n\tStreamEventTypePing     ServerStreamEventType = \"ping\"\n)\n\n// ServerStreamEvent is emitted to clients over SSE.\ntype ServerStreamEvent struct {\n\tType           ServerStreamEventType `json:\"type,omitempty\"`\n\tText           string                `json:\"text,omitempty\"`\n\tExecutionCount int                   `json:\"execution_count,omitempty\"`\n\tExecutionTime  int64                 `json:\"execution_time,omitempty\"`\n\tTimestamp      int64                 `json:\"timestamp,omitempty\"`\n\tResults        map[string]any        `json:\"results,omitempty\"`\n\tError          *execute.ErrorOutput  `json:\"error,omitempty\"`\n}\n\n// ToJSON serializes the event for streaming.\nfunc (s ServerStreamEvent) ToJSON() []byte {\n\tbytes, _ := json.Marshal(s)\n\treturn bytes\n}\n\n// Summary renders a lightweight, log-friendly string without JSON.\nfunc (s ServerStreamEvent) Summary() string {\n\tparts := []string{fmt.Sprintf(\"type=%s\", s.Type)}\n\tif s.Text != \"\" {\n\t\tparts = append(parts, fmt.Sprintf(\"text=%s\", truncateString(s.Text, 100)))\n\t}\n\tif s.ExecutionTime > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"elapsed_ms=%d\", s.ExecutionTime))\n\t}\n\tif len(s.Results) > 0 {\n\t\tparts = append(parts, fmt.Sprintf(\"results=%d\", len(s.Results)))\n\t}\n\tif s.Error != nil {\n\t\terrLabel := s.Error.EName\n\t\tif errLabel == \"\" {\n\t\t\terrLabel = \"error\"\n\t\t}\n\t\tparts = append(parts, fmt.Sprintf(\"error=%s: %s\", errLabel, truncateString(s.Error.EValue, 80)))\n\t}\n\treturn strings.Join(parts, \" \")\n}\n\nfunc truncateString(value string, maxCount int) string {\n\tif maxCount <= 0 || len(value) <= maxCount {\n\t\treturn value\n\t}\n\treturn value[:maxCount] + \"...\"\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/codeinterpreting_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestRunCodeRequestValidate(t *testing.T) {\n\treq := RunCodeRequest{\n\t\tCode: \"print('hi')\",\n\t}\n\trequire.NoError(t, req.Validate())\n\n\treq.Code = \"\"\n\trequire.Error(t, req.Validate(), \"expected validation error when code is empty\")\n}\n\nfunc TestRunCommandRequestValidate(t *testing.T) {\n\treq := RunCommandRequest{Command: \"ls\"}\n\trequire.NoError(t, req.Validate(), \"expected command validation success\")\n\n\treq.TimeoutMs = -100\n\trequire.Error(t, req.Validate(), \"expected validation error when timeout is negative\")\n\n\treq.TimeoutMs = 0\n\treq.Command = \"ls\"\n\trequire.NoError(t, req.Validate(), \"expected success when timeout is omitted/zero\")\n\n\treq.TimeoutMs = 10\n\treq.Command = \"\"\n\trequire.Error(t, req.Validate(), \"expected validation error when command is empty\")\n}\n\nfunc ptr32(v uint32) *uint32 { return &v }\n\nfunc TestRunCommandRequestValidateUidGid(t *testing.T) {\n\t// uid-only: valid\n\treq := RunCommandRequest{Command: \"id\", Uid: ptr32(1000)}\n\trequire.NoError(t, req.Validate(), \"expected success with uid only\")\n\n\t// uid + gid: valid\n\treq = RunCommandRequest{Command: \"id\", Uid: ptr32(1000), Gid: ptr32(1000)}\n\trequire.NoError(t, req.Validate(), \"expected success with uid and gid\")\n\n\t// gid-only: must be rejected\n\treq = RunCommandRequest{Command: \"id\", Gid: ptr32(1000)}\n\trequire.Error(t, req.Validate(), \"expected validation error when gid is set without uid\")\n}\n\nfunc TestServerStreamEventToJSON(t *testing.T) {\n\tevent := ServerStreamEvent{\n\t\tType:           StreamEventTypeStdout,\n\t\tText:           \"hello\",\n\t\tExecutionCount: 3,\n\t}\n\n\tdata := event.ToJSON()\n\tvar decoded ServerStreamEvent\n\trequire.NoError(t, json.Unmarshal(data, &decoded))\n\trequire.Equal(t, event.Type, decoded.Type)\n\trequire.Equal(t, event.Text, decoded.Text)\n\trequire.Equal(t, event.ExecutionCount, decoded.ExecutionCount)\n}\n\nfunc TestServerStreamEventSummary(t *testing.T) {\n\tlongText := strings.Repeat(\"a\", 120)\n\ttests := []struct {\n\t\tname     string\n\t\tevent    ServerStreamEvent\n\t\tcontains []string\n\t}{\n\t\t{\n\t\t\tname: \"basic stdout\",\n\t\t\tevent: ServerStreamEvent{\n\t\t\t\tType:           StreamEventTypeStdout,\n\t\t\t\tText:           \"hello\",\n\t\t\t\tExecutionCount: 2,\n\t\t\t},\n\t\t\tcontains: []string{\"type=stdout\", \"text=hello\"},\n\t\t},\n\t\t{\n\t\t\tname: \"truncated text and error\",\n\t\t\tevent: ServerStreamEvent{\n\t\t\t\tType:  StreamEventTypeError,\n\t\t\t\tText:  longText,\n\t\t\t\tError: &execute.ErrorOutput{EName: \"ValueError\", EValue: \"boom\"},\n\t\t\t},\n\t\t\tcontains: []string{\n\t\t\t\t\"type=error\",\n\t\t\t\t\"text=\" + strings.Repeat(\"a\", 100) + \"...\",\n\t\t\t\t\"error=ValueError: boom\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsummary := tt.event.Summary()\n\t\t\tfor _, want := range tt.contains {\n\t\t\t\trequire.Containsf(t, summary, want, \"summary missing %q\", want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/command.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport \"time\"\n\n// CommandStatusResponse represents command status for REST APIs.\ntype CommandStatusResponse struct {\n\tID         string     `json:\"id\"`\n\tContent    string     `json:\"content,omitempty\"`\n\tRunning    bool       `json:\"running\"`\n\tExitCode   *int       `json:\"exit_code,omitempty\"`\n\tError      string     `json:\"error,omitempty\"`\n\tStartedAt  time.Time  `json:\"started_at,omitempty\"`\n\tFinishedAt *time.Time `json:\"finished_at,omitempty\"`\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/error.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\ntype ErrorCode string\n\nconst (\n\tErrorCodeInvalidRequest      ErrorCode = \"INVALID_REQUEST_BODY\"\n\tErrorCodeMissingQuery        ErrorCode = \"MISSING_QUERY\"\n\tErrorCodeRuntimeError        ErrorCode = \"RUNTIME_ERROR\"\n\tErrorCodeInvalidFile         ErrorCode = \"INVALID_FILE\"\n\tErrorCodeInvalidFileContent  ErrorCode = \"INVALID_FILE_CONTENT\"\n\tErrorCodeInvalidFileMetadata ErrorCode = \"INVALID_FILE_METADATA\"\n\tErrorCodeFileNotFound        ErrorCode = \"FILE_NOT_FOUND\"\n\tErrorCodeUnknown             ErrorCode = \"UNKNOWN\"\n\tErrorCodeContextNotFound     ErrorCode = \"CONTEXT_NOT_FOUND\"\n)\n\ntype ErrorResponse struct {\n\tCode    ErrorCode `json:\"code,omitempty\"`\n\tMessage string    `json:\"message,omitempty\"`\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/filesystem.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport \"time\"\n\n// FileInfo represents file metadata including path and permissions\ntype FileInfo struct {\n\tPath       string    `json:\"path,omitempty\"`\n\tSize       int64     `json:\"size\"`\n\tModifiedAt time.Time `json:\"modified_at,omitempty\"`\n\tCreatedAt  time.Time `json:\"created_at,omitempty\"`\n\tPermission `json:\",inline\"`\n}\n\ntype FileMetadata struct {\n\tPath       string `json:\"path,omitempty\"`\n\tPermission `json:\",inline\"`\n}\n\n// Permission represents file ownership and mode\ntype Permission struct {\n\tOwner string `json:\"owner\"`\n\tGroup string `json:\"group\"`\n\tMode  int    `json:\"mode\"`\n}\n\n// RenameFileItem represents a file rename operation\ntype RenameFileItem struct {\n\tSrc  string `json:\"src,omitempty\"`\n\tDest string `json:\"dest,omitempty\"`\n}\n\n// ReplaceFileContentItem represents a content replacement operation\ntype ReplaceFileContentItem struct {\n\tOld string `json:\"old,omitempty\"`\n\tNew string `json:\"new,omitempty\"`\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/header.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nconst (\n\t// ApiAccessTokenHeader carries the auth token.\n\tApiAccessTokenHeader = \"X-EXECD-ACCESS-TOKEN\"\n)\n"
  },
  {
    "path": "components/execd/pkg/web/model/metric.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport \"time\"\n\n// Metrics represents system resource usage metrics\ntype Metrics struct {\n\tCpuCount    float64 `json:\"cpu_count\"`\n\tCpuUsedPct  float64 `json:\"cpu_used_pct\"`\n\tMemTotalMiB float64 `json:\"mem_total_mib\"`\n\tMemUsedMiB  float64 `json:\"mem_used_mib\"`\n\tTimestamp   int64   `json:\"timestamp\"`\n}\n\nfunc NewMetrics() *Metrics {\n\treturn &Metrics{\n\t\tCpuCount:    0,\n\t\tCpuUsedPct:  0,\n\t\tMemTotalMiB: 0,\n\t\tMemUsedMiB:  0,\n\t\tTimestamp:   time.Now().UnixMilli(),\n\t}\n}\n"
  },
  {
    "path": "components/execd/pkg/web/model/session.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage model\n\nimport (\n\t\"github.com/go-playground/validator/v10\"\n)\n\n// CreateSessionRequest is the request body for creating a bash session.\ntype CreateSessionRequest struct {\n\tCwd string `json:\"cwd,omitempty\"`\n}\n\n// CreateSessionResponse is the response for create_session.\ntype CreateSessionResponse struct {\n\tSessionID string `json:\"session_id\"`\n}\n\n// RunInSessionRequest is the request body for running code in an existing session.\ntype RunInSessionRequest struct {\n\tCode      string `json:\"code\" validate:\"required\"`\n\tCwd       string `json:\"cwd,omitempty\"`\n\tTimeoutMs int64  `json:\"timeout_ms,omitempty\" validate:\"omitempty,gte=0\"`\n}\n\n// Validate validates RunInSessionRequest.\nfunc (r *RunInSessionRequest) Validate() error {\n\tvalidate := validator.New()\n\treturn validate.Struct(r)\n}\n"
  },
  {
    "path": "components/execd/pkg/web/proxy.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage web\n\nimport (\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n)\n\nfunc ProxyMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif !strings.HasPrefix(c.Request.URL.Path, \"/proxy/\") {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tr := c.Request\n\t\tw := c.Writer\n\n\t\trest := strings.TrimPrefix(r.URL.Path, \"/proxy/\")\n\t\tparts := strings.SplitN(rest, \"/\", 2)\n\t\tif len(parts) == 0 || parts[0] == \"\" {\n\t\t\thttp.Error(w, \"port is required\", http.StatusBadRequest)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tport := parts[0]\n\t\tpath := \"/\"\n\t\tif len(parts) == 2 && parts[1] != \"\" {\n\t\t\tpath += parts[1]\n\t\t}\n\n\t\ttarget := &url.URL{\n\t\t\tScheme: \"http\",\n\t\t\tHost:   \"127.0.0.1:\" + port,\n\t\t\tPath:   path,\n\t\t}\n\n\t\tisWebSocket := strings.ToLower(r.Header.Get(\"Upgrade\")) == \"websocket\"\n\n\t\tproxy := httputil.NewSingleHostReverseProxy(target)\n\t\t// Flush SSE chunks promptly; a small interval avoids buffering breaks chunked streams.\n\t\tproxy.FlushInterval = 200 * time.Millisecond\n\n\t\tproxy.Director = func(req *http.Request) {\n\t\t\treq.URL.Scheme = \"http\"\n\t\t\treq.URL.Host = \"127.0.0.1:\" + port\n\t\t\treq.URL.Path = path\n\t\t\treq.URL.RawQuery = r.URL.RawQuery\n\t\t\treq.URL.RawPath = \"\"\n\t\t\treq.RequestURI = \"\"\n\n\t\t\treq.Header.Set(\"X-Forwarded-For\", getClientIP(r))\n\t\t\treq.Header.Set(\"X-Forwarded-Proto\", \"http\")\n\t\t\treq.Header.Del(\"X-Forwarded-Host\")\n\n\t\t\tif isWebSocket {\n\t\t\t\treq.Header.Set(\"Connection\", \"Upgrade\")\n\t\t\t\treq.Header.Set(\"Upgrade\", \"websocket\")\n\t\t\t\treq.Header.Set(\"Sec-WebSocket-Version\", \"13\")\n\t\t\t\tif key := r.Header.Get(\"Sec-WebSocket-Key\"); key != \"\" {\n\t\t\t\t\treq.Header.Set(\"Sec-WebSocket-Key\", key)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tproxy.Transport = &http.Transport{\n\t\t\tDialContext: (&net.Dialer{\n\t\t\t\tTimeout:   600 * time.Second,\n\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t}).DialContext,\n\t\t\tMaxIdleConns:        100,\n\t\t\tMaxIdleConnsPerHost: 100,\n\t\t\tIdleConnTimeout:     600 * time.Second,\n\t\t}\n\n\t\tproxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {\n\t\t\tlog.Error(\"Proxy error: %v, request: %s %s\", err, req.Method, req.RequestURI)\n\t\t\thttp.Error(rw, \"Bad Gateway\", http.StatusBadGateway)\n\t\t}\n\n\t\tlog.Info(\"Proxy: %s %s -> %s (WebSocket: %v)\", r.Method, r.RequestURI, target.Host, isWebSocket)\n\n\t\tproxy.ServeHTTP(w, r)\n\t\tc.Abort()\n\t}\n}\n\nfunc getClientIP(r *http.Request) string {\n\tif ip := r.Header.Get(\"X-Forwarded-For\"); ip != \"\" {\n\t\treturn strings.Split(ip, \",\")[0]\n\t}\n\tif ip := r.Header.Get(\"X-Real-IP\"); ip != \"\" {\n\t\treturn ip\n\t}\n\treturn r.RemoteAddr\n}\n"
  },
  {
    "path": "components/execd/pkg/web/router.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage web\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/alibaba/opensandbox/execd/pkg/log\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/controller\"\n\t\"github.com/alibaba/opensandbox/execd/pkg/web/model\"\n)\n\n// NewRouter builds a Gin engine with all execd routes.\nfunc NewRouter(accessToken string) *gin.Engine {\n\tgin.SetMode(gin.ReleaseMode)\n\tr := gin.New()\n\tr.Use(gin.Recovery())\n\tr.Use(logMiddleware(), accessTokenMiddleware(accessToken), ProxyMiddleware())\n\n\tr.GET(\"/ping\", controller.PingHandler)\n\n\tfiles := r.Group(\"/files\")\n\t{\n\t\tfiles.DELETE(\"\", withFilesystem(func(c *controller.FilesystemController) { c.RemoveFiles() }))\n\t\tfiles.GET(\"/info\", withFilesystem(func(c *controller.FilesystemController) { c.GetFilesInfo() }))\n\t\tfiles.POST(\"/mv\", withFilesystem(func(c *controller.FilesystemController) { c.RenameFiles() }))\n\t\tfiles.POST(\"/permissions\", withFilesystem(func(c *controller.FilesystemController) { c.ChmodFiles() }))\n\t\tfiles.GET(\"/search\", withFilesystem(func(c *controller.FilesystemController) { c.SearchFiles() }))\n\t\tfiles.POST(\"/replace\", withFilesystem(func(c *controller.FilesystemController) { c.ReplaceContent() }))\n\t\tfiles.POST(\"/upload\", withFilesystem(func(c *controller.FilesystemController) { c.UploadFile() }))\n\t\tfiles.GET(\"/download\", withFilesystem(func(c *controller.FilesystemController) { c.DownloadFile() }))\n\t}\n\n\tdirectories := r.Group(\"/directories\")\n\t{\n\t\tdirectories.POST(\"\", withFilesystem(func(c *controller.FilesystemController) { c.MakeDirs() }))\n\t\tdirectories.DELETE(\"\", withFilesystem(func(c *controller.FilesystemController) { c.RemoveDirs() }))\n\t}\n\n\tcode := r.Group(\"/code\")\n\t{\n\t\tcode.POST(\"\", withCode(func(c *controller.CodeInterpretingController) { c.RunCode() }))\n\t\tcode.DELETE(\"\", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCode() }))\n\t\tcode.POST(\"/context\", withCode(func(c *controller.CodeInterpretingController) { c.CreateContext() }))\n\t\tcode.GET(\"/contexts\", withCode(func(c *controller.CodeInterpretingController) { c.ListContexts() }))\n\t\tcode.DELETE(\"/contexts\", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContextsByLanguage() }))\n\t\tcode.DELETE(\"/contexts/:contextId\", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContext() }))\n\t\tcode.GET(\"/contexts/:contextId\", withCode(func(c *controller.CodeInterpretingController) { c.GetContext() }))\n\t}\n\n\tsession := r.Group(\"/session\")\n\t{\n\t\tsession.POST(\"\", withCode(func(c *controller.CodeInterpretingController) { c.CreateSession() }))\n\t\tsession.POST(\"/:sessionId/run\", withCode(func(c *controller.CodeInterpretingController) { c.RunInSession() }))\n\t\tsession.DELETE(\"/:sessionId\", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() }))\n\t}\n\n\tcommand := r.Group(\"/command\")\n\t{\n\t\tcommand.POST(\"\", withCode(func(c *controller.CodeInterpretingController) { c.RunCommand() }))\n\t\tcommand.DELETE(\"\", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCommand() }))\n\t\tcommand.GET(\"/status/:id\", withCode(func(c *controller.CodeInterpretingController) { c.GetCommandStatus() }))\n\t\tcommand.GET(\"/:id/logs\", withCode(func(c *controller.CodeInterpretingController) { c.GetBackgroundCommandOutput() }))\n\t}\n\n\tmetric := r.Group(\"/metrics\")\n\t{\n\t\tmetric.GET(\"\", withMetric(func(c *controller.MetricController) { c.GetMetrics() }))\n\t\tmetric.GET(\"/watch\", withMetric(func(c *controller.MetricController) { c.WatchMetrics() }))\n\t}\n\n\treturn r\n}\n\nfunc withFilesystem(fn func(*controller.FilesystemController)) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tfn(controller.NewFilesystemController(ctx))\n\t}\n}\n\nfunc withCode(fn func(*controller.CodeInterpretingController)) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tfn(controller.NewCodeInterpretingController(ctx))\n\t}\n}\n\nfunc withMetric(fn func(*controller.MetricController)) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tfn(controller.NewMetricController(ctx))\n\t}\n}\n\nfunc accessTokenMiddleware(token string) gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tif token == \"\" {\n\t\t\tctx.Next()\n\t\t\treturn\n\t\t}\n\n\t\trequestedToken := ctx.GetHeader(model.ApiAccessTokenHeader)\n\t\tif requestedToken == \"\" || requestedToken != token {\n\t\t\tctx.AbortWithStatusJSON(http.StatusUnauthorized, map[string]any{\n\t\t\t\t\"error\": \"Unauthorized: invalid or missing header \" + model.ApiAccessTokenHeader,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tctx.Next()\n\t}\n}\n\nfunc logMiddleware() gin.HandlerFunc {\n\treturn func(ctx *gin.Context) {\n\t\tlog.Info(\"Requested: %v - %v\", ctx.Request.Method, ctx.Request.URL.String())\n\t\tctx.Next()\n\t}\n}\n"
  },
  {
    "path": "components/execd/tests/jupyter.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nexport JUPYTER_PORT=54321\nexport JUPYTER_TOKEN=opensandboxexecdintegrationtest\n\ninstall_jupyter() {\n\t# install jupyter notebook for integration testing\n\tpython --version\n\tpip install ipykernel jupyter\n\n\techo \"Starting jupyter notebook ...\"\n\tjupyter notebook --ip=0.0.0.0 --port=$JUPYTER_PORT --allow-root --no-browser --NotebookApp.token=$JUPYTER_TOKEN >/tmp/jupyter.log 2>&1 &\n\n\tsleep 3\n}\n"
  },
  {
    "path": "components/execd/tests/smoke.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nset -euxo pipefail\n\nsource tests/jupyter.sh\ninstall_jupyter\n\nexport EXECD_API_GRACE_SHUTDOWN=500ms\nexport EXECD_LOG_FILE=execd.log\n./bin/execd -jupyter-host=http://127.0.0.1:${JUPYTER_PORT} --jupyter-token=${JUPYTER_TOKEN} --log-level=7 >startup.log 2>&1 &\n"
  },
  {
    "path": "components/execd/tests/smoke_api.py",
    "content": "#!/usr/bin/env python3\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nSimple smoke tests for execd APIs.\n\nPrerequisites:\n- execd server running locally (default http://localhost:44772)\n- Optional: set env BASE_URL to override\n- Optional: set env API_TOKEN if server expects X-EXECD-ACCESS-TOKEN\n\"\"\"\n\nimport json\nimport os\nimport sys\nimport time\nimport uuid\nimport tempfile\nimport pathlib\n\nimport requests\n\nBASE_URL = os.environ.get(\"BASE_URL\", \"http://localhost:44772\").rstrip(\"/\")\nAPI_TOKEN = os.environ.get(\"API_TOKEN\")\n\nHEADERS = {}\nif API_TOKEN:\n    HEADERS[\"X-EXECD-ACCESS-TOKEN\"] = API_TOKEN\n\nsession = requests.Session()\nsession.headers.update(HEADERS)\n\n\ndef expect(cond: bool, msg: str):\n    if not cond:\n        raise SystemExit(msg)\n\n\ndef sse_get_command_id() -> str:\n    url = f\"{BASE_URL}/command\"\n    payload = {\"command\": \"echo smoke-command && sleep 1\", \"background\": True}\n    with session.post(url, json=payload, stream=True, timeout=15) as resp:\n        expect(resp.status_code == 200, f\"SSE start failed: {resp.status_code} {resp.text}\")\n        for line in resp.iter_lines():\n            if not line or not line.startswith(b\"data:\"):\n                # controller emits raw JSON lines without SSE 'data:' prefix\n                try:\n                    data = json.loads(line.decode())\n                except Exception:\n                    continue\n            else:\n                data = json.loads(line[len(b\"data:\") :].decode())\n            if data.get(\"type\") == \"init\":\n                cmd_id = data.get(\"text\")\n                expect(cmd_id, \"missing command id in init event\")\n                return cmd_id\n    raise SystemExit(\"Failed to obtain command id from SSE\")\n\n\ndef wait_status(cmd_id: str, timeout: float = 15.0) -> dict:\n    url = f\"{BASE_URL}/command/status/{cmd_id}\"\n    deadline = time.time() + timeout\n    last = None\n    while time.time() < deadline:\n        r = session.get(url, timeout=5)\n        expect(r.status_code == 200, f\"status failed: {r.status_code} {r.text}\")\n        last = r.json()\n        if not last.get(\"running\", True):\n            return last\n        time.sleep(0.3)\n    return last\n\n\ndef fetch_logs(cmd_id: str, cursor: int = 0):\n    url = f\"{BASE_URL}/command/{cmd_id}/logs\"\n    r = session.get(url, params={\"cursor\": cursor}, timeout=10)\n    expect(r.status_code == 200, f\"logs failed: {r.status_code} {r.text}\")\n    return r.text, r.headers.get(\"EXECD-COMMANDS-TAIL-CURSOR\")\n\n\ndef sse_disconnect_should_stop_ping():\n    \"\"\"\n    Open an SSE stream for a long-running command, receive init, then close the\n    client side early to ensure the server handles disconnects (ping loop should\n    stop). We verify the server is still responsive afterwards.\n    \"\"\"\n    url = f\"{BASE_URL}/command\"\n    payload = {\n        # long command so the server would keep pinging if not cancelled\n        \"command\": \"sh -c 'echo long-run-start && sleep 20 && echo long-run-end'\",\n        \"background\": False,\n    }\n\n    with session.post(url, json=payload, stream=True, timeout=10) as resp:\n        expect(resp.status_code == 200, f\"SSE start failed: {resp.status_code} {resp.text}\")\n        for line in resp.iter_lines():\n            if not line:\n                continue\n            try:\n                if line.startswith(b\"data:\"):\n                    data = json.loads(line[len(b\"data:\") :].decode())\n                else:\n                    data = json.loads(line.decode())\n            except Exception:\n                continue\n            if data.get(\"type\") == \"init\":\n                break\n        # explicitly close to simulate client drop\n        resp.close()\n\n    # Give server a moment to observe disconnect and ensure API remains healthy\n    time.sleep(1)\n    pong = session.get(f\"{BASE_URL}/ping\", timeout=5)\n    expect(pong.status_code == 200, \"ping failed after SSE disconnect\")\n\n\ndef upload_and_download():\n    tmp_dir = f\"/tmp/execd-smoke-{uuid.uuid4().hex}\"\n    path = f\"{tmp_dir}/hello.txt\"\n    metadata = json.dumps({\"path\": path})\n    files = {\n        \"metadata\": (\"metadata\", metadata, \"application/json\"),\n        \"file\": (\"file\", b\"hello execd\\n\", \"application/octet-stream\"),\n    }\n    up = session.post(f\"{BASE_URL}/files/upload\", files=files, timeout=10)\n    expect(up.status_code == 200, f\"upload failed: {up.status_code} {up.text}\")\n\n    down = session.get(f\"{BASE_URL}/files/download\", params={\"path\": path}, timeout=10)\n    expect(down.status_code == 200, f\"download failed: {down.status_code} {down.text}\")\n    expect(down.content == b\"hello execd\\n\", \"downloaded content mismatch\")\n\n\ndef filesystem_smoke():\n    base_dir = os.path.join(tempfile.gettempdir(), f\"execd-smoke-{uuid.uuid4().hex}\")\n    sub_dir = os.path.join(base_dir, \"sub\")\n    file_path = os.path.join(sub_dir, \"hello.txt\")\n    renamed_path = os.path.join(sub_dir, \"hello_renamed.txt\")\n\n    # create dirs\n    mk = session.post(f\"{BASE_URL}/directories\", json={sub_dir: {\"mode\": 0}}, timeout=10)\n    expect(mk.status_code == 200, f\"mkdir failed: {mk.status_code} {mk.text}\")\n\n    # upload a file\n    metadata = json.dumps({\"path\": file_path})\n    files = {\n        \"metadata\": (\"metadata\", metadata, \"application/json\"),\n        \"file\": (\"file\", b\"hello execd\\n\", \"application/octet-stream\"),\n    }\n    up = session.post(f\"{BASE_URL}/files/upload\", files=files, timeout=10)\n    expect(up.status_code == 200, f\"upload failed: {up.status_code} {up.text}\")\n\n    # get info\n    info = session.get(f\"{BASE_URL}/files/info\", params={\"path\": [file_path]}, timeout=10)\n    expect(info.status_code == 200, f\"info failed: {info.status_code} {info.text}\")\n\n    # search\n    search = session.get(f\"{BASE_URL}/files/search\", params={\"path\": base_dir, \"pattern\": \"*.txt\"}, timeout=10)\n    expect(search.status_code == 200, f\"search failed: {search.status_code} {search.text}\")\n    found = False\n    for f in search.json():\n        p = f.get(\"path\")\n        if not p:\n            continue\n        if pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve():\n            found = True\n            break\n    expect(found, \"search did not find file\")\n\n    # replace content\n    rep = session.post(\n        f\"{BASE_URL}/files/replace\",\n        json={file_path: {\"old\": \"hello\", \"new\": \"hi\"}},\n        timeout=10,\n    )\n    expect(rep.status_code == 200, f\"replace failed: {rep.status_code} {rep.text}\")\n\n    # download to verify replace\n    down = session.get(f\"{BASE_URL}/files/download\", params={\"path\": file_path}, timeout=10)\n    expect(down.status_code == 200, f\"download failed: {down.status_code} {down.text}\")\n    expect(down.content == b\"hi execd\\n\", \"replace content mismatch\")\n\n    # chmod (mode only)\n    chmod = session.post(f\"{BASE_URL}/files/permissions\", json={file_path: {\"mode\": 644}}, timeout=10)\n    expect(chmod.status_code == 200, f\"chmod failed: {chmod.status_code} {chmod.text}\")\n\n    # rename\n    mv = session.post(\n        f\"{BASE_URL}/files/mv\",\n        json=[{\"src\": file_path, \"dest\": renamed_path}],\n        timeout=10,\n    )\n    expect(mv.status_code == 200, f\"rename failed: {mv.status_code} {mv.text}\")\n\n    # remove file\n    rm_file = session.delete(f\"{BASE_URL}/files\", params={\"path\": [renamed_path]}, timeout=10)\n    expect(rm_file.status_code == 200, f\"remove file failed: {rm_file.status_code} {rm_file.text}\")\n\n    # remove dir\n    rm_dir = session.delete(f\"{BASE_URL}/directories\", params={\"path\": [base_dir]}, timeout=10)\n    expect(rm_dir.status_code == 200, f\"remove dir failed: {rm_dir.status_code} {rm_dir.text}\")\n\n\ndef main():\n    print(f\"[+] base: {BASE_URL}\")\n    r = session.get(f\"{BASE_URL}/ping\", timeout=5)\n    expect(r.status_code == 200, \"ping failed\")\n    print(\"[+] ping ok\")\n\n    sse_disconnect_should_stop_ping()\n    print(\"[+] SSE disconnect handled\")\n\n    cmd_id = sse_get_command_id()\n    print(f\"[+] command id: {cmd_id}\")\n\n    status = wait_status(cmd_id)\n    print(f\"[+] status: {status}\")\n\n    logs, cursor = fetch_logs(cmd_id, cursor=0)\n    print(f\"[+] logs (cursor={cursor}):\\n{logs}\")\n\n    filesystem_smoke()\n    print(\"[+] filesystem APIs ok\")\n\n    print(\"[+] smoke tests PASS\")\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except SystemExit as exc:\n        print(f\"[!] smoke tests FAIL: {exc}\", file=sys.stderr)\n        sys.exit(1)"
  },
  {
    "path": "components/ingress/.golangci.yml",
    "content": "run:\n  skip-dirs:\n    - vendor\n    - tests\n    - scripts\n  skip-files:\n    - .*/zz_generated.deepcopy.go\n    - .*/mock/*.go\n  tests: false\n  timeout: 10m\nlinters-settings:\n  funlen:\n    lines: 500\n    statements: 200\n  gocyclo:\n    min-complexity: 40\n  gosimple:\n    checks: [\"S1019\", \"S1002\"]\n  staticcheck:\n    checks: [\"SA4006\"]\n  govet:\n    enable:\n      - asmdecl\n      - assign\n      - atomic\n      - atomicalign\n      - bools\n      - buildtag\n      - cgocall\n      - copylocks\n      - deepequalerrors\n      - errorsas\n      - findcall\n      - framepointer\n      - httpresponse\n      - ifaceassert\n      - lostcancel\n      - nilfunc\n      - nilness\n      - reflectvaluecompare\n      - shift\n      - sigchanyzer\n      - sortslice\n      - stdmethods\n      - stringintconv\n      - testinggoroutine\n      - tests\n      - unmarshal\n      - unreachable\n      - unsafeptr\n      - unusedresult\n      - printf\n    disable:\n      - composites\n      - loopclosure\n      - fieldalignment\n      - shadow\n      - structtag\n      - unusedwrite\n  errcheck:\n    exclude-functions:\n    - flag.Set\n    - os.Setenv\n    - os.Unsetenv\n    - logger.Sync\n    - fmt.Fprintf\n    - fmt.Fprintln\n    - (io.Closer).Close\n    - (io.ReadCloser).Close\n    - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler\n  nestif:\n    # 复杂度大于32的认为阻塞\n    min-complexity: 32\n  goconst:\n    # Minimal length of string constant.\n    # Default: 3\n    min-len: 3\n    # Minimum occurrences of constant string count to trigger issue.\n    # Default: 3\n    min-occurrences: 3\n    # Ignore test files.\n    # Default: false\n    ignore-tests: true\n    match-constant: false\n    numbers: true\n    min: 2\n    max: 10\n    ignore-calls: true\n  gosec:\n    includes:\n      - G101 # Look for hard coded credentials\n      - G102 # Bind to all interfaces\n      - G103 # Audit the use of unsafe block\n      - G104 # Audit errors not checked\n      - G106 # Audit the use of ssh.InsecureIgnoreHostKey\n      - G107 # Url provided to HTTP request as taint input\n      - G108 # Profiling endpoint automatically exposed on /debug/pprof\n      - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32\n      - G110 # Potential DoS vulnerability via decompression bomb\n      - G111 # Potential directory traversal\n      - G112 # Potential slowloris attack\n      - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772)\n      # - G114 # Use of net/http serve function that has no support for setting timeouts\n      - G201 # SQL query construction using format string\n      - G202 # SQL query construction using string concatenation\n      - G203 # Use of unescaped data in HTML templates\n      #- G204 # Audit use of command execution\n      - G301 # Poor file permissions used when creating a directory\n      - G302 # Poor file permissions used with chmod\n      - G303 # Creating tempfile using a predictable path\n      - G304 # File path provided as taint input\n      - G305 # File traversal when extracting zip/tar archive\n      - G306 # Poor file permissions used when writing to a new file\n      - G307 # Deferring a method which returns an error\n      #- G401 # Detect the usage of DES, RC4, MD5 or SHA1\n      - G402 # Look for bad TLS connection settings\n      - G403 # Ensure minimum RSA key length of 2048 bits\n      - G404 # Insecure random number source (rand)\n      #- G501 # Import blocklist: crypto/md5\n      - G502 # Import blocklist: crypto/des\n      - G503 # Import blocklist: crypto/rc4\n      - G504 # Import blocklist: net/http/cgi\n      - G505 # Import blocklist: crypto/sha1\n      - G601 # Implicit memory aliasing of items from a range statement\n    # Exclude generated files\n    # Default: false\n    exclude-generated: true\n    # Filter out the issues with a lower severity than the given value.\n    # Valid options are: low, medium, high.\n    # Default: low\n    severity: medium\n    # Filter out the issues with a lower confidence than the given value.\n    # Valid options are: low, medium, high.\n    # Default: low\n    confidence: medium\n    # Concurrency value.\n    # Default: the number of logical CPUs usable by the current process.\n    concurrency: 12\n    # To specify the configuration of rules.\n    config:\n      # Globals are applicable to all rules.\n      global:\n        nosec: true\n        show-ignored: true\n        audit: true\n      G101:\n        # Regexp pattern for variables and constants to find.\n        # Default: \"(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred\"\n        pattern: \"(?i)example\"\n        # If true, complain about all cases (even with low entropy).\n        # Default: false\n        ignore_entropy: false\n        # Maximum allowed entropy of the string.\n        # Default: \"80.0\"\n        entropy_threshold: \"80.0\"\n        per_char_threshold: \"3.0\"\n        truncate: \"32\"\n      G104:\n        fmt:\n          - Fscanf\n      G111:\n        # Regexp pattern to find potential directory traversal.\n        # Default: \"http\\\\.Dir\\\\(\\\"\\\\/\\\"\\\\)|http\\\\.Dir\\\\('\\\\/'\\\\)\"\n        pattern: \"custom\\\\.Dir\\\\(\\\\)\"\n      # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll\n      # Default: \"0750\"\n      G301: \"0750\"\n      # Maximum allowed permissions mode for os.OpenFile and os.Chmod\n      # Default: \"0600\"\n      G302: \"0600\"\n      # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile\n      # Default: \"0600\"\n      G306: \"0600\"\n  nilnil:\n    checked-types:\n      - ptr\n      - map\n      - chan\n  depguard:\n    rules:\n      prevent_unmaintained_packages:\n        list-mode: lax # allow unless explicitely denied\n        files:\n          - $all\n          - \"!$test\"\n        allow:\n          - $gostd\n          - path/filepath\n        deny:\n          - pkg: io/ioutil\n            desc: \"replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil\"\n          - pkg: path\n            desc: \"replaced by cross-platform package path/filepath\"\n  gci:\n    # Section configuration to compare against.\n    # Section names are case-insensitive and may contain parameters in ().\n    # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`,\n    # If `custom-order` is `true`, it follows the order of `sections` option.\n    # Default: [\"standard\", \"default\"]\n    sections:\n      - standard # Standard section: captures all standard packages.\n      - default # Default section: contains all imports that could not be matched to another section type.:\n      - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix.\n      - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled.\n      - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled.\n      - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled.\n    # Skip generated files.\n    # Default: true\n    skip-generated: true\n    # Enable custom order of sections.\n    # If `true`, make the section order the same as the order of `sections`.\n    # Default: false\n    custom-order: true\n    # Drops lexical ordering for custom sections.\n    # Default: false\n    no-lex-order: true\n  forbidigo:\n    forbid:\n      # Forbid spew Dump, whether it is called as function or method.\n      # Depends on analyze-types below.\n      - ^spew\\.(ConfigState\\.)?Dump$\n      # The package name might be ambiguous.\n      # The full import path can be used as additional criteria.\n      # Depends on analyze-types below.\n      - p: ^v1.Dump$\n        pkg: ^example.com/pkg/api/v1$\n\nlinters:\n  enable:\n    - asasalint\n    - asciicheck\n    - bidichk\n    - bodyclose\n    # - cyclop\n    - decorder\n    - depguard\n    - errcheck\n    # - errchkjson\n    - errorlint\n    - forbidigo\n    # - forcetypeassert\n    - funlen\n    - ineffassign\n    - gocognit\n    - gocyclo\n    - goheader\n    - gomodguard\n    - goprintffuncname\n    - gosimple\n    - gosec\n    - grouper\n    - importas\n    - maintidx\n    - misspell\n    - nakedret\n    - nilerr\n    - nilnil\n    # - noctx\n    - nosprintfhostport\n    - paralleltest\n    - predeclared\n    # - promlinter\n    - reassign\n    - sqlclosecheck\n    - staticcheck\n    - tenv\n    - testpackage\n    - tparallel\n    # del\n    # - typecheck\n    - usestdlibvars\n    - nestif\n    - unused\n    - makezero\n    - govet\n    - goconst\n    - gci\n    # - rowserrcheck\n    # 1.59 version no new lints\n    # 1.58 version new lints\n    # - fatcontext\n    - canonicalheader\n    # 1.57 version new lints\n    - copyloopvar\n    - intrange\n    # 1.56 version new lints\n    - spancheck\n    # 1.55 version new lints\n    - gochecksumtype\n    - perfsprint\n    - sloglint\n    - testifylint\n    - mirror\n    - zerologlint\n    # 1.51 version new lints\n    - gocheckcompilerdirectives\n    # 1.50 version new lints\n    - testableexamples\n\nissues:\n  # Note: path identifiers are regular expressions, hence the \\.go suffixes.\n  exclude-rules:\n    - path: main\\.go\n      linters:\n        - forbidigo\n    - path: _test\\.go\n      linters:\n        - dogsled\n        - errcheck\n        - goconst\n        - gosec\n        - ineffassign\n        - maintidx\n        - typecheck\n    - path: \\.go$\n      text: \"should have a package comment\"\n    - path: \\.go$\n      text: 'exported (.+) should have comment( \\(or a comment on this block\\))? or be unexported'\n    - path: \\.go$\n      text: \"fmt.Sprintf can be replaced with string concatenation\"\n"
  },
  {
    "path": "components/ingress/DEVELOPMENT.md",
    "content": "# Development Guide (Quick)\n\n## Prerequisites\n- Go 1.24+\n- Docker (optional, for image build)\n- Access to a Kubernetes cluster with BatchSandbox CRD installed.\n\n## Install deps\n```bash\ncd components/ingress\ngo mod tidy && go mod vendor\n```\n\n## Build & Run\n```bash\nmake build          # binary at bin/ingress with ldflags version info\n./bin/ingress \\\n  --namespace <target-namespace> \\\n  --port 28888 \\\n  --log-level info\n```\n\n## Tests & Lint\n```bash\nmake test           # go test ./...\ngo vet ./...        # included in make build\n```\n\n## Docker (with build args)\n```bash\ndocker build \\\n  --build-arg VERSION=$(git describe --tags --always --dirty) \\\n  --build-arg GIT_COMMIT=$(git rev-parse HEAD) \\\n  --build-arg BUILD_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\") \\\n  -t opensandbox/ingress:dev .\n```\n\n## Key Paths\n- `main.go` — entrypoint, HTTP routes, provider initialization.\n- `pkg/proxy/` — HTTP/WebSocket reverse proxy logic.\n- `pkg/sandbox/` — Sandbox provider abstraction and BatchSandbox implementation.\n- `version/` — build metadata (ldflags).\n\n## Tips\n- Health check: `/status.ok`\n- Proxy endpoint: `/` (routes based on `OpenSandbox-Ingress-To` header or Host)\n- Env overrides: `VERSION/GIT_COMMIT/BUILD_TIME` usable via Makefile and build.sh.\n- BatchSandbox must have `sandbox.opensandbox.io/endpoints` annotation with JSON array of IPs.\n\n"
  },
  {
    "path": "components/ingress/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM golang:1.24.0 AS builder\n\nWORKDIR /build\n\nARG VERSION=dev\nARG GIT_COMMIT=unknown\nARG BUILD_TIME=unknown\n\nCOPY kubernetes ./kubernetes\n# Prepare local modules to satisfy replace directives.\nCOPY components/internal/go.mod components/internal/go.sum ./components/internal/\nCOPY components/ingress/go.mod components/ingress/go.sum ./components/ingress/\n\nWORKDIR /build\n\nRUN cd components/internal && go mod download\nRUN cd components/ingress && go mod download\n\n# Copy sources.\nCOPY components/internal ./components/internal\nCOPY components/ingress/. ./components/ingress\n\nWORKDIR /build/components/ingress\n\nRUN CGO_ENABLED=0 go build \\\n    -ldflags \"-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \\\n              -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'\" \\\n    -o /build/ingress ./main.go\n\nFROM alpine:latest\n\nCOPY --from=builder /build/ingress .\n\nENTRYPOINT [\"./ingress\"]\n"
  },
  {
    "path": "components/ingress/Makefile",
    "content": ".PHONY: fmt\nfmt: ## Run go fmt against code.\n\tgo fmt ./...\n\n.PHONY: vet\nvet: ## Run go vet against code.\n\tgo mod tidy && go mod vendor\n\tgo vet ./...\n\n.PHONY: test\ntest: vet ## Run tests\n\tgo test -v -coverpkg=./... ./pkg/...\n\n##@ Linter\n\n.PHONY: install-golint\ninstall-golint:\n\t@if ! command -v golangci-lint &> /dev/null; then \\\n  \t\techo \"installing golangci-lint...\"; \\\n  \t\tgo install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \\\n  \telse \\\n  \t    echo \"golangci-lint already installed\"; \\\n\tfi\n\n.PHONY: golint\ngolint: fmt install-golint\n\tgolangci-lint run -v --fix ./...\n\nVERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo \"dev\")\nGIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo \"unknown\")\nBUILD_TIME ?= $(shell date -u +\"%Y-%m-%dT%H:%M:%SZ\")\nLDFLAGS := -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \\\n\t-X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \\\n\t-X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)'\n\n.PHONY: build\nbuild: vet ## Build the binary.\n\t@mkdir -p bin\n\tgo build -ldflags \"$(LDFLAGS)\" -o bin/router main.go\n\n.PHONY: clean\nclean: ## Clean build artifacts.\n\trm -rf bin/ vendor/\n"
  },
  {
    "path": "components/ingress/README.md",
    "content": "# OpenSandbox Ingress\n\n## Overview\n- HTTP/WebSocket reverse proxy that routes to sandbox instances.\n- Watches sandbox CRs (BatchSandbox or AgentSandbox, chosen by `--provider-type`) in a target Namespace:\n  - BatchSandbox: reads endpoints from `sandbox.opensandbox.io/endpoints` annotation.\n  - AgentSandbox: reads `status.serviceFQDN`.\n- Exposes `/status.ok` health check; prints build metadata (version, commit, time, Go/platform) at startup.\n\n## Quick Start\n```bash\ngo run main.go \\\n  --namespace <target-namespace> \\\n  --provider-type <batchsandbox|agent-sandbox> \\\n  --mode <header|uri> \\\n  --port 28888 \\\n  --log-level info\n```\nEndpoints: `/` (proxy), `/status.ok` (health).\n\n## Routing Modes\n\nThe ingress supports two routing modes for discovering sandbox instances:\n\n### Header Mode (default: `--mode header`)\n\nRoutes requests based on the `OpenSandbox-Ingress-To` header or the `Host` header.\n\n**Format:**\n- Header: `OpenSandbox-Ingress-To: <sandbox-id>-<port>`\n- Host: `<sandbox-id>-<port>.<domain>`\n\n**Example:**\n```bash\n# Using OpenSandbox-Ingress-To header\ncurl -H \"OpenSandbox-Ingress-To: my-sandbox-8080\" https://ingress.opensandbox.io/api/users\n\n# Using Host header\ncurl -H \"Host: my-sandbox-8080.example.com\" https://ingress.opensandbox.io/api/users\n```\n\n**Parsing logic:**\n- Extracts sandbox ID and port from the format `<sandbox-id>-<port>`\n- The last segment after the last `-` is treated as the port\n- Everything before the last `-` is treated as the sandbox ID\n\n### URI Mode (`--mode uri`)\n\nRoutes requests based on the URI path structure.\n\n**Format:**\n\n`/<sandbox-id>/<sandbox-port>/<path-to-request>`\n\n**Example:**\n```bash\n# Request to sandbox \"my-sandbox\" on port 8080, forwarding to /api/users\ncurl https://ingress.opensandbox.io/my-sandbox/8080/api/users\n\n# WebSocket example\nwss://ingress.opensandbox.io/my-sandbox/8080/ws\n```\n\n**Parsing logic:**\n- First path segment: sandbox ID\n- Second path segment: sandbox port\n- Remaining path: forwarded to the target sandbox as the request URI\n- If no remaining path is provided, defaults to `/`\n\n**Use cases:**\n- When you cannot modify HTTP headers\n- When you need path-based routing\n- For simpler client configuration without custom headers\n\n## Auto-Renew on Ingress Access (OSEP-0009)\n\nWhen enabled, the ingress publishes **renew-intent** events to a Redis list on each proxied request (after resolving the sandbox). The OpenSandbox server consumes these events and may extend sandbox expiration for sandboxes that opted in at creation time. See [OSEP-0009](https://github.com/alibaba/opensandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) for the full design.\n\n**Requirements:** The server must have auto-renew and Redis consumer enabled; the sandbox must be created with `extensions[\"auto_renew_on_access\"]=\"true\"`. This feature is best-effort and disabled by default.\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `--renew-intent-enabled` | `false` | Enable publishing renew-intent events to Redis |\n| `--renew-intent-redis-dsn` | `redis://127.0.0.1:6379/0` | Redis DSN (may include `user:password@`) |\n| `--renew-intent-queue-key` | `opensandbox:renew:intent` | Redis List key for intent payloads |\n| `--renew-intent-queue-max-len` | `0` | Max list length (0 = no cap); LTRIM applied when &gt; 0 |\n| `--renew-intent-min-interval` | `60` | Min seconds between intents per sandbox (client-side throttle) |\n\n**Example (with Redis):**\n```bash\ngo run main.go \\\n  --namespace opensandbox \\\n  --renew-intent-enabled \\\n  --renew-intent-redis-dsn \"redis://user:pass@redis:6379/0\" \\\n  --renew-intent-min-interval 120\n```\n\n## Build\n```bash\ncd components/ingress\nmake build\n# override build metadata if needed\nVERSION=1.2.3 GIT_COMMIT=$(git rev-parse HEAD) BUILD_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\") make build\n```\n\n## Docker Build\nDockerfile already wires ldflags via build args:\n```bash\ndocker build \\\n  --build-arg VERSION=$(git describe --tags --always --dirty) \\\n  --build-arg GIT_COMMIT=$(git rev-parse HEAD) \\\n  --build-arg BUILD_TIME=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\") \\\n  -t opensandbox/ingress:local .\n```\n\n## Multi-arch Publish Script\n`build.sh` uses buildx to build/push linux/amd64 and linux/arm64:\n```bash\ncd components/ingress\nTAG=local VERSION=1.2.3 GIT_COMMIT=abc BUILD_TIME=2025-01-01T00:00:00Z bash build.sh\n```\n\n## Runtime Requirements\n- Access to Kubernetes API (in-cluster or via KUBECONFIG).\n- If `--provider-type=batchsandbox`: BatchSandbox CRs in the specified Namespace with `sandbox.opensandbox.io/endpoints` annotation containing Pod IPs.\n- If `--provider-type=agent-sandbox`: AgentSandbox CRs with `status.serviceFQDN` populated.\n\n## Implementation Notes\n\n### Header Mode Behavior\n- Routing key priority: `OpenSandbox-Ingress-To` header first, otherwise Host parsing `<sandbox-name>-<port>.*`.\n- Sandbox name extracted from request is used to query the sandbox CR (BatchSandbox or AgentSandbox) via informer cache:\n  - BatchSandbox → endpoints annotation.\n  - AgentSandbox → `status.serviceFQDN`.\n- The original request path is preserved and forwarded to the target sandbox.\n\n### URI Mode Behavior\n- Routing information is extracted from the URI path: `/<sandbox-id>/<sandbox-port>/<path-to-request>`.\n- The sandbox ID and port are extracted from the first two path segments.\n- The remaining path (`/<path-to-request>`) is forwarded to the target sandbox as the request URI.\n- If no remaining path is provided, the request URI defaults to `/`.\n\n### Commons\n- Error handling:\n  - `ErrSandboxNotFound` (sandbox resource not exists) → HTTP 404\n  - `ErrSandboxNotReady` (not enough replicas, missing endpoints, invalid config) → HTTP 503\n  - Other errors (K8s API errors, etc.) → HTTP 502\n- WebSocket path forwards essential headers and X-Forwarded-*; HTTP path strips `OpenSandbox-Ingress-To` before proxying (header mode only).\n\n## Development & Tests\n```bash\ncd components/ingress\ngo test ./...\n```\nKey code:\n- `main.go`: entrypoint and handlers.\n- `pkg/proxy/`: HTTP/WebSocket proxy logic, sandbox endpoint resolution.\n- `pkg/sandbox/`: Sandbox provider abstraction and BatchSandbox implementation.\n- `version/`: build metadata output (populated via ldflags).\n\n"
  },
  {
    "path": "components/ingress/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\nVERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo \"dev\")}\nGIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo \"unknown\")}\nBUILD_TIME=${BUILD_TIME:-$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")}\n\nREPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath \"$(dirname \"$0\")/../..\")\ncd \"${REPO_ROOT}\"\n\ndocker buildx rm ingress-builder || true\n\ndocker buildx create --use --name ingress-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\nLATEST_TAGS=()\nif [[ \"${TAG}\" == v* ]]; then\n  LATEST_TAGS+=(-t opensandbox/ingress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:latest)\nfi\n\ndocker buildx build \\\n  -t opensandbox/ingress:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:${TAG} \\\n  \"${LATEST_TAGS[@]}\" \\\n  -f components/ingress/Dockerfile \\\n  --build-arg VERSION=\"${VERSION}\" \\\n  --build-arg GIT_COMMIT=\"${GIT_COMMIT}\" \\\n  --build-arg BUILD_TIME=\"${BUILD_TIME}\" \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "components/ingress/go.mod",
    "content": "module github.com/alibaba/opensandbox/ingress\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/alibaba/OpenSandbox/sandbox-k8s v0.0.0\n\tgithub.com/alibaba/opensandbox/internal v0.0.0\n\tgithub.com/alicebob/miniredis/v2 v2.37.0\n\tgithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674\n\tgithub.com/redis/go-redis/v9 v9.18.0\n\tgithub.com/stretchr/testify v1.11.1\n\tk8s.io/apimachinery v0.34.3\n\tk8s.io/client-go v0.34.3\n\tknative.dev/pkg v0.0.0-20260120122510-4a022ed9999a\n)\n\nrequire (\n\tgithub.com/blendle/zapdriver v1.3.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.12.2 // indirect\n\tgithub.com/evanphx/json-patch/v5 v5.9.11 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.21.0 // indirect\n\tgithub.com/go-openapi/swag v0.23.0 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/google/gnostic-models v0.7.0 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/hashicorp/golang-lru v1.0.2 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/kelseyhightower/envconfig v1.4.0 // indirect\n\tgithub.com/mailru/easyjson v0.9.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/yuin/gopher-lua v1.1.1 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/otel v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.40.0 // indirect\n\tgo.uber.org/atomic v1.11.0 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgo.uber.org/zap v1.27.1 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/net v0.49.0 // indirect\n\tgolang.org/x/oauth2 v0.32.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.40.0 // indirect\n\tgolang.org/x/term v0.39.0 // indirect\n\tgolang.org/x/text v0.33.0 // indirect\n\tgolang.org/x/time v0.10.0 // indirect\n\tgomodules.xyz/jsonpatch/v2 v2.5.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.10 // indirect\n\tgopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/api v0.34.3 // indirect\n\tk8s.io/klog/v2 v2.130.1 // indirect\n\tk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect\n\tk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect\n\tsigs.k8s.io/controller-runtime v0.21.0 // indirect\n\tsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect\n\tsigs.k8s.io/yaml v1.6.0 // indirect\n)\n\nreplace github.com/alibaba/OpenSandbox/sandbox-k8s => ../../kubernetes\n\nreplace github.com/alibaba/opensandbox/internal => ../internal\n"
  },
  {
    "path": "components/ingress/go.sum",
    "content": "github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=\ngithub.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=\ngithub.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=\ngithub.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=\ngithub.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=\ngithub.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=\ngithub.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=\ngithub.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=\ngithub.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=\ngithub.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=\ngithub.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=\ngithub.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=\ngithub.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=\ngithub.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=\ngithub.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=\ngithub.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=\ngithub.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=\ngithub.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=\ngithub.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=\ngithub.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=\ngithub.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=\ngithub.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=\ngithub.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=\ngithub.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=\ngithub.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=\ngo.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=\ngo.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=\ngo.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=\ngo.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=\ngo.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=\ngo.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=\ngo.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=\ngo.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=\ngo.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=\ngo.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=\ngolang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=\ngolang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=\ngolang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=\ngolang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=\ngolang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=\ngolang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=\ngolang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=\ngolang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=\ngolang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=\ngomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=\ngopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=\nk8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=\nk8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=\nk8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nk8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=\nk8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nknative.dev/pkg v0.0.0-20260120122510-4a022ed9999a h1:9f29OTA7w/iVIX6PS6yveVVzNbcUS74eQfchVe8o2/4=\nknative.dev/pkg v0.0.0-20260120122510-4a022ed9999a/go.mod h1:Tz3GoxcNC5vH3Zo//cW3mnHL474u+Y1wbsUIZ11p8No=\nsigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=\nsigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "components/ingress/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"knative.dev/pkg/injection\"\n\t\"knative.dev/pkg/signals\"\n\n\t\"github.com/alibaba/opensandbox/ingress/pkg/flag\"\n\t\"github.com/alibaba/opensandbox/ingress/pkg/proxy\"\n\t\"github.com/alibaba/opensandbox/ingress/pkg/renewintent\"\n\t\"github.com/alibaba/opensandbox/ingress/pkg/sandbox\"\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/alibaba/opensandbox/internal/version\"\n)\n\nfunc main() {\n\tversion.EchoVersion(\"OpenSandbox Ingress\")\n\n\tflag.InitFlags()\n\tif flag.Namespace == \"\" {\n\t\tlog.Panicf(\"'-namespace' not set.\")\n\t}\n\n\tcfg := injection.ParseAndGetRESTConfigOrDie()\n\tcfg.ContentType = runtime.ContentTypeProtobuf\n\tcfg.UserAgent = \"opensandbox-ingress/\" + version.GitCommit\n\n\tctx := signals.NewContext()\n\tctx = withLogger(ctx, flag.LogLevel)\n\n\t// Create sandbox provider factory\n\tproviderFactory := sandbox.NewProviderFactory(\n\t\tcfg,\n\t\tflag.Namespace,\n\t\ttime.Second*30, // resync period\n\t)\n\n\t// Create sandbox provider based on provider type\n\tsandboxProvider, err := providerFactory.CreateProvider(sandbox.ProviderType(flag.ProviderType))\n\tif err != nil {\n\t\tlog.Panicf(\"Failed to create sandbox provider: %v\", err)\n\t}\n\n\t// Start provider (includes cache sync)\n\tif err := sandboxProvider.Start(ctx); err != nil {\n\t\tlog.Panicf(\"Failed to start sandbox provider: %v\", err)\n\t}\n\n\tvar renewPublisher renewintent.Publisher\n\tif flag.RenewIntentEnabled {\n\t\tredisClient, err := renewintent.RedisClientFromDSN(flag.RenewIntentRedisDSN)\n\t\tif err != nil {\n\t\t\tlog.Panicf(\"Failed to create Redis client for renew-intent: %v\", err)\n\t\t}\n\t\trenewPublisher = renewintent.NewRedisPublisher(ctx, redisClient, renewintent.RedisPublisherConfig{\n\t\t\tQueueKey:    flag.RenewIntentQueueKey,\n\t\t\tQueueMaxLen: flag.RenewIntentQueueMaxLen,\n\t\t\tMinInterval: time.Duration(flag.RenewIntentMinIntervalSec) * time.Second,\n\t\t\tLogger:      proxy.Logger,\n\t\t})\n\t}\n\n\t// Create reverse proxy with sandbox provider\n\treverseProxy := proxy.NewProxy(ctx, sandboxProvider, proxy.Mode(flag.Mode), renewPublisher)\n\thttp.Handle(\"/\", reverseProxy)\n\thttp.HandleFunc(\"/status.ok\", proxy.Healthz)\n\n\tif err := http.ListenAndServe(fmt.Sprintf(\":%v\", flag.Port), nil); err != nil {\n\t\tlog.Panicf(\"Error starting http server: %v\", err)\n\t}\n\n\tpanic(\"unreachable\")\n}\n\nfunc withLogger(ctx context.Context, logLevel string) context.Context {\n\tlogger := slogger.MustNew(slogger.Config{Level: logLevel}).Named(\"opensandbox.ingress\")\n\treturn proxy.WithLogger(ctx, logger)\n}\n"
  },
  {
    "path": "components/ingress/pkg/flag/flags.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage flag\n\nvar (\n\t// LogLevel controls the router log verbosity.\n\tLogLevel string\n\n\t// Port controls the HTTP listener port.\n\tPort int\n\n\t// Namespace filters the target sandbox instances.\n\tNamespace string\n\n\t// ProviderType specifies the sandbox provider type (e.g., batchsandbox).\n\tProviderType string\n\n\t// Mode specifies the sandbox service discovery mode (e.g., header, uri).\n\tMode string\n\n\tRenewIntentEnabled        bool\n\tRenewIntentRedisDSN       string\n\tRenewIntentQueueKey       string\n\tRenewIntentQueueMaxLen    int\n\tRenewIntentMinIntervalSec int\n)\n"
  },
  {
    "path": "components/ingress/pkg/flag/parser.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage flag\n\nimport (\n\t\"flag\"\n)\n\nfunc InitFlags() {\n\tflag.StringVar(&LogLevel, \"log-level\", \"info\", \"Server log level\")\n\tflag.IntVar(&Port, \"port\", 28888, \"Server listening port (default: 28888)\")\n\tflag.StringVar(&Namespace, \"namespace\", \"opensandbox\", \"The Kubernetes namespace to watch for sandbox resources\")\n\tflag.StringVar(&ProviderType, \"provider-type\", \"batchsandbox\", \"The sandbox provider type (default: batchsandbox)\")\n\tflag.StringVar(&Mode, \"mode\", \"header\", \"The sandbox service discovery mode (default: header)\")\n\n\tflag.BoolVar(&RenewIntentEnabled, \"renew-intent-enabled\", false, \"Enable publishing renew-intent events to Redis (OSEP-0009)\")\n\tflag.StringVar(&RenewIntentRedisDSN, \"renew-intent-redis-dsn\", \"redis://127.0.0.1:6379/0\", \"Redis DSN for renew-intent queue\")\n\tflag.StringVar(&RenewIntentQueueKey, \"renew-intent-queue-key\", \"opensandbox:renew:intent\", \"Redis List key for renew-intent payloads\")\n\tflag.IntVar(&RenewIntentQueueMaxLen, \"renew-intent-queue-max-len\", 0, \"Max renew-intent queue length (0 = no cap)\")\n\tflag.IntVar(&RenewIntentMinIntervalSec, \"renew-intent-min-interval\", 60, \"Min seconds between publishing intents for the same sandbox (client-side throttle)\")\n\n\tflag.Parse()\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/header.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport \"net/http\"\n\nvar (\n\tXRealIP         = http.CanonicalHeaderKey(\"X-Real-IP\")\n\tXForwardedFor   = http.CanonicalHeaderKey(\"X-Forwarded-For\")\n\tXForwardedProto = http.CanonicalHeaderKey(\"X-Forwarded-Proto\")\n\n\tSandboxIngress = http.CanonicalHeaderKey(\"OpenSandbox-Ingress-To\")\n\t// DeprecatedSandboxIngress is the deprecated header name\n\t// Deprecated\n\tDeprecatedSandboxIngress = http.CanonicalHeaderKey(\"OPEN-SANDBOX-INGRESS\")\n\n\tAccessControlAllowOrigin  = http.CanonicalHeaderKey(\"Access-Control-Allow-Origin\")\n\tReverseProxyServerPowerBy = http.CanonicalHeaderKey(\"Reverse-Proxy-Server-PowerBy\")\n\n\tSecWebSocketProtocol = http.CanonicalHeaderKey(\"Sec-WebSocket-Protocol\")\n\tCookie               = http.CanonicalHeaderKey(\"Cookie\")\n\tSetCookie            = http.CanonicalHeaderKey(\"Set-Cookie\")\n\tHost                 = http.CanonicalHeaderKey(\"Host\")\n\tOrigin               = http.CanonicalHeaderKey(\"Origin\")\n)\n"
  },
  {
    "path": "components/ingress/pkg/proxy/healthz.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport \"net/http\"\n\nfunc Healthz(w http.ResponseWriter, _ *http.Request) {\n\tw.WriteHeader(http.StatusOK)\n\t_, _ = w.Write([]byte(\"OK\"))\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/healthz_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestHealthz(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodGet, \"/healthz\", nil)\n\trr := httptest.NewRecorder()\n\n\tHealthz(rr, req)\n\n\tassert.Equal(t, http.StatusOK, rr.Code)\n\tassert.Equal(t, \"OK\", rr.Body.String())\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/host.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n)\n\ntype Mode string\n\nconst (\n\t// ModeHeader is the mode that uses the Host or SandboxIngress header\n\t// to determine the sandbox instance.\n\tModeHeader Mode = \"header\"\n\n\t// ModeURI is the mode that uses the URI path to determine the\n\t// sandbox instance.\n\t//\n\t// Pattern is 'hostname/<sandbox-id>/<sandbox-port>/<path-to-request>'.\n\tModeURI Mode = \"uri\"\n)\n\nfunc (p *Proxy) getSandboxHostDefinition(r *http.Request) (*sandboxHost, error) {\n\tswitch p.mode {\n\tcase ModeHeader:\n\t\ttargetHost := p.parseTargetHostByHeader(r)\n\t\tif targetHost == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"missing header '%s' or 'Host'\", SandboxIngress)\n\t\t}\n\n\t\thost, err := p.parseSandboxHost(targetHost)\n\t\tif err != nil || host.ingressKey == \"\" || host.port == 0 {\n\t\t\treturn nil, fmt.Errorf(\"invalid host: %s\", targetHost)\n\t\t}\n\t\treturn host, nil\n\tcase ModeURI:\n\t\treturn p.parseSandboxURI(r)\n\t}\n\n\treturn nil, fmt.Errorf(\"unknown ingress mode: %s\", p.mode)\n}\n\nfunc (p *Proxy) parseTargetHostByHeader(r *http.Request) string {\n\ttargetHost := r.Header.Get(SandboxIngress)\n\tif targetHost != \"\" {\n\t\treturn targetHost\n\t}\n\tdeprecatedTargetHost := r.Header.Get(DeprecatedSandboxIngress)\n\tif deprecatedTargetHost != \"\" {\n\t\treturn deprecatedTargetHost\n\t}\n\n\treturn r.Host\n}\n\ntype sandboxHost struct {\n\tingressKey string\n\tport       int\n\trequestURI string\n}\n\nfunc (p *Proxy) parseSandboxHost(s string) (*sandboxHost, error) {\n\tdomain := strings.Split(strings.TrimPrefix(strings.TrimPrefix(s, \"https://\"), \"http://\"), \".\")\n\tif len(domain) < 1 {\n\t\treturn &sandboxHost{}, fmt.Errorf(\"invalid host: %s\", s)\n\t}\n\n\tingressAndPort := strings.Split(domain[0], \"-\")\n\tif len(ingressAndPort) <= 1 || ingressAndPort[0] == \"\" {\n\t\treturn &sandboxHost{}, fmt.Errorf(\"invalid host: %s\", s)\n\t}\n\n\tingress := strings.Join(ingressAndPort[:len(ingressAndPort)-1], \"-\")\n\tport, err := strconv.Atoi(ingressAndPort[len(ingressAndPort)-1])\n\tif err != nil {\n\t\treturn &sandboxHost{}, fmt.Errorf(\"invalid port format: %w\", err)\n\t}\n\treturn &sandboxHost{ingress, port, \"\"}, nil\n}\n\nfunc (p *Proxy) parseSandboxURI(r *http.Request) (*sandboxHost, error) {\n\tpath := r.URL.Path\n\tif path == \"\" {\n\t\treturn nil, errors.New(\"missing URI path\")\n\t}\n\n\t// Remove leading slash and split by '/'\n\tpath = strings.TrimPrefix(path, \"/\")\n\tparts := strings.SplitN(path, \"/\", 3)\n\tif len(parts) < 2 {\n\t\treturn nil, fmt.Errorf(\"invalid URI path format: expected '/<sandbox-id>/<sandbox-port>/<path-to-request>', got: %s\", r.URL.Path)\n\t}\n\n\tsandboxID := parts[0]\n\tport, err := strconv.Atoi(parts[1])\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid port format: %w\", err)\n\t}\n\tif sandboxID == \"\" || port <= 0 {\n\t\treturn nil, errors.New(\"missing sandbox-id or sandbox-port in URI path\")\n\t}\n\n\t// Extract the remaining path (user's target request URI)\n\tvar requestURI string\n\tif len(parts) >= 3 && parts[2] != \"\" {\n\t\trequestURI = \"/\" + parts[2]\n\t} else {\n\t\trequestURI = \"/\"\n\t}\n\n\treturn &sandboxHost{\n\t\tingressKey: sandboxID,\n\t\tport:       port,\n\t\trequestURI: requestURI,\n\t}, nil\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/http.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"net/http\"\n\t\"net/http/httputil\"\n\t\"net/url\"\n)\n\ntype HTTPProxy struct{}\n\nfunc NewHTTPProxy() *HTTPProxy {\n\treturn &HTTPProxy{}\n}\n\nfunc (hp *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\ttargetURL := r.URL.String()\n\n\tproxy, err := hp.newReverseProxy(targetURL)\n\tif err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadGateway)\n\t\treturn\n\t}\n\n\tproxy.ServeHTTP(w, r)\n}\n\nfunc (hp *HTTPProxy) newReverseProxy(targetHost string) (*httputil.ReverseProxy, error) {\n\turl, err := url.Parse(targetHost)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tproxy := httputil.NewSingleHostReverseProxy(url)\n\tproxy.Director = func(req *http.Request) {\n\t\treq.URL.Scheme = url.Scheme\n\t\treq.URL.Host = url.Host\n\t\treq.Host = url.Host\n\t\treq.Header.Del(SandboxIngress)\n\t}\n\tproxy.ModifyResponse = func(response *http.Response) error {\n\t\tresponse.Header.Add(ReverseProxyServerPowerBy, \"OpenSandbox-ingress\")\n\t\treturn nil\n\t}\n\treturn proxy, nil\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/http_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/ingress/pkg/sandbox\"\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// mockProvider implements sandbox.Provider interface for testing\ntype mockProvider struct {\n\tendpoints map[string]string // sandboxName -> IP\n\tnotReady  map[string]bool   // sandboxName -> notReady flag\n}\n\nfunc (m *mockProvider) GetEndpoint(sandboxId string) (string, error) {\n\tif m.notReady != nil && m.notReady[sandboxId] {\n\t\treturn \"\", fmt.Errorf(\"%w: %s\", sandbox.ErrSandboxNotReady, sandboxId)\n\t}\n\tif ip, ok := m.endpoints[sandboxId]; ok {\n\t\treturn ip, nil\n\t}\n\treturn \"\", fmt.Errorf(\"%w: %s\", sandbox.ErrSandboxNotFound, sandboxId)\n}\n\nfunc (m *mockProvider) Start(_ context.Context) error {\n\treturn nil\n}\n\nfunc Test_HTTPProxy(t *testing.T) {\n\tt.Run(\"with header mode\", func(t *testing.T) {\n\t\thttpProxyWithHeaderMode(t)\n\t})\n\n\tt.Run(\"with uri mode\", func(t *testing.T) {\n\t\thttpProxyWithURIMode(t)\n\t})\n}\n\nfunc httpProxyWithHeaderMode(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler))\n\tdefer server.Close()\n\tserverPort := server.URL[len(\"http://127.0.0.1:\"):]\n\n\t// Create mock provider with sandbox endpoint\n\tprovider := &mockProvider{\n\t\tendpoints: map[string]string{\n\t\t\t\"test-sandbox\": \"127.0.0.1\",\n\t\t},\n\t}\n\n\tctx := context.Background()\n\tLogger = slogger.MustNew(slogger.Config{Level: \"debug\"})\n\tproxy := NewProxy(ctx, provider, ModeHeader, nil)\n\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/\", proxy)\n\tport, err := findAvailablePort()\n\tassert.Nil(t, err)\n\n\tgo func() {\n\t\tassert.NoError(t, http.ListenAndServe(\":\"+strconv.Itoa(port), mux))\n\t}()\n\n\ttime.Sleep(2 * time.Second)\n\n\t// no header\n\trequest, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/hello\", port), nil)\n\tassert.Nil(t, err)\n\tresponse, err := http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusBadRequest, response.StatusCode)\n\tbytes, _ := io.ReadAll(response.Body)\n\tt.Log(string(bytes))\n\n\t// no sandbox backend\n\trequest, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/hello\", port), nil)\n\trequest.Header.Set(SandboxIngress, fmt.Sprintf(\"non-existent-%v\", port))\n\tresponse, err = http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404\n\tbytes, _ = io.ReadAll(response.Body)\n\tt.Log(string(bytes))\n\n\t// valid sandbox request\n\trequest, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/hello?a=1&b=2\", port), nil)\n\tassert.Nil(t, err)\n\n\trequest.Header.Set(SandboxIngress, fmt.Sprintf(\"test-sandbox-%v\", serverPort))\n\tresponse, err = http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tif response.StatusCode != http.StatusOK {\n\t\tbytes, err := io.ReadAll(response.Body)\n\t\tassert.Nil(t, err)\n\t\tt.Log(string(bytes))\n\t}\n\tassert.Equal(t, http.StatusOK, response.StatusCode)\n\n\t// Compatible Host parsing for reverse proxy mode\n\trequest, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/hello?a=1&b=2\", port), nil)\n\tassert.Nil(t, err)\n\n\trequest.Host = fmt.Sprintf(\"test-sandbox-%v.sandbox.alibaba-inc.com\", serverPort)\n\tresponse, err = http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tif response.StatusCode != http.StatusOK {\n\t\tbytes, err := io.ReadAll(response.Body)\n\t\tassert.Nil(t, err)\n\t\tt.Log(string(bytes))\n\t}\n\tassert.Equal(t, http.StatusOK, response.StatusCode)\n}\n\nfunc httpProxyWithURIMode(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler))\n\tdefer server.Close()\n\tserverPort := server.URL[len(\"http://127.0.0.1:\"):]\n\n\t// Create mock provider with sandbox endpoint\n\tprovider := &mockProvider{\n\t\tendpoints: map[string]string{\n\t\t\t\"test-sandbox\": \"127.0.0.1\",\n\t\t},\n\t}\n\n\tctx := context.Background()\n\tLogger = slogger.MustNew(slogger.Config{Level: \"debug\"})\n\tproxy := NewProxy(ctx, provider, ModeURI, nil)\n\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/\", proxy)\n\tport, err := findAvailablePort()\n\tassert.Nil(t, err)\n\n\tgo func() {\n\t\tassert.NoError(t, http.ListenAndServe(\":\"+strconv.Itoa(port), mux))\n\t}()\n\n\ttime.Sleep(2 * time.Second)\n\n\t// uri is empty\n\trequest, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v\", port), nil)\n\tassert.Nil(t, err)\n\tresponse, err := http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusBadRequest, response.StatusCode)\n\tbytes, _ := io.ReadAll(response.Body)\n\tt.Log(string(bytes))\n\n\t// no sandbox backend\n\trequest, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/non-existent-xxx/80/hello\", port), nil)\n\tresponse, err = http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tassert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404\n\tbytes, _ = io.ReadAll(response.Body)\n\tt.Log(string(bytes))\n\n\t// valid sandbox request\n\trequest, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(\"http://127.0.0.1:%v/test-sandbox/%v/hello?a=1&b=2\", port, serverPort), nil)\n\tassert.Nil(t, err)\n\tresponse, err = http.DefaultClient.Do(request)\n\tassert.Nil(t, err)\n\tif response.StatusCode != http.StatusOK {\n\t\tbytes, err := io.ReadAll(response.Body)\n\t\tassert.Nil(t, err)\n\t\tt.Log(string(bytes))\n\t}\n\tassert.Equal(t, http.StatusOK, response.StatusCode)\n}\n\nfunc realBackendHTTPHandler(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodGet {\n\t\thttp.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)\n\t\treturn\n\t}\n\n\tif r.URL.Path != \"/hello\" {\n\t\thttp.Error(w, fmt.Sprintf(\"path is not /hello, but %s\", r.URL.Path), http.StatusBadRequest)\n\t}\n\tif r.URL.RawQuery != \"a=1&b=2\" {\n\t\thttp.Error(w, fmt.Sprintf(\"query is not a=1&b=2, but %s\", r.URL.RawQuery), http.StatusBadRequest)\n\t}\n\n\tw.WriteHeader(http.StatusOK)\n\t_, _ = w.Write([]byte(\"hello world\"))\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/logger.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n)\n\nvar Logger slogger.Logger\n\nfunc WithLogger(ctx context.Context, logger slogger.Logger) context.Context {\n\tLogger = logger\n\treturn ctx\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/proxy.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/alibaba/opensandbox/ingress/pkg/renewintent\"\n\t\"github.com/alibaba/opensandbox/ingress/pkg/sandbox\"\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n)\n\ntype Proxy struct {\n\tsandboxProvider      sandbox.Provider\n\tmode                 Mode\n\trenewIntentPublisher renewintent.Publisher\n}\n\nfunc NewProxy(_ context.Context, sandboxProvider sandbox.Provider, mode Mode, renewIntentPublisher renewintent.Publisher) *Proxy {\n\treturn &Proxy{\n\t\tsandboxProvider:      sandboxProvider,\n\t\tmode:                 mode,\n\t\trenewIntentPublisher: renewIntentPublisher,\n\t}\n}\n\nfunc (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tLogger.With(slogger.Field{Key: \"error\", Value: err}).Errorf(\"Proxy: proxy causes panic\")\n\t\t\tvar errMsg string\n\t\t\tif e, ok := err.(error); ok {\n\t\t\t\terrMsg = e.Error()\n\t\t\t} else {\n\t\t\t\terrMsg = fmt.Sprintf(\"%v\", err)\n\t\t\t}\n\t\t\thttp.Error(w, errMsg, http.StatusBadGateway)\n\t\t}\n\t}()\n\n\thost, err := p.getSandboxHostDefinition(r)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"OpenSandbox Ingress: %v\", err), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\ttargetHost, err, code := p.resolveRealHost(host)\n\tif err != nil {\n\t\thttp.Error(w, fmt.Sprintf(\"OpenSandbox Ingress: %v\", err), code)\n\t\treturn\n\t}\n\n\tif p.renewIntentPublisher != nil {\n\t\tp.renewIntentPublisher.PublishIntent(host.ingressKey, host.port, host.requestURI)\n\t}\n\n\t// modify if requestURI is not empty\n\tif host.requestURI != \"\" {\n\t\tr.URL.Path = host.requestURI\n\t}\n\n\tr.Host = targetHost\n\tr.URL.Host = targetHost\n\tr.Header.Del(SandboxIngress)\n\n\tLogger.With(\n\t\tslogger.Field{Key: \"target\", Value: targetHost},\n\t\tslogger.Field{Key: \"client\", Value: p.getClientIP(r)},\n\t\tslogger.Field{Key: \"uri\", Value: r.RequestURI},\n\t\tslogger.Field{Key: \"method\", Value: r.Method},\n\t).Infof(\"ingress requested\")\n\tp.serve(w, r)\n}\n\nfunc (p *Proxy) serve(w http.ResponseWriter, r *http.Request) {\n\tif p.isWebSocketRequest(r) {\n\t\tif r.URL == nil {\n\t\t\thttp.Error(w, \"invalid request URL\", http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif r.URL.Scheme == \"\" {\n\t\t\tif r.TLS != nil {\n\t\t\t\tr.URL.Scheme = \"wss\"\n\t\t\t} else {\n\t\t\t\tr.URL.Scheme = \"ws\"\n\t\t\t}\n\t\t}\n\t\tNewWebSocketProxy(r.URL).ServeHTTP(w, r)\n\t} else {\n\t\tif r.URL.Scheme == \"\" {\n\t\t\tif r.TLS != nil {\n\t\t\t\tr.URL.Scheme = \"https\"\n\t\t\t} else {\n\t\t\t\tr.URL.Scheme = \"http\"\n\t\t\t}\n\t\t}\n\t\tNewHTTPProxy().ServeHTTP(w, r)\n\t}\n}\n\nfunc (p *Proxy) isWebSocketRequest(r *http.Request) bool {\n\tif r.Method != http.MethodGet {\n\t\treturn false\n\t}\n\tif r.Header.Get(\"Upgrade\") != \"websocket\" {\n\t\treturn false\n\t}\n\tif r.Header.Get(\"Connection\") != \"Upgrade\" {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (p *Proxy) resolveRealHost(host *sandboxHost) (string, error, int) {\n\t// Get endpoint IP from sandbox provider\n\tendpointIP, err := p.sandboxProvider.GetEndpoint(host.ingressKey)\n\tif err != nil {\n\t\t// Map sandbox errors to HTTP status codes\n\t\tswitch {\n\t\tcase errors.Is(err, sandbox.ErrSandboxNotFound):\n\t\t\treturn \"\", err, http.StatusNotFound\n\t\tcase errors.Is(err, sandbox.ErrSandboxNotReady):\n\t\t\treturn \"\", err, http.StatusServiceUnavailable\n\t\tdefault:\n\t\t\treturn \"\", err, http.StatusBadGateway\n\t\t}\n\t}\n\n\t// Construct target host with port\n\ttargetHost := fmt.Sprintf(\"%s:%d\", endpointIP, host.port)\n\treturn targetHost, nil, 0\n}\n\nfunc (p *Proxy) getClientIP(r *http.Request) string {\n\tclientIP, _, _ := net.SplitHostPort(r.RemoteAddr)\n\tif len(r.Header.Get(XForwardedFor)) != 0 {\n\t\txff := r.Header.Get(XForwardedFor)\n\t\ts := strings.Index(xff, \", \")\n\t\tif s == -1 {\n\t\t\ts = len(r.Header.Get(XForwardedFor))\n\t\t}\n\t\tclientIP = xff[:s]\n\t} else if len(r.Header.Get(XRealIP)) != 0 {\n\t\tclientIP = r.Header.Get(XRealIP)\n\t}\n\n\treturn clientIP\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/proxy_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// Test_WatchPods is removed as we now use BatchSandbox Provider instead of direct Pod watching\n\nfunc TestIsWebSocketRequest(t *testing.T) {\n\tproxy := &Proxy{}\n\n\t// Valid websocket request\n\treq := httptest.NewRequest(http.MethodGet, \"/ws\", nil)\n\treq.Header.Set(\"Upgrade\", \"websocket\")\n\treq.Header.Set(\"Connection\", \"Upgrade\")\n\tassert.True(t, proxy.isWebSocketRequest(req))\n\n\t// Missing upgrade headers\n\treq = httptest.NewRequest(http.MethodGet, \"/ws\", nil)\n\tassert.False(t, proxy.isWebSocketRequest(req))\n\n\t// Wrong method\n\treq = httptest.NewRequest(http.MethodPost, \"/ws\", nil)\n\treq.Header.Set(\"Upgrade\", \"websocket\")\n\treq.Header.Set(\"Connection\", \"Upgrade\")\n\tassert.False(t, proxy.isWebSocketRequest(req))\n}\n\nfunc TestParseSandboxHost(t *testing.T) {\n\tproxy := &Proxy{}\n\n\thost, err := proxy.parseSandboxHost(\"sandbox-1234.example.com\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"sandbox\", host.ingressKey)\n\tassert.Equal(t, 1234, host.port)\n\n\thost, err = proxy.parseSandboxHost(\"https://alpha-beta-8080.sandbox.test\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"alpha-beta\", host.ingressKey)\n\tassert.Equal(t, 8080, host.port)\n\n\t_, err = proxy.parseSandboxHost(\"invalidhost\")\n\tassert.Error(t, err)\n\n\t_, err = proxy.parseSandboxHost(\"-1234.example.com\")\n\tassert.Error(t, err)\n}\n\nfunc TestGetClientIP(t *testing.T) {\n\tproxy := &Proxy{}\n\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"192.0.2.1:12345\"\n\tassert.Equal(t, \"192.0.2.1\", proxy.getClientIP(req))\n\n\treq = httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"192.0.2.1:12345\"\n\treq.Header.Set(XRealIP, \"203.0.113.5\")\n\tassert.Equal(t, \"203.0.113.5\", proxy.getClientIP(req))\n\n\treq = httptest.NewRequest(http.MethodGet, \"/\", nil)\n\treq.RemoteAddr = \"192.0.2.1:12345\"\n\treq.Header.Set(XForwardedFor, \"10.0.0.1, 198.51.100.2\")\n\tassert.Equal(t, \"10.0.0.1\", proxy.getClientIP(req))\n}\n\nfunc findAvailablePort() (int, error) {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer listener.Close()\n\n\tport := listener.Addr().(*net.TCPAddr).Port\n\treturn port, nil\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/websocket.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/gorilla/websocket\"\n)\n\nvar (\n\t// defaultWebSocketDialer is a dialer with all fields set to the default zero values.\n\tdefaultWebSocketDialer = websocket.DefaultDialer\n\n\t// defaultUpgrader specifies the parameters for upgrading an HTTP\n\t// connection to a WebSocket connection.\n\tdefaultUpgrader = &websocket.Upgrader{\n\t\tReadBufferSize:  1024,\n\t\tWriteBufferSize: 1024,\n\t}\n)\n\n// WebSocketProxy is an HTTP Handler that takes an incoming WebSocket\n// connection and proxies it to another server.\ntype WebSocketProxy struct {\n\t// director, if non-nil, is a function that may copy additional request\n\t// headers from the incoming WebSocket connection into the output headers\n\t// which will be forwarded to another server.\n\tdirector func(incoming *http.Request, out http.Header)\n\n\t// backend returns the backend URL which the proxy uses to reverse proxy\n\t// the incoming WebSocket connection. Request is the initial incoming and\n\t// unmodified request.\n\tbackend func(*http.Request) *url.URL\n\n\t//  dialer contains options for connecting to the backend WebSocket server.\n\t//  If nil, DefaultDialer is used.\n\tdialer *websocket.Dialer\n\n\t// upgrader specifies the parameters for upgrading a incoming HTTP\n\t// connection to a WebSocket connection. If nil, DefaultUpgrader is used.\n\tupgrader *websocket.Upgrader\n}\n\n// ProxyHandler returns a new http.Handler interface that reverse proxies the\n// request to the given target.\nfunc ProxyHandler(target *url.URL) http.Handler { return NewWebSocketProxy(target) }\n\n// NewWebSocketProxy returns a new Websocket reverse proxy that rewrites the\n// URL's to the scheme, host and base path provider in target.\nfunc NewWebSocketProxy(target *url.URL) *WebSocketProxy {\n\tbackend := func(r *http.Request) *url.URL {\n\t\t// Shallow copy\n\t\tu := *target\n\t\tu.Fragment = r.URL.Fragment\n\t\tu.Path = r.URL.Path\n\t\tu.RawQuery = r.URL.RawQuery\n\t\treturn &u\n\t}\n\treturn &WebSocketProxy{backend: backend}\n}\n\n//nolint:gocognit\nfunc (w *WebSocketProxy) ServeHTTP(rw http.ResponseWriter, r *http.Request) {\n\tif w.backend == nil {\n\t\thttp.Error(rw, \"WebSocketProxy: backend is not defined\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tbackendURL := w.backend(r)\n\tif backendURL == nil {\n\t\thttp.Error(rw, \"WebSocketProxy: backend URL is nil\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tdialer := w.dialer\n\tif w.dialer == nil {\n\t\tdialer = defaultWebSocketDialer\n\t}\n\n\t// Pass headers from the incoming request to the dialer to forward them to\n\t// the final destinations.\n\trequestHeader := http.Header{}\n\tif origin := r.Header.Get(Origin); origin != \"\" {\n\t\trequestHeader.Add(Origin, origin)\n\t}\n\tfor _, prot := range r.Header[SecWebSocketProtocol] {\n\t\trequestHeader.Add(SecWebSocketProtocol, prot)\n\t}\n\tfor _, cokiee := range r.Header[Cookie] {\n\t\trequestHeader.Add(Cookie, cokiee)\n\t}\n\tif r.Host != \"\" {\n\t\trequestHeader.Set(Host, r.Host)\n\t}\n\n\t// Pass X-Forwarded-For headers too, code below is a part of\n\t// httputil.ReverseProxy. See http://en.wikipedia.org/wiki/X-Forwarded-For\n\t// for more information\n\tif clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {\n\t\t// If we aren't the first proxy retain prior\n\t\t// X-Forwarded-For information as a comma+space\n\t\t// separated list and fold multiple headers into one.\n\t\tif prior, ok := r.Header[XForwardedFor]; ok {\n\t\t\tclientIP = strings.Join(prior, \", \") + \", \" + clientIP\n\t\t}\n\t\trequestHeader.Set(XForwardedFor, clientIP)\n\t}\n\n\t// Set the originating protocol of the incoming HTTP request. The SSL might\n\t// be terminated on our site and because we doing proxy adding this would\n\t// be helpful for applications on the backend.\n\trequestHeader.Set(XForwardedProto, \"http\")\n\tif r.TLS != nil {\n\t\trequestHeader.Set(XForwardedProto, \"https\")\n\t}\n\n\t// Enable the director to copy any additional headers it desires for\n\t// forwarding to the remote server.\n\tif w.director != nil {\n\t\tw.director(r, requestHeader)\n\t}\n\n\t// Connect to the backend URL, also pass the headers we get from the requst\n\t// together with the Forwarded headers we prepared above.\n\tconnBackend, resp, err := dialer.Dial(backendURL.String(), requestHeader)\n\tif err != nil {\n\t\tLogger.With(slogger.Field{Key: \"error\", Value: err}).Errorf(\"WebSocketProxy: couldn't dial to remote backend\")\n\t\tif resp != nil {\n\t\t\t// If the WebSocket handshake fails, ErrBadHandshake is returned\n\t\t\t// along with a non-nil *http.Response so that callers can handle\n\t\t\t// redirects, authentication, etcetera.\n\t\t\tif err := copyResponse(rw, resp); err != nil {\n\t\t\t\tLogger.With(slogger.Field{Key: \"error\", Value: err}).Errorf(\"WebSocketProxy: couldn't write response after failed remote backend handshake\")\n\t\t\t}\n\t\t} else {\n\t\t\thttp.Error(rw, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)\n\t\t}\n\t\treturn\n\t}\n\tdefer connBackend.Close()\n\n\tupgrader := w.upgrader\n\tif w.upgrader == nil {\n\t\tupgrader = defaultUpgrader\n\t}\n\n\t// Only pass those headers to the upgrader.\n\tupgradeHeader := http.Header{}\n\tif hdr := resp.Header.Get(SecWebSocketProtocol); hdr != \"\" {\n\t\tupgradeHeader.Set(SecWebSocketProtocol, hdr)\n\t}\n\tif hdr := resp.Header.Get(SetCookie); hdr != \"\" {\n\t\tupgradeHeader.Set(SetCookie, hdr)\n\t}\n\n\t// Now upgrade the existing incoming request to a WebSocket connection.\n\t// Also pass the header that we gathered from the Dial handshake.\n\tconnPub, err := upgrader.Upgrade(rw, r, upgradeHeader)\n\tif err != nil {\n\t\tLogger.With(slogger.Field{Key: \"error\", Value: err}).Errorf(\"WebSocketProxy: couldn't upgrade websocket connection\")\n\t\treturn\n\t}\n\tdefer connPub.Close()\n\n\terrClient := make(chan error, 1)\n\terrBackend := make(chan error, 1)\n\treplicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error) {\n\t\tfor {\n\t\t\tmsgType, msg, err := src.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\tm := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf(\"%v\", err))\n\t\t\t\tif e, ok := err.(*websocket.CloseError); ok { //nolint:errorlint\n\t\t\t\t\tif e.Code != websocket.CloseNoStatusReceived {\n\t\t\t\t\t\tm = websocket.FormatCloseMessage(e.Code, e.Text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\terrc <- err\n\t\t\t\t_ = dst.WriteMessage(websocket.CloseMessage, m)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\terr = dst.WriteMessage(msgType, msg)\n\t\t\tif err != nil {\n\t\t\t\terrc <- err\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tgo replicateWebsocketConn(connPub, connBackend, errClient)\n\tgo replicateWebsocketConn(connBackend, connPub, errBackend)\n\n\tvar message string\n\tselect {\n\tcase err = <-errClient:\n\t\tmessage = \"WebSocketProxy: Error when copying from backend to client: %v\"\n\tcase err = <-errBackend:\n\t\tmessage = \"WebSocketProxy: Error when copying from client to backend: %v\"\n\n\t}\n\tif e, ok := err.(*websocket.CloseError); !ok || e.Code == websocket.CloseAbnormalClosure { //nolint:errorlint\n\t\tLogger.With(slogger.Field{Key: \"error\", Value: err}).Errorf(message, err)\n\t}\n}\n\nfunc copyResponse(rw http.ResponseWriter, resp *http.Response) error {\n\tcopyHeader(rw.Header(), resp.Header)\n\trw.WriteHeader(resp.StatusCode)\n\tdefer resp.Body.Close()\n\n\t_, err := io.Copy(rw, resp.Body)\n\treturn err\n}\n\nfunc copyHeader(dst, src http.Header) {\n\tfor k, vv := range src {\n\t\tfor _, v := range vv {\n\t\t\tdst.Add(k, v)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/proxy/websocket_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage proxy\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\tslogger \"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/gorilla/websocket\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_WebSocketProxy(t *testing.T) {\n\tt.Run(\"with header mode\", func(t *testing.T) {\n\t\twebSocketProxyWithHeaderMode(t)\n\t})\n\tt.Run(\"with uri mode\", func(t *testing.T) {\n\t\twebSocketProxyWithURIMode(t)\n\t})\n}\n\nfunc webSocketProxyWithHeaderMode(t *testing.T) {\n\t// Create mock provider\n\tprovider := &mockProvider{\n\t\tendpoints: map[string]string{\n\t\t\t\"test-sandbox\": \"127.0.0.1\",\n\t\t},\n\t}\n\n\tctx := context.Background()\n\tLogger = slogger.MustNew(slogger.Config{Level: \"debug\"})\n\tproxy := NewProxy(ctx, provider, ModeHeader, nil)\n\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/\", proxy)\n\tproxyPort, err := findAvailablePort()\n\tproxyURL := \"ws://127.0.0.1:\" + strconv.Itoa(proxyPort)\n\tassert.Nil(t, err)\n\n\tgo func() {\n\t\tassert.NoError(t, http.ListenAndServe(\":\"+strconv.Itoa(proxyPort), mux))\n\t}()\n\n\ttime.Sleep(2 * time.Second)\n\n\tbackendPort, err := findAvailablePort()\n\tassert.Nil(t, err)\n\n\t// backend echo server\n\tgo func() {\n\t\tmux2 := http.NewServeMux()\n\t\tmux2.HandleFunc(\"/ws\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tt.Logf(\"r.URL.Path: %s\", r.URL.Path)\n\t\t\tt.Logf(\"r.URL.RawPath: %s\", r.URL.RawPath)\n\t\t\tt.Logf(\"r.Host: %s\", r.Host)\n\t\t\t// Don't upgrade if original host header isn't preserved\n\t\t\tassert.True(t, strings.HasPrefix(r.Host, \"127.0.0.1\"))\n\n\t\t\tconn, err := defaultUpgrader.Upgrade(w, r, nil)\n\t\t\tif err != nil {\n\t\t\t\tlog.Println(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmessageType, p, err := conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err = conn.WriteMessage(messageType, p); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\n\t\terr := http.ListenAndServe(\":\"+strconv.Itoa(backendPort), mux2)\n\t\tif err != nil {\n\t\t\tt.Error(\"ListenAndServe: \", err)\n\t\t\treturn\n\t\t}\n\t}()\n\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// frontend server, dial now our proxy, which will reverse proxy our\n\t// message to the backend websocket server.\n\th := http.Header{}\n\th.Set(SandboxIngress, \"test-sandbox-\"+strconv.Itoa(backendPort))\n\tconn, _, err := websocket.DefaultDialer.Dial(proxyURL+\"/ws\", h)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// write a message and send it to the backend server\n\tmsg := \"hello kite\"\n\terr = conn.WriteMessage(websocket.TextMessage, []byte(msg))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tmessageType, p, err := conn.ReadMessage()\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif messageType != websocket.TextMessage {\n\t\tt.Error(\"incoming message type is not Text\")\n\t}\n\n\tif msg != string(p) {\n\t\tt.Errorf(\"expecting: %s, got: %s\", msg, string(p))\n\t}\n}\n\nfunc webSocketProxyWithURIMode(t *testing.T) {\n\t// Create mock provider\n\tprovider := &mockProvider{\n\t\tendpoints: map[string]string{\n\t\t\t\"test-sandbox\": \"127.0.0.1\",\n\t\t},\n\t}\n\n\tctx := context.Background()\n\tLogger = slogger.MustNew(slogger.Config{Level: \"debug\"})\n\tproxy := NewProxy(ctx, provider, ModeURI, nil)\n\n\tmux := http.NewServeMux()\n\tmux.Handle(\"/\", proxy)\n\tproxyPort, err := findAvailablePort()\n\tproxyURL := \"ws://127.0.0.1:\" + strconv.Itoa(proxyPort)\n\tassert.Nil(t, err)\n\n\tgo func() {\n\t\tassert.NoError(t, http.ListenAndServe(\":\"+strconv.Itoa(proxyPort), mux))\n\t}()\n\n\ttime.Sleep(2 * time.Second)\n\n\tbackendPort, err := findAvailablePort()\n\tassert.Nil(t, err)\n\n\t// backend echo server\n\tgo func() {\n\t\tmux2 := http.NewServeMux()\n\t\tmux2.HandleFunc(\"/ws\", func(w http.ResponseWriter, r *http.Request) {\n\t\t\tt.Logf(\"r.URL.Path: %s\", r.URL.Path)\n\t\t\tt.Logf(\"r.URL.RawPath: %s\", r.URL.RawPath)\n\t\t\tt.Logf(\"r.Host: %s\", r.Host)\n\t\t\t// Don't upgrade if original host header isn't preserved\n\t\t\tassert.True(t, strings.HasPrefix(r.Host, \"127.0.0.1\"))\n\n\t\t\tconn, err := defaultUpgrader.Upgrade(w, r, nil)\n\t\t\tif err != nil {\n\t\t\t\tlog.Println(err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tmessageType, p, err := conn.ReadMessage()\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err = conn.WriteMessage(messageType, p); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t})\n\n\t\terr := http.ListenAndServe(\":\"+strconv.Itoa(backendPort), mux2)\n\t\tif err != nil {\n\t\t\tt.Error(\"ListenAndServe: \", err)\n\t\t\treturn\n\t\t}\n\t}()\n\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// frontend server, dial now our proxy, which will reverse proxy our\n\t// message to the backend websocket server.\n\th := http.Header{}\n\th.Set(SandboxIngress, \"test-sandbox-\"+strconv.Itoa(backendPort))\n\tconn, _, err := websocket.DefaultDialer.Dial(proxyURL+fmt.Sprintf(\"/test-sandbox/%v\", backendPort)+\"/ws\", h)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// write a message and send it to the backend server\n\tmsg := \"hello kite\"\n\terr = conn.WriteMessage(websocket.TextMessage, []byte(msg))\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tmessageType, p, err := conn.ReadMessage()\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\n\tif messageType != websocket.TextMessage {\n\t\tt.Error(\"incoming message type is not Text\")\n\t}\n\n\tif msg != string(p) {\n\t\tt.Errorf(\"expecting: %s, got: %s\", msg, string(p))\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/renewintent/intent.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage renewintent\n\nimport \"time\"\n\ntype Intent struct {\n\tSandboxID  string `json:\"sandbox_id\"`\n\tObservedAt string `json:\"observed_at\"`\n\tPort       int    `json:\"port,omitempty\"`\n\tRequestURI string `json:\"request_uri,omitempty\"`\n}\n\nfunc NewIntent(sandboxID string, port int, requestURI string) Intent {\n\treturn Intent{\n\t\tSandboxID:  sandboxID,\n\t\tObservedAt: time.Now().UTC().Format(time.RFC3339Nano),\n\t\tPort:       port,\n\t\tRequestURI: requestURI,\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/renewintent/intent_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage renewintent\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestNewIntent(t *testing.T) {\n\tintent := NewIntent(\"sb-123\", 8080, \"/api/foo\")\n\tassert.Equal(t, \"sb-123\", intent.SandboxID)\n\tassert.Equal(t, 8080, intent.Port)\n\tassert.Equal(t, \"/api/foo\", intent.RequestURI)\n\tassert.NotEmpty(t, intent.ObservedAt)\n}\n\nfunc TestIntent_JSONRoundTrip(t *testing.T) {\n\tintent := NewIntent(\"my-sandbox\", 80, \"/\")\n\tdata, err := json.Marshal(intent)\n\tassert.NoError(t, err)\n\tvar decoded Intent\n\terr = json.Unmarshal(data, &decoded)\n\tassert.NoError(t, err)\n\tassert.Equal(t, intent.SandboxID, decoded.SandboxID)\n\tassert.Equal(t, intent.Port, decoded.Port)\n\tassert.Equal(t, intent.RequestURI, decoded.RequestURI)\n}\n\nfunc TestIntent_JSONHasRequiredFields(t *testing.T) {\n\tintent := NewIntent(\"id\", 0, \"\")\n\tdata, err := json.Marshal(intent)\n\tassert.NoError(t, err)\n\tvar m map[string]interface{}\n\terr = json.Unmarshal(data, &m)\n\tassert.NoError(t, err)\n\tfor _, key := range []string{\"sandbox_id\", \"observed_at\"} {\n\t\t_, ok := m[key]\n\t\tassert.True(t, ok, \"missing required JSON field %q\", key)\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/renewintent/publisher.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage renewintent\n\ntype Publisher interface {\n\tPublishIntent(sandboxID string, port int, requestURI string)\n}\n"
  },
  {
    "path": "components/ingress/pkg/renewintent/redis.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage renewintent\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/redis/go-redis/v9\"\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n)\n\nconst (\n\tredisOpTimeout = 5 * time.Second\n\tpublishWorkers = 4\n\tpublishChanCap = 8192\n)\n\ntype RedisPublisherConfig struct {\n\tQueueKey    string\n\tQueueMaxLen int\n\tMinInterval time.Duration\n\tLogger      logger.Logger\n}\n\ntype intentReq struct {\n\tsandboxID  string\n\tport       int\n\trequestURI string\n}\n\ntype RedisPublisher struct {\n\tclient   *redis.Client\n\tcfg      RedisPublisherConfig\n\tlastSent sync.Map\n\tch       chan intentReq\n\tstopped  atomic.Bool\n}\n\nfunc NewRedisPublisher(ctx context.Context, client *redis.Client, cfg RedisPublisherConfig) *RedisPublisher {\n\tp := &RedisPublisher{client: client, cfg: cfg, ch: make(chan intentReq, publishChanCap)}\n\tfor i := 0; i < publishWorkers; i++ {\n\t\tgo func() {\n\t\t\tfor {\n\t\t\t\tselect {\n\t\t\t\tcase req := <-p.ch:\n\t\t\t\t\tp.doPublish(req.sandboxID, req.port, req.requestURI)\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\t}\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tp.stopped.Store(true)\n\t}()\n\n\tif cfg.MinInterval > 0 {\n\t\tgo wait.UntilWithContext(ctx, p.runCleanupThrottle, cfg.MinInterval*2)\n\t}\n\treturn p\n}\n\nfunc (p *RedisPublisher) shouldSendIntent(sandboxID string) bool {\n\tif p.cfg.MinInterval <= 0 {\n\t\treturn true\n\t}\n\tnow := time.Now()\n\tif v, ok := p.lastSent.Load(sandboxID); ok {\n\t\tif now.Sub(v.(time.Time)) < p.cfg.MinInterval {\n\t\t\treturn false\n\t\t}\n\t}\n\tp.lastSent.Store(sandboxID, now)\n\treturn true\n}\n\nfunc (p *RedisPublisher) PublishIntent(sandboxID string, port int, requestURI string) {\n\tif p.stopped.Load() {\n\t\treturn\n\t}\n\tselect {\n\tcase p.ch <- intentReq{sandboxID: sandboxID, port: port, requestURI: requestURI}:\n\tdefault:\n\t}\n}\n\nfunc (p *RedisPublisher) doPublish(sandboxID string, port int, requestURI string) {\n\tif !p.shouldSendIntent(sandboxID) {\n\t\treturn\n\t}\n\n\tintent := NewIntent(sandboxID, port, requestURI)\n\tpayload, err := json.Marshal(intent)\n\tif err != nil {\n\t\tp.cfg.Logger.With(logger.Field{Key: \"sandbox_id\", Value: sandboxID}).Errorf(\"renewintent: marshal intent: %v\", err)\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)\n\tdefer cancel()\n\tpipe := p.client.Pipeline()\n\tpipe.LPush(ctx, p.cfg.QueueKey, string(payload))\n\tif p.cfg.QueueMaxLen > 0 {\n\t\tpipe.LTrim(ctx, p.cfg.QueueKey, 0, int64(p.cfg.QueueMaxLen-1))\n\t}\n\t_, err = pipe.Exec(ctx)\n\tif err != nil {\n\t\tp.cfg.Logger.With(\n\t\t\tlogger.Field{Key: \"sandbox_id\", Value: sandboxID},\n\t\t\tlogger.Field{Key: \"queue_key\", Value: p.cfg.QueueKey},\n\t\t\tlogger.Field{Key: \"error\", Value: err},\n\t\t).Errorf(\"renewintent: redis publish failed\")\n\t\treturn\n\t}\n\tp.cfg.Logger.With(\n\t\tlogger.Field{Key: \"sandbox_id\", Value: sandboxID},\n\t\tlogger.Field{Key: \"queue_key\", Value: p.cfg.QueueKey},\n\t).Debugf(\"renewintent: published\")\n}\n\nfunc RedisClientFromDSN(dsn string) (*redis.Client, error) {\n\topts, err := redis.ParseURL(dsn)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif opts == nil {\n\t\treturn nil, errors.New(\"renewintent: redis DSN produced nil options\")\n\t}\n\treturn redis.NewClient(opts), nil\n}\n\nfunc (p *RedisPublisher) runCleanupThrottle(_ context.Context) {\n\tcutoff := time.Now().Add(-p.cfg.MinInterval * 2)\n\tp.lastSent.Range(func(key, value any) bool {\n\t\tif value.(time.Time).Before(cutoff) {\n\t\t\tp.lastSent.Delete(key)\n\t\t}\n\t\treturn true\n\t})\n}\n"
  },
  {
    "path": "components/ingress/pkg/renewintent/redis_bench_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage renewintent\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/alibaba/opensandbox/internal/logger\"\n\t\"github.com/alicebob/miniredis/v2\"\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype nopLogger struct{}\n\nfunc (nopLogger) Debugf(string, ...any)                {}\nfunc (nopLogger) Infof(string, ...any)                 {}\nfunc (nopLogger) Warnf(string, ...any)                 {}\nfunc (nopLogger) Errorf(string, ...any)                {}\nfunc (n nopLogger) With(...logger.Field) logger.Logger { return n }\nfunc (n nopLogger) Named(string) logger.Logger         { return n }\nfunc (nopLogger) Sync() error                          { return nil }\n\n// Benchmarks use miniredis (in-memory Redis) so timing excludes real network I/O.\n\nfunc BenchmarkRedisPublisher_PublishIntent(b *testing.B) {\n\tmr, err := miniredis.Run()\n\tif err != nil {\n\t\tb.Fatal(err)\n\t}\n\tdefer mr.Close()\n\n\tclient := redis.NewClient(&redis.Options{Addr: mr.Addr()})\n\tdefer client.Close()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tcfg := RedisPublisherConfig{\n\t\tQueueKey:    \"opensandbox:renew:intent\",\n\t\tQueueMaxLen: 0,\n\t\tMinInterval: 0,\n\t\tLogger:      nopLogger{},\n\t}\n\tp := NewRedisPublisher(ctx, client, cfg)\n\n\tsandboxID := \"bench-sandbox\"\n\tport := 8080\n\trequestURI := \"/api/health\"\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tp.PublishIntent(sandboxID, port, requestURI)\n\t}\n}\n\nfunc BenchmarkRedisPublisher_PublishIntent_Throttled(b *testing.B) {\n\tmr, err := miniredis.Run()\n\tif err != nil {\n\t\tb.Fatal(err)\n\t}\n\tdefer mr.Close()\n\n\tclient := redis.NewClient(&redis.Options{Addr: mr.Addr()})\n\tdefer client.Close()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tcfg := RedisPublisherConfig{\n\t\tQueueKey:    \"opensandbox:renew:intent\",\n\t\tQueueMaxLen: 0,\n\t\tMinInterval: 1 << 30, // large so throttle skips most\n\t\tLogger:      nopLogger{},\n\t}\n\tp := NewRedisPublisher(ctx, client, cfg)\n\n\tsandboxID := \"bench-sandbox\"\n\tport := 8080\n\trequestURI := \"/api/health\"\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tp.PublishIntent(sandboxID, port, requestURI)\n\t}\n}\n\nfunc BenchmarkRedisPublisher_PublishIntent_ManySandboxes(b *testing.B) {\n\tmr, err := miniredis.Run()\n\tif err != nil {\n\t\tb.Fatal(err)\n\t}\n\tdefer mr.Close()\n\n\tclient := redis.NewClient(&redis.Options{Addr: mr.Addr()})\n\tdefer client.Close()\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\tcfg := RedisPublisherConfig{\n\t\tQueueKey:    \"opensandbox:renew:intent\",\n\t\tQueueMaxLen: 0,\n\t\tMinInterval: 0,\n\t\tLogger:      nopLogger{},\n\t}\n\tp := NewRedisPublisher(ctx, client, cfg)\n\n\tport := 8080\n\trequestURI := \"/api/health\"\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tsandboxID := fmt.Sprintf(\"sandbox-%d\", i%1000)\n\t\tp.PublishIntent(sandboxID, port, requestURI)\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/agent_sandbox_provider.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/apimachinery/pkg/util/validation\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/cache\"\n)\n\nconst (\n\tagentSandboxGroup    = \"agents.x-k8s.io\"\n\tagentSandboxVersion  = \"v1alpha1\"\n\tagentSandboxResource = \"sandboxes\"\n\n\tagentSandboxConditionReady = \"Ready\"\n\tagentSandboxNamePrefix     = \"sandbox\"\n)\n\nvar (\n\tdns1035InvalidChars     = regexp.MustCompile(`[^a-z0-9-]+`)\n\tdns1035DuplicateHyphens = regexp.MustCompile(`-+`)\n)\n\n// AgentSandboxProvider implements Provider for agents.x-k8s.io Sandbox CR.\n// It uses a dynamic informer to watch resources in the target namespace.\ntype AgentSandboxProvider struct {\n\tinformerFactory dynamicinformer.DynamicSharedInformerFactory\n\tinformer        cache.SharedIndexInformer\n\tnamespace       string\n\tgvr             schema.GroupVersionResource\n}\n\n// NewAgentSandboxProvider creates a Provider backed by dynamic informer.\nfunc NewAgentSandboxProvider(config *rest.Config, namespace string, resyncPeriod time.Duration) *AgentSandboxProvider {\n\tdyn, err := dynamic.NewForConfig(config)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"failed to create dynamic client: %v\", err))\n\t}\n\n\treturn newAgentSandboxProviderWithClient(dyn, namespace, resyncPeriod)\n}\n\n// newAgentSandboxProviderWithClient is a helper for tests to inject fake dynamic client.\nfunc newAgentSandboxProviderWithClient(dyn dynamic.Interface, namespace string, resyncPeriod time.Duration) *AgentSandboxProvider {\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\n\tfactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(\n\t\tdyn,\n\t\tresyncPeriod,\n\t\tnamespace,\n\t\tnil, // no extra list options\n\t)\n\n\tinformer := factory.ForResource(gvr).Informer()\n\n\treturn &AgentSandboxProvider{\n\t\tinformerFactory: factory,\n\t\tinformer:        informer,\n\t\tnamespace:       namespace,\n\t\tgvr:             gvr,\n\t}\n}\n\nfunc agentSandboxResourceName(sandboxId string) string {\n\treturn toDNS1035Label(sandboxId, agentSandboxNamePrefix)\n}\n\nfunc toDNS1035Label(value, prefix string) string {\n\tnormalized := strings.ToLower(strings.TrimSpace(value))\n\tnormalized = dns1035InvalidChars.ReplaceAllString(normalized, \"-\")\n\tnormalized = dns1035DuplicateHyphens.ReplaceAllString(normalized, \"-\")\n\tnormalized = strings.Trim(normalized, \"-\")\n\n\thash := sha256.Sum256([]byte(value))\n\tsuffix := hex.EncodeToString(hash[:])[:8]\n\n\tif normalized == \"\" {\n\t\tnormalized = prefix + \"-\" + suffix\n\t} else if !startsWithLetter(normalized) {\n\t\tnormalized = prefix + \"-\" + normalized\n\t}\n\n\tif len(normalized) > validation.DNS1035LabelMaxLength {\n\t\tmaxBase := validation.DNS1035LabelMaxLength - len(suffix) - 1\n\t\tbase := normalized\n\t\tif len(base) > maxBase {\n\t\t\tbase = base[:maxBase]\n\t\t}\n\t\tbase = strings.Trim(base, \"-\")\n\t\tif !startsWithLetter(base) {\n\t\t\tbase = prefix\n\t\t}\n\t\tnormalized = base + \"-\" + suffix\n\t}\n\n\treturn strings.Trim(normalized, \"-\")\n}\n\nfunc startsWithLetter(value string) bool {\n\tif value == \"\" {\n\t\treturn false\n\t}\n\tfirst := value[0]\n\treturn first >= 'a' && first <= 'z'\n}\n\nfunc legacyAgentSandboxName(sandboxId string) string {\n\tlegacyPrefix := agentSandboxNamePrefix + \"-\"\n\tif strings.HasPrefix(sandboxId, legacyPrefix) {\n\t\treturn sandboxId\n\t}\n\treturn legacyPrefix + sandboxId\n}\n\nfunc resourceNameCandidates(sandboxId string) []string {\n\tcandidates := []string{}\n\tprimary := agentSandboxResourceName(sandboxId)\n\tcandidates = append(candidates, primary)\n\tif sandboxId != primary {\n\t\tcandidates = append(candidates, sandboxId)\n\t}\n\tlegacy := legacyAgentSandboxName(sandboxId)\n\tif legacy != primary && legacy != sandboxId {\n\t\tcandidates = append(candidates, legacy)\n\t}\n\treturn candidates\n}\n\nfunc (a *AgentSandboxProvider) GetEndpoint(sandboxId string) (string, error) {\n\tcandidates := resourceNameCandidates(sandboxId)\n\tvar (\n\t\tobj    any\n\t\texists bool\n\t\terr    error\n\t)\n\tfor _, name := range candidates {\n\t\tkey := fmt.Sprintf(\"%s/%s\", a.namespace, name)\n\t\tobj, exists, err = a.informer.GetStore().GetByKey(key)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to get AgentSandbox %s: %w\", key, err)\n\t\t}\n\t\tif exists {\n\t\t\tbreak\n\t\t}\n\t}\n\tif !exists {\n\t\treturn \"\", fmt.Errorf(\"%w: %s/%s\", ErrSandboxNotFound, a.namespace, sandboxId)\n\t}\n\n\tu, ok := obj.(*unstructured.Unstructured)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"unexpected object type for sandbox %s: %T\", sandboxId, obj)\n\t}\n\n\tstatus, ok := u.Object[\"status\"].(map[string]any)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"%w: sandbox %s missing status\", ErrSandboxNotReady, sandboxId)\n\t}\n\n\t// Check ready condition first; must be Ready=True to proceed.\n\tif ready, reason, message := a.checkSandboxReadyCondition(status); !ready {\n\t\treturn \"\", fmt.Errorf(\"%w: sandbox %s not ready (%s: %s)\", ErrSandboxNotReady, sandboxId, reason, message)\n\t}\n\n\tserviceFQDN, _ := status[\"serviceFQDN\"].(string)\n\tif serviceFQDN == \"\" {\n\t\treturn \"\", fmt.Errorf(\"%w: sandbox %s has no serviceFQDN\", ErrSandboxNotReady, sandboxId)\n\t}\n\n\treturn serviceFQDN, nil\n}\n\n// Start starts the informer factory and waits for cache sync.\nfunc (a *AgentSandboxProvider) Start(ctx context.Context) error {\n\ta.informerFactory.Start(ctx.Done())\n\n\tif !cache.WaitForCacheSync(ctx.Done(), a.informer.HasSynced) {\n\t\treturn errors.New(\"failed to sync AgentSandbox informer cache\")\n\t}\n\n\treturn nil\n}\n\n// checkSandboxReadyCondition inspects status.conditions for Ready=True.\n// Returns (isReady, reason, message).\n//\n// https://github.com/kubernetes-sigs/agent-sandbox/blob/main/controllers/sandbox_controller.go#L195\nfunc (a *AgentSandboxProvider) checkSandboxReadyCondition(status map[string]any) (bool, string, string) {\n\tconds, ok := status[\"conditions\"].([]any)\n\tif !ok {\n\t\treturn false, \"NoConditions\", \"no sandbox conditions reported\"\n\t}\n\tfor _, c := range conds {\n\t\tm, ok := c.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif t, _ := m[\"type\"].(string); t != agentSandboxConditionReady {\n\t\t\tcontinue\n\t\t}\n\t\tif s, _ := m[\"status\"].(string); s == string(metav1.ConditionTrue) {\n\t\t\treturn true, agentSandboxConditionReady, \"\"\n\t\t}\n\t\treason, _ := m[\"reason\"].(string)\n\t\tmessage, _ := m[\"message\"].(string)\n\t\tif reason == \"\" {\n\t\t\treason = \"DependenciesNotReady\"\n\t\t}\n\t\tif message == \"\" {\n\t\t\tmessage = \"Ready condition is not True\"\n\t\t}\n\t\treturn false, reason, message\n\t}\n\n\treturn false, \"ReadyConditionMissing\", \"ready condition missing\"\n}\n\nvar _ Provider = (*AgentSandboxProvider)(nil)\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/agent_sandbox_provider_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\tdynamicfake \"k8s.io/client-go/dynamic/fake\"\n)\n\n// buildUnstructuredSandbox creates a minimal unstructured Sandbox object.\nfunc buildUnstructuredSandbox(name, namespace string) *unstructured.Unstructured {\n\treturn &unstructured.Unstructured{\n\t\tObject: map[string]any{\n\t\t\t\"apiVersion\": agentSandboxGroup + \"/\" + agentSandboxVersion,\n\t\t\t\"kind\":       \"Sandbox\",\n\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\"name\":      name,\n\t\t\t\t\"namespace\": namespace,\n\t\t\t},\n\t\t\t\"spec\": map[string]any{\n\t\t\t\t\"podTemplate\": map[string]any{\n\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\"containers\": []any{},\n\t\t\t\t\t},\n\t\t\t\t\t\"metadata\": map[string]any{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestAgentSandboxProvider_Start_Success(t *testing.T) {\n\tnamespace := \"test-ns\"\n\n\tobj := buildUnstructuredSandbox(\"demo\", namespace)\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t\tobj,\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err, \"Start should succeed with fake dynamic informer\")\n\n\t// Manually seed store (fake dynamic client doesn't backfill informer cache automatically)\n\terr = provider.informer.GetStore().Add(obj)\n\tassert.NoError(t, err)\n\n\tkey := obj.GetNamespace() + \"/\" + obj.GetName()\n\t_, exists, _ := provider.informer.GetStore().GetByKey(key)\n\tassert.True(t, exists, \"informer cache should accept added object after start\")\n}\n\nfunc TestAgentSandboxProvider_Start_ContextCancelled(t *testing.T) {\n\tnamespace := \"test-ns\"\n\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel() // cancel before start\n\n\terr := provider.Start(ctx)\n\tassert.Error(t, err, \"Start should fail when context already cancelled\")\n}\n\nfunc TestAgentSandboxProvider_GetEndpoint_ServiceFQDN(t *testing.T) {\n\tnamespace := \"test-ns\"\n\tobj := buildUnstructuredSandbox(\"demo\", namespace)\n\tobj.Object[\"status\"] = map[string]any{\n\t\t\"serviceFQDN\": \"sandbox.demo.svc.cluster.local\",\n\t\t\"conditions\": []any{\n\t\t\tmap[string]any{\n\t\t\t\t\"type\":   \"Ready\",\n\t\t\t\t\"status\": \"True\",\n\t\t\t},\n\t\t},\n\t}\n\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Seed store\n\terr = provider.informer.GetStore().Add(obj)\n\tassert.NoError(t, err)\n\n\tendpoint, err := provider.GetEndpoint(\"demo\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"sandbox.demo.svc.cluster.local\", endpoint)\n}\n\nfunc TestAgentSandboxProvider_GetEndpoint_NotFound(t *testing.T) {\n\tnamespace := \"test-ns\"\n\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t_, err = provider.GetEndpoint(\"missing\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotFound))\n}\n\nfunc TestAgentSandboxProvider_GetEndpoint_NoServiceFQDN(t *testing.T) {\n\tnamespace := \"test-ns\"\n\tobj := buildUnstructuredSandbox(\"demo\", namespace)\n\tobj.Object[\"status\"] = map[string]any{}\n\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Seed store\n\terr = provider.informer.GetStore().Add(obj)\n\tassert.NoError(t, err)\n\n\t_, err = provider.GetEndpoint(\"demo\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotReady))\n}\n\nfunc TestAgentSandboxProvider_GetEndpoint_NotReadyCondition(t *testing.T) {\n\tnamespace := \"test-ns\"\n\tobj := buildUnstructuredSandbox(\"demo\", namespace)\n\tobj.Object[\"status\"] = map[string]any{\n\t\t\"serviceFQDN\": \"sandbox.demo.svc.cluster.local\",\n\t\t\"conditions\": []any{\n\t\t\tmap[string]any{\n\t\t\t\t\"type\":    \"Ready\",\n\t\t\t\t\"status\":  \"False\",\n\t\t\t\t\"reason\":  \"DependenciesNotReady\",\n\t\t\t\t\"message\": \"Pod not ready\",\n\t\t\t},\n\t\t},\n\t}\n\n\tscheme := runtime.NewScheme()\n\tgvr := schema.GroupVersionResource{\n\t\tGroup:    agentSandboxGroup,\n\t\tVersion:  agentSandboxVersion,\n\t\tResource: agentSandboxResource,\n\t}\n\tfakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(\n\t\tscheme,\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tgvr: \"SandboxList\",\n\t\t},\n\t)\n\n\tprovider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Seed store\n\terr = provider.informer.GetStore().Add(obj)\n\tassert.NoError(t, err)\n\n\t_, err = provider.GetEndpoint(\"demo\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotReady))\n}\n\nfunc TestToDNS1035Label_HashOnSymbolOnlyIDs(t *testing.T) {\n\tname1 := toDNS1035Label(\"!!!\", agentSandboxNamePrefix)\n\tname2 := toDNS1035Label(\"???\", agentSandboxNamePrefix)\n\n\tassert.NotEqual(t, name1, name2)\n\tassert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name1)\n\tassert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name2)\n}\n\nfunc TestToDNS1035Label_PrefixesDigitStart(t *testing.T) {\n\tname := toDNS1035Label(\"1234\", agentSandboxNamePrefix)\n\tassert.Equal(t, \"sandbox-1234\", name)\n}\n\nfunc TestToDNS1035Label_TruncatesWithHashSuffix(t *testing.T) {\n\tinput := \"A\" + strings.Repeat(\"b\", 100)\n\tname := toDNS1035Label(input, agentSandboxNamePrefix)\n\n\tassert.LessOrEqual(t, len(name), 63)\n\tassert.Regexp(t, `^[a-z][a-z0-9-]*$`, name)\n\tassert.Regexp(t, `[0-9a-f]{8}$`, name)\n}\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/batchsandbox_provider.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\tkerrors \"k8s.io/apimachinery/pkg/api/errors\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\tclientset \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tinformers \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions\"\n\tlisters \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils\"\n)\n\n// BatchSandboxProvider implements Provider interface for BatchSandbox resources\ntype BatchSandboxProvider struct {\n\tinformerFactory informers.SharedInformerFactory\n\tlister          listers.BatchSandboxLister\n\tinformerSynced  cache.InformerSynced\n\tnamespace       string\n}\n\n// NewBatchSandboxProvider creates a new BatchSandboxProvider\nfunc NewBatchSandboxProvider(\n\tconfig *rest.Config,\n\tnamespace string,\n\tresyncPeriod time.Duration,\n) *BatchSandboxProvider {\n\tclientset, err := clientset.NewForConfig(config)\n\tif err != nil {\n\t\tpanic(fmt.Sprintf(\"failed to create sandbox clientset: %v\", err))\n\t}\n\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tclientset,\n\t\tresyncPeriod,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\treturn &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n}\n\n// Start starts the informer factory and waits for cache sync\nfunc (p *BatchSandboxProvider) Start(ctx context.Context) error {\n\tp.informerFactory.Start(ctx.Done())\n\n\t// Wait for cache sync\n\tif !cache.WaitForCacheSync(ctx.Done(), p.informerSynced) {\n\t\treturn errors.New(\"failed to sync BatchSandbox informer cache\")\n\t}\n\n\treturn nil\n}\n\n// GetEndpoint retrieves the endpoint IP for a BatchSandbox\nfunc (p *BatchSandboxProvider) GetEndpoint(sandboxId string) (string, error) {\n\t// Get BatchSandbox from cache using lister with provider's namespace\n\tbatchSandbox, err := p.lister.BatchSandboxes(p.namespace).Get(sandboxId)\n\tif err != nil {\n\t\tif kerrors.IsNotFound(err) {\n\t\t\treturn \"\", fmt.Errorf(\"%w: %s/%s\", ErrSandboxNotFound, p.namespace, sandboxId)\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"failed to get BatchSandbox %s/%s: %w\", p.namespace, sandboxId, err)\n\t}\n\n\t// Check if BatchSandbox is ready\n\tif batchSandbox.Status.Ready < 1 {\n\t\treturn \"\", fmt.Errorf(\"%w: %s/%s (ready: %d/%d)\",\n\t\t\tErrSandboxNotReady, p.namespace, sandboxId, batchSandbox.Status.Ready, batchSandbox.Status.Replicas)\n\t}\n\n\t// Get endpoints from BatchSandbox using kubernetes utils\n\tendpoints, err := utils.GetEndpoints(batchSandbox)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"%w: %s/%s: %w\", ErrSandboxNotReady, p.namespace, sandboxId, err)\n\t}\n\n\t// Return the first available endpoint\n\treturn endpoints[0], nil\n}\n\nvar _ Provider = (*BatchSandboxProvider)(nil)\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/batchsandbox_provider_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tfakeclientset \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/fake\"\n\tinformers \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils\"\n)\n\n// Note: Integration tests with real informers are in e2e tests\n// Unit tests here focus on provider behavior\n\n// TestBatchSandboxProvider_WithFakeInformer tests the provider using fake clientset and informer\nfunc TestBatchSandboxProvider_WithFakeInformer(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\t// Create a ready BatchSandbox with valid endpoints\n\treadyBatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"ready-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tutils.AnnotationEndpoints: `[\"10.0.0.1\", \"10.0.0.2\"]`,\n\t\t\t},\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(2)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 2,\n\t\t\tReady:    2,\n\t\t},\n\t}\n\n\t// Create a not ready BatchSandbox\n\tnotReadyBatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"not-ready-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(1)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 1,\n\t\t\tReady:    0,\n\t\t},\n\t}\n\n\t// Create fake clientset with test objects\n\tfakeClient := fakeclientset.NewSimpleClientset(readyBatchSandbox, notReadyBatchSandbox)\n\n\t// Create informer factory\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\t// Create provider\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\t// Start informer and wait for cache sync\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err, \"Provider should start successfully\")\n\n\t// Manually add objects to informer cache (fake clientset doesn't auto-populate informer)\n\terr = batchSandboxInformer.Informer().GetStore().Add(readyBatchSandbox)\n\tassert.NoError(t, err)\n\terr = batchSandboxInformer.Informer().GetStore().Add(notReadyBatchSandbox)\n\tassert.NoError(t, err)\n\n\t// Test 1: Get endpoint from ready sandbox\n\tt.Run(\"GetEndpoint from ready sandbox\", func(t *testing.T) {\n\t\tendpoint, err := provider.GetEndpoint(\"ready-sandbox\")\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"10.0.0.1\", endpoint, \"Should return first endpoint IP\")\n\t})\n\n\t// Test 2: Get endpoint from not ready sandbox\n\tt.Run(\"GetEndpoint from not ready sandbox\", func(t *testing.T) {\n\t\t_, err := provider.GetEndpoint(\"not-ready-sandbox\")\n\t\tassert.Error(t, err)\n\t\tassert.True(t, errors.Is(err, ErrSandboxNotReady), \"Should return ErrSandboxNotReady\")\n\t\tassert.Contains(t, err.Error(), \"not ready\")\n\t})\n\n\t// Test 3: Get endpoint from non-existent sandbox\n\tt.Run(\"GetEndpoint from non-existent sandbox\", func(t *testing.T) {\n\t\t_, err := provider.GetEndpoint(\"non-existent\")\n\t\tassert.Error(t, err)\n\t\tassert.True(t, errors.Is(err, ErrSandboxNotFound), \"Should return ErrSandboxNotFound\")\n\t\tassert.Contains(t, err.Error(), \"not found\")\n\t})\n}\n\n// TestBatchSandboxProvider_MissingAnnotation tests sandbox without endpoints annotation\nfunc TestBatchSandboxProvider_MissingAnnotation(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\t// Create BatchSandbox without endpoints annotation\n\tbatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"no-annotation-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(1)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 1,\n\t\t\tReady:    1,\n\t\t},\n\t}\n\n\tfakeClient := fakeclientset.NewSimpleClientset(batchSandbox)\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Manually add object to informer cache\n\terr = batchSandboxInformer.Informer().GetStore().Add(batchSandbox)\n\tassert.NoError(t, err)\n\n\t_, err = provider.GetEndpoint(\"no-annotation-sandbox\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotReady), \"Should return ErrSandboxNotReady\")\n\tassert.Contains(t, err.Error(), \"has no annotations\")\n}\n\n// TestBatchSandboxProvider_InvalidAnnotation tests sandbox with invalid annotation format\nfunc TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\tbatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"invalid-annotation-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tutils.AnnotationEndpoints: `invalid-json`,\n\t\t\t},\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(1)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 1,\n\t\t\tReady:    1,\n\t\t},\n\t}\n\n\tfakeClient := fakeclientset.NewSimpleClientset(batchSandbox)\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Manually add object to informer cache\n\terr = batchSandboxInformer.Informer().GetStore().Add(batchSandbox)\n\tassert.NoError(t, err)\n\n\t_, err = provider.GetEndpoint(\"invalid-annotation-sandbox\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotReady), \"Should return ErrSandboxNotReady\")\n\tassert.Contains(t, err.Error(), \"failed to parse\")\n}\n\n// TestBatchSandboxProvider_DynamicUpdate tests adding object after informer starts\nfunc TestBatchSandboxProvider_DynamicUpdate(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\tfakeClient := fakeclientset.NewSimpleClientset()\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Initially no sandbox exists\n\t_, err = provider.GetEndpoint(\"dynamic-sandbox\")\n\tassert.Error(t, err)\n\tassert.True(t, errors.Is(err, ErrSandboxNotFound), \"Should return ErrSandboxNotFound\")\n\tassert.Contains(t, err.Error(), \"not found\")\n\n\t// Add a new BatchSandbox\n\tnewBatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"dynamic-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tutils.AnnotationEndpoints: `[\"10.0.0.100\"]`,\n\t\t\t},\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(1)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 1,\n\t\t\tReady:    1,\n\t\t},\n\t}\n\n\t_, err = fakeClient.SandboxV1alpha1().BatchSandboxes(namespace).Create(\n\t\tcontext.Background(), newBatchSandbox, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\t// Wait for informer to pick up the change\n\tassert.Eventually(t, func() bool {\n\t\tendpoint, err := provider.GetEndpoint(\"dynamic-sandbox\")\n\t\treturn err == nil && endpoint == \"10.0.0.100\"\n\t}, 3*time.Second, 100*time.Millisecond, \"Informer should eventually sync the new object\")\n}\n\n// TestBatchSandboxProvider_StartCacheSyncFailure tests cache sync timeout\nfunc TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\tfakeClient := fakeclientset.NewSimpleClientset()\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\t// Create a context that expires immediately\n\tctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)\n\tdefer cancel()\n\n\t// Wait for context to expire\n\ttime.Sleep(10 * time.Millisecond)\n\n\terr := provider.Start(ctx)\n\tassert.Error(t, err, \"Should fail when cache sync times out\")\n\tassert.Contains(t, err.Error(), \"failed to sync\")\n}\n\n// TestBatchSandboxProvider_GetEndpointNonNotFoundError tests non-IsNotFound K8s errors\nfunc TestBatchSandboxProvider_GetEndpointNonNotFoundError(t *testing.T) {\n\tnamespace := \"test-namespace\"\n\n\t// Create a sandbox with Ready status but missing endpoint annotation\n\tbatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"missing-endpoint-sandbox\",\n\t\t\tNamespace: namespace,\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tutils.AnnotationEndpoints: `[\"10.0.0.1\"]`,\n\t\t\t},\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: ptr(int32(1)),\n\t\t},\n\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{\n\t\t\tReplicas: 1,\n\t\t\tReady:    1,\n\t\t},\n\t}\n\n\tfakeClient := fakeclientset.NewSimpleClientset(batchSandbox)\n\tinformerFactory := informers.NewSharedInformerFactoryWithOptions(\n\t\tfakeClient,\n\t\ttime.Second*30,\n\t\tinformers.WithNamespace(namespace),\n\t)\n\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\n\tprovider := &BatchSandboxProvider{\n\t\tinformerFactory: informerFactory,\n\t\tlister:          batchSandboxInformer.Lister(),\n\t\tinformerSynced:  batchSandboxInformer.Informer().HasSynced,\n\t\tnamespace:       namespace,\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\terr := provider.Start(ctx)\n\tassert.NoError(t, err)\n\n\t// Manually add object to informer cache\n\terr = batchSandboxInformer.Informer().GetStore().Add(batchSandbox)\n\tassert.NoError(t, err)\n\n\t// Should successfully get endpoint\n\tendpoint, err := provider.GetEndpoint(\"missing-endpoint-sandbox\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"10.0.0.1\", endpoint)\n}\n\n// ptr is a helper function to create int32 pointer\nfunc ptr(i int32) *int32 {\n\treturn &i\n}\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/errors_test.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\n// Ensure wrapping ErrSandboxNotReady keeps errors.Is behavior.\nfunc TestErrSandboxNotReadyWrapping(t *testing.T) {\n\twrapped := fmt.Errorf(\"%w: custom detail\", ErrSandboxNotReady)\n\n\tif !errors.Is(wrapped, ErrSandboxNotReady) {\n\t\tt.Fatalf(\"expected errors.Is to match ErrSandboxNotReady, got false; err=%v\", wrapped)\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/factory.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"k8s.io/client-go/rest\"\n)\n\n// DefaultProviderFactory is the default implementation of ProviderFactory\ntype DefaultProviderFactory struct {\n\tconfig       *rest.Config\n\tnamespace    string\n\tresyncPeriod time.Duration\n}\n\n// NewProviderFactory creates a new DefaultProviderFactory\nfunc NewProviderFactory(config *rest.Config, namespace string, resyncPeriod time.Duration) *DefaultProviderFactory {\n\treturn &DefaultProviderFactory{\n\t\tconfig:       config,\n\t\tnamespace:    namespace,\n\t\tresyncPeriod: resyncPeriod,\n\t}\n}\n\n// CreateProvider creates a Provider instance based on the provider type\nfunc (f *DefaultProviderFactory) CreateProvider(providerType ProviderType) (Provider, error) {\n\tswitch providerType {\n\tcase ProviderTypeBatchSandbox:\n\t\treturn NewBatchSandboxProvider(f.config, f.namespace, f.resyncPeriod), nil\n\tcase ProviderTypeAgentSandbox:\n\t\treturn NewAgentSandboxProvider(f.config, f.namespace, f.resyncPeriod), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported provider type: %s\", providerType)\n\t}\n}\n"
  },
  {
    "path": "components/ingress/pkg/sandbox/provider.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"errors\"\n)\n\ntype ProviderType string\n\nconst (\n\tProviderTypeBatchSandbox ProviderType = \"batchsandbox\"\n\tProviderTypeAgentSandbox ProviderType = \"agent-sandbox\"\n)\n\nfunc (tpy ProviderType) String() string { return string(tpy) }\n\n// Standard errors for Provider operations\nvar (\n\t// ErrSandboxNotFound indicates the sandbox resource does not exist\n\tErrSandboxNotFound = errors.New(\"sandbox not found\")\n\n\t// ErrSandboxNotReady indicates the sandbox exists but is not ready\n\t// This includes: not enough ready replicas, missing endpoints, invalid configuration\n\tErrSandboxNotReady = errors.New(\"sandbox not ready\")\n)\n\n// Provider defines the interface for sandbox resource providers\n// Implementations include BatchSandboxProvider, AgentSandboxProvider, etc.\ntype Provider interface {\n\t// GetEndpoint retrieves the IP address for a sandbox by its id/name\n\t// The namespace is determined by the provider's configuration\n\t// Returns the first available IP from the endpoints annotation\n\t// Returns error if sandbox not found or no endpoints available\n\t// Note: This is a local cache query, no network I/O involved\n\tGetEndpoint(sandboxId string) (string, error)\n\n\t// Start initializes and starts the provider's informer cache\n\t// Waits for cache sync before returning\n\t// Must be called before using GetEndpoint\n\tStart(ctx context.Context) error\n}\n\n// ProviderFactory creates a Provider instance based on the provider type\ntype ProviderFactory interface {\n\tCreateProvider(providerType ProviderType) (Provider, error)\n}\n"
  },
  {
    "path": "components/internal/go.mod",
    "content": "module github.com/alibaba/opensandbox/internal\n\ngo 1.24.0\n\nrequire go.uber.org/zap v1.27.0\n\nrequire go.uber.org/multierr v1.10.0 // indirect\n"
  },
  {
    "path": "components/internal/go.sum",
    "content": "github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=\ngo.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "components/internal/logger/logger.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logger\n\n// Field is a structured logging key/value pair.\ntype Field struct {\n\tKey   string\n\tValue any\n}\n\n// Logger defines the minimal logging surface shared by components.\n//   - Formatted levels: Debugf/Infof/Warnf/Errorf\n//   - With: attach structured fields to derived logger\n//   - Named: derive a sub-logger with name\n//   - Sync: flush buffers (no-op for implementations that don't buffer)\ntype Logger interface {\n\tDebugf(template string, args ...any)\n\tInfof(template string, args ...any)\n\tWarnf(template string, args ...any)\n\tErrorf(template string, args ...any)\n\tWith(fields ...Field) Logger\n\tNamed(name string) Logger\n\tSync() error\n}\n"
  },
  {
    "path": "components/internal/logger/zap.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logger\n\nimport (\n\t\"os\"\n\t\"strings\"\n\n\t\"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n)\n\nconst envLogOutput = \"OPENSANDBOX_LOG_OUTPUT\"\n\n// Config is the minimal configuration to align execd/ingress defaults.\n// - JSON encoding, ISO8601 time\n// - Caller/stacktrace disabled\n// - Stdout as default output\n// - Level defaults to info\ntype Config struct {\n\tLevel            string   // debug|info|warn|error|fatal (default: info)\n\tOutputPaths      []string // default: stdout\n\tErrorOutputPaths []string // default: OutputPaths\n}\n\n// New creates a zap-backed Logger with the provided config.\nfunc New(cfg Config) (Logger, error) {\n\tcfg = applyEnvOutputs(cfg)\n\n\tzapCfg := zap.NewProductionConfig()\n\tzapCfg.Level = zap.NewAtomicLevelAt(parseLevel(cfg.Level))\n\tzapCfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder\n\tzapCfg.EncoderConfig.CallerKey = \"\"\n\tzapCfg.DisableCaller = true\n\tzapCfg.DisableStacktrace = true\n\tzapCfg.EncoderConfig.StacktraceKey = \"\"\n\n\tzapCfg.OutputPaths = cfg.OutputPaths\n\tzapCfg.ErrorOutputPaths = cfg.ErrorOutputPaths\n\n\tbase, err := zapCfg.Build()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &zapLogger{base: base, sugar: base.Sugar()}, nil\n}\n\n// MustNew is a convenience helper that panics on error.\nfunc MustNew(cfg Config) Logger {\n\tl, err := New(cfg)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn l\n}\n\n// AsZapSugared returns the underlying zap SugaredLogger when available.\nfunc AsZapSugared(l Logger) (*zap.SugaredLogger, bool) {\n\tzl, ok := l.(*zapLogger)\n\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn zl.sugar, true\n}\n\ntype zapLogger struct {\n\tbase  *zap.Logger\n\tsugar *zap.SugaredLogger\n}\n\nfunc (l *zapLogger) Debugf(template string, args ...any) {\n\tl.sugar.Debugf(template, args...)\n}\n\nfunc (l *zapLogger) Infof(template string, args ...any) {\n\tl.sugar.Infof(template, args...)\n}\n\nfunc (l *zapLogger) Warnf(template string, args ...any) {\n\tl.sugar.Warnf(template, args...)\n}\n\nfunc (l *zapLogger) Errorf(template string, args ...any) {\n\tl.sugar.Errorf(template, args...)\n}\n\nfunc (l *zapLogger) With(fields ...Field) Logger {\n\tif len(fields) == 0 {\n\t\treturn l\n\t}\n\tzfs := make([]zap.Field, 0, len(fields))\n\tfor _, f := range fields {\n\t\tzfs = append(zfs, zap.Any(f.Key, f.Value))\n\t}\n\tnb := l.base.With(zfs...)\n\treturn &zapLogger{base: nb, sugar: nb.Sugar()}\n}\n\nfunc (l *zapLogger) Named(name string) Logger {\n\tnb := l.base.Named(name)\n\treturn &zapLogger{base: nb, sugar: nb.Sugar()}\n}\n\nfunc (l *zapLogger) Sync() error {\n\treturn l.base.Sync()\n}\n\nfunc parseLevel(level string) zapcore.Level {\n\tswitch strings.ToLower(level) {\n\tcase \"debug\":\n\t\treturn zapcore.DebugLevel\n\tcase \"warn\", \"warning\":\n\t\treturn zapcore.WarnLevel\n\tcase \"error\":\n\t\treturn zapcore.ErrorLevel\n\tcase \"fatal\":\n\t\treturn zapcore.FatalLevel\n\tdefault:\n\t\treturn zapcore.InfoLevel\n\t}\n}\n\nfunc applyEnvOutputs(cfg Config) Config {\n\tenvVal := strings.TrimSpace(os.Getenv(envLogOutput))\n\tif len(cfg.OutputPaths) == 0 {\n\t\tif envVal != \"\" {\n\t\t\tcfg.OutputPaths = splitAndTrim(envVal)\n\t\t} else {\n\t\t\tcfg.OutputPaths = []string{\"stdout\"}\n\t\t}\n\t}\n\tif len(cfg.ErrorOutputPaths) == 0 {\n\t\t// Default error output matches output paths.\n\t\tcfg.ErrorOutputPaths = cfg.OutputPaths\n\t}\n\treturn cfg\n}\n\nfunc splitAndTrim(s string) []string {\n\tparts := strings.Split(s, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, p := range parts {\n\t\tif v := strings.TrimSpace(p); v != \"\" {\n\t\t\tout = append(out, v)\n\t\t}\n\t}\n\treturn out\n}\n"
  },
  {
    "path": "components/internal/version/version.go",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage version\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n)\n\n// Package values are typically overridden at build time via -ldflags.\nvar (\n\t// Version is the component version.\n\tVersion = \"dirty\"\n\t// BuildTime is when the binary was built.\n\tBuildTime = \"assigned-at-build-time\"\n\t// GitCommit is the commit id used to build the binary.\n\tGitCommit = \"assigned-at-build-time\"\n)\n\n// EchoVersion prints build info for the given component name (e.g. \"OpenSandbox Ingress\", \"OpenSandbox Execd\").\n// All components can use this by passing their display name.\nfunc EchoVersion(componentName string) {\n\tfmt.Println(\"=====================================================\")\n\tfmt.Printf(\" %s\\n\", componentName)\n\tfmt.Println(\"-----------------------------------------------------\")\n\tfmt.Printf(\" Version     : %s\\n\", Version)\n\tfmt.Printf(\" Git Commit  : %s\\n\", GitCommit)\n\tfmt.Printf(\" Build Time  : %s\\n\", BuildTime)\n\tfmt.Printf(\" Go Version  : %s\\n\", runtime.Version())\n\tfmt.Printf(\" Platform    : %s/%s\\n\", runtime.GOOS, runtime.GOARCH)\n\tfmt.Println(\"=====================================================\")\n}\n"
  },
  {
    "path": "docs/.nvmrc",
    "content": "22\n"
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import { defineConfig } from \"vitepress\";\nimport { loadManifest } from \"./scripts/docs-manifest.mjs\";\n\nconst manifest = loadManifest();\nconst docsBase = process.env.DOCS_BASE || \"/\";\n\nexport default defineConfig({\n  title: \"OpenSandbox\",\n  description: \"OpenSandbox documentation site for users and developers\",\n  head: [[\"link\", { rel: \"icon\", type: \"image/svg+xml\", href: \"/favicon.svg\" }]],\n  cleanUrls: true,\n  lastUpdated: true,\n  base: docsBase,\n  ignoreDeadLinks: [/^https?:\\/\\/localhost/, /\\/README$/, /\\/index$/, \"./contributing\"],\n  srcExclude: [\"node_modules/**\", \"README_zh.md\", \"RELEASE_NOTE_TEMPLATE.md\"],\n  rewrites: manifest.rewrites,\n  themeConfig: {\n    logo: \"/assets/logo.svg\",\n    search: {\n      provider: \"local\",\n    },\n    socialLinks: [{ icon: \"github\", link: \"https://github.com/alibaba/OpenSandbox\" }],\n    nav: manifest.nav.en,\n    sidebar: {\n      ...manifest.sidebar.en,\n      ...manifest.sidebar.zh,\n    },\n    outline: {\n      level: [2, 3],\n    },\n  },\n  locales: {\n    root: {\n      label: \"English\",\n      lang: \"en-US\",\n      themeConfig: {\n        nav: manifest.nav.en,\n      },\n    },\n    zh: {\n      label: \"简体中文\",\n      lang: \"zh-CN\",\n      themeConfig: {\n        nav: manifest.nav.zh,\n      },\n    },\n  },\n});\n"
  },
  {
    "path": "docs/.vitepress/scripts/docs-manifest.mjs",
    "content": "import fs from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst repoRoot = path.resolve(__dirname, \"../../../\");\nconst docsRoot = path.join(repoRoot, \"docs\");\nconst generatedRoot = path.join(docsRoot, \"generated\");\nconst manifestPath = path.join(docsRoot, \".vitepress\", \"generated\", \"manifest.json\");\n\nconst blobBaseUrl = \"https://github.com/alibaba/OpenSandbox/blob/main\";\nconst treeBaseUrl = \"https://github.com/alibaba/OpenSandbox/tree/main\";\nconst rawBaseUrl = \"https://raw.githubusercontent.com/alibaba/OpenSandbox/main\";\n\nconst ignoredDirNames = new Set([\n  \".git\",\n  \".github\",\n  \"node_modules\",\n  \".vitepress\",\n  \".pytest_cache\",\n  \"generated\",\n  \".venv\",\n  \"venv\",\n  \"__pycache__\",\n  \"dist\",\n  \"build\",\n  \"target\",\n  \"bin\",\n]);\nconst zhReadmePattern = /^README(?:[-_](?:zh|zh-cn|zh_cn))?\\.md$/i;\nconst standardReadmePattern = /^README\\.md$/i;\n\nconst sectionDefinitions = [\n  {\n    id: \"modules\",\n    scanRoots: [\"server\", \"components\", \"sandboxes\", \"kubernetes\", \"specs\", \"sdks\"],\n    includeDevelopment: true,\n  },\n  {\n    id: \"examples\",\n    scanRoots: [\"examples\"],\n    includeDevelopment: false,\n  },\n  {\n    id: \"community\",\n    scanRoots: [\"oseps\"],\n    includeDevelopment: false,\n  },\n];\n\nconst manualEntries = [\n  {\n    key: \"guide-home\",\n    sectionId: \"overview\",\n    slug: \"overview/home\",\n    enPath: \"README.md\",\n    zhPath: \"docs/README_zh.md\",\n    titleEn: \"OpenSandbox\",\n    titleZh: \"OpenSandbox\",\n  },\n  {\n    key: \"guide-architecture\",\n    sectionId: \"overview\",\n    slug: \"overview/architecture\",\n    enPath: \"docs/architecture.md\",\n    zhPath: null,\n    titleEn: \"Architecture\",\n    titleZh: \"架构设计\",\n  },\n  {\n    key: \"guide-network\",\n    sectionId: \"modules\",\n    slug: \"design/single-host-network\",\n    enPath: \"docs/single_host_network.md\",\n    zhPath: null,\n    titleEn: \"Single Host Network\",\n    titleZh: \"单机场景网络设计\",\n  },\n  {\n    key: \"community-contributing\",\n    sectionId: \"community\",\n    slug: \"community/contributing\",\n    enPath: \"CONTRIBUTING.md\",\n    zhPath: null,\n    titleEn: \"Contributing\",\n    titleZh: \"参与贡献\",\n  },\n  {\n    key: \"community-code-of-conduct\",\n    sectionId: \"community\",\n    slug: \"community/code-of-conduct\",\n    enPath: \"CODE_OF_CONDUCT.md\",\n    zhPath: null,\n    titleEn: \"Code of Conduct\",\n    titleZh: \"行为准则\",\n  },\n];\n\nconst moduleGroupLabels = {\n  en: {\n    sdks: \"SDKs\",\n    specs: \"Specs & API\",\n    server: \"Server\",\n    components: \"Components\",\n    sandboxes: \"Sandboxes\",\n    kubernetes: \"Kubernetes\",\n    design: \"Design\",\n  },\n  zh: {\n    sdks: \"SDKs\",\n    specs: \"Specs & API\",\n    server: \"Server\",\n    components: \"Components\",\n    sandboxes: \"Sandboxes\",\n    kubernetes: \"Kubernetes\",\n    design: \"设计\",\n  },\n};\n\nconst communityGroupLabels = {\n  en: {\n    community: \"Community\",\n    oseps: \"OSEPs\",\n  },\n  zh: {\n    community: \"社区\",\n    oseps: \"OSEPs\",\n  },\n};\n\nconst shortTitleByPath = {\n  \"sdks/code-interpreter/javascript/README.md\": \"Code Interpreter JS SDK\",\n  \"sdks/code-interpreter/kotlin/README.md\": \"Code Interpreter Kotlin SDK\",\n  \"sdks/code-interpreter/python/README.md\": \"Code Interpreter Python SDK\",\n  \"sdks/code-interpreter/csharp/README.md\": \"Code Interpreter C# SDK\",\n  \"sdks/sandbox/javascript/README.md\": \"Sandbox JS SDK\",\n  \"sdks/sandbox/kotlin/README.md\": \"Sandbox Kotlin SDK\",\n  \"sdks/sandbox/python/README.md\": \"Sandbox Python SDK\",\n  \"sdks/sandbox/csharp/README.md\": \"Sandbox C# SDK\",\n  \"sdks/mcp/sandbox/python/README.md\": \"MCP Sandbox Python SDK\",\n  \"cli/README.md\": \"CLI (Python)\",\n  \"sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md\": \"Sandbox Execd API (Kotlin)\",\n  \"sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md\": \"Sandbox Lifecycle API (Kotlin)\",\n\n  \"examples/agent-sandbox/README.md\": \"Agent Sandbox\",\n  \"examples/aio-sandbox/README.md\": \"AIO Sandbox\",\n  \"examples/chrome/README.md\": \"Chrome\",\n  \"examples/claude-code/README.md\": \"Claude Code\",\n  \"examples/code-interpreter/README.md\": \"Code Interpreter\",\n  \"examples/codex-cli/README.md\": \"Codex CLI\",\n  \"examples/desktop/README.md\": \"Desktop (VNC)\",\n  \"examples/gemini-cli/README.md\": \"Gemini CLI\",\n  \"examples/google-adk/README.md\": \"Google ADK\",\n  \"examples/host-volume-mount/README.md\": \"Host Volume Mount\",\n  \"examples/langgraph/README.md\": \"LangGraph\",\n  \"examples/playwright/README.md\": \"Playwright\",\n  \"examples/README.md\": \"Examples Overview\",\n  \"examples/rl-training/README.md\": \"RL Training\",\n  \"examples/vscode/README.md\": \"VS Code\",\n\n  \"server/README.md\": \"Server\",\n  \"server/DEVELOPMENT.md\": \"Server Development\",\n  \"components/ingress/README.md\": \"Ingress\",\n  \"components/ingress/DEVELOPMENT.md\": \"Ingress Development\",\n  \"components/egress/README.md\": \"Egress Sidecar\",\n  \"components/execd/README.md\": \"execd\",\n  \"components/execd/DEVELOPMENT.md\": \"execd Development\",\n  \"sandboxes/code-interpreter/README.md\": \"Code Interpreter Runtime\",\n  \"kubernetes/README.md\": \"Kubernetes Controller\",\n  \"kubernetes/examples/task-executor/README.md\": \"Task Executor\",\n  \"kubernetes/examples/controller/README.md\": \"Controller Example\",\n  \"oseps/README.md\": \"OSEP Overview\",\n};\n\nconst shortTitleByPathZh = {\n  \"sdks/code-interpreter/javascript/README.md\": \"代码解释器 JS SDK\",\n  \"sdks/code-interpreter/kotlin/README.md\": \"代码解释器 Kotlin SDK\",\n  \"sdks/code-interpreter/python/README.md\": \"代码解释器 Python SDK\",\n  \"sdks/code-interpreter/csharp/README.md\": \"代码解释器 C# SDK\",\n  \"sdks/sandbox/javascript/README.md\": \"沙箱 JS SDK\",\n  \"sdks/sandbox/kotlin/README.md\": \"沙箱 Kotlin SDK\",\n  \"sdks/sandbox/python/README.md\": \"沙箱 Python SDK\",\n  \"sdks/sandbox/csharp/README.md\": \"沙箱 C# SDK\",\n  \"sdks/mcp/sandbox/python/README.md\": \"MCP 沙箱 Python SDK\",\n  \"cli/README.md\": \"CLI（Python）\",\n  \"sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md\": \"沙箱 Execd API（Kotlin）\",\n  \"sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md\": \"沙箱生命周期 API（Kotlin）\",\n\n  \"examples/agent-sandbox/README.md\": \"Agent Sandbox\",\n  \"examples/aio-sandbox/README.md\": \"AIO 沙箱\",\n  \"examples/chrome/README.md\": \"Chrome\",\n  \"examples/claude-code/README.md\": \"Claude Code\",\n  \"examples/code-interpreter/README.md\": \"代码解释器\",\n  \"examples/codex-cli/README.md\": \"Codex CLI\",\n  \"examples/desktop/README.md\": \"桌面环境（VNC）\",\n  \"examples/gemini-cli/README.md\": \"Gemini CLI\",\n  \"examples/google-adk/README.md\": \"Google ADK\",\n  \"examples/host-volume-mount/README.md\": \"宿主机目录挂载\",\n  \"examples/langgraph/README.md\": \"LangGraph\",\n  \"examples/playwright/README.md\": \"Playwright\",\n  \"examples/README.md\": \"示例总览\",\n  \"examples/rl-training/README.md\": \"强化学习训练\",\n  \"examples/vscode/README.md\": \"VS Code\",\n\n  \"server/README.md\": \"Server\",\n  \"server/DEVELOPMENT.md\": \"Server 开发指南\",\n  \"components/ingress/README.md\": \"Ingress\",\n  \"components/ingress/DEVELOPMENT.md\": \"Ingress 开发指南\",\n  \"components/egress/README.md\": \"Egress Sidecar\",\n  \"components/execd/README.md\": \"execd\",\n  \"components/execd/DEVELOPMENT.md\": \"execd 开发指南\",\n  \"sandboxes/code-interpreter/README.md\": \"代码解释器运行时\",\n  \"kubernetes/README.md\": \"Kubernetes 控制器\",\n  \"kubernetes/examples/task-executor/README.md\": \"Task Executor\",\n  \"kubernetes/examples/controller/README.md\": \"Controller 示例\",\n  \"oseps/README.md\": \"OSEP 总览\",\n};\n\nfunction ensureDir(dirPath) {\n  fs.mkdirSync(dirPath, { recursive: true });\n}\n\nfunction rmIfExists(targetPath) {\n  if (fs.existsSync(targetPath)) {\n    fs.rmSync(targetPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 80 });\n  }\n}\n\nfunction walkMarkdownFiles(absDirPath, acc = []) {\n  const entries = fs.readdirSync(absDirPath, { withFileTypes: true });\n  for (const entry of entries) {\n    if (ignoredDirNames.has(entry.name)) {\n      continue;\n    }\n    const absPath = path.join(absDirPath, entry.name);\n    if (entry.isDirectory()) {\n      walkMarkdownFiles(absPath, acc);\n      continue;\n    }\n    if (!entry.isFile()) {\n      continue;\n    }\n    if (entry.name.endsWith(\".md\")) {\n      acc.push(absPath);\n    }\n  }\n  return acc;\n}\n\nfunction shouldIgnoreRepoPath(repoRelPath) {\n  const normalized = repoRelPath.replaceAll(\"\\\\\", \"/\");\n  const denylistFragments = [\n    \"/.venv/\",\n    \"/venv/\",\n    \"/node_modules/\",\n    \"/docs/.vitepress/\",\n    \"/docs/generated/\",\n    \"/.pytest_cache/\",\n    \"/__pycache__/\",\n    \"/dist/\",\n    \"/build/\",\n    \"/target/\",\n    \"/bin/\",\n  ];\n  return denylistFragments.some((fragment) => normalized.includes(fragment));\n}\n\nfunction toRepoRelative(absPath) {\n  return path.relative(repoRoot, absPath).replaceAll(path.sep, \"/\");\n}\n\nfunction readHeadingTitle(absPath, fallbackTitle) {\n  if (!fs.existsSync(absPath)) {\n    return fallbackTitle;\n  }\n  const content = fs.readFileSync(absPath, \"utf8\");\n  const lines = content.split(/\\r?\\n/);\n  let inFence = false;\n  for (const line of lines) {\n    const trimmed = line.trimStart();\n    if (trimmed.startsWith(\"```\")) {\n      inFence = !inFence;\n      continue;\n    }\n    if (inFence) {\n      continue;\n    }\n    const matched = trimmed.match(/^#{1,3}\\s+(.+)$/);\n    if (matched) {\n      return matched[1].trim();\n    }\n  }\n  return fallbackTitle;\n}\n\nfunction normalizeTitleWhitespace(title) {\n  return title.replace(/\\s+/g, \" \").trim();\n}\n\nfunction shortenOsepTitle(repoRelPath, title, locale = \"en\") {\n  const match = repoRelPath.match(/^oseps\\/(0\\d{3})-(.+)\\.md$/i);\n  if (!match) {\n    return title;\n  }\n  const number = match[1];\n  const slug = match[2].toLowerCase();\n  if (locale === \"zh\") {\n    if (slug.includes(\"fqdn\") && slug.includes(\"egress\")) {\n      return `OSEP-${number}: FQDN 出口访问控制`;\n    }\n    if (slug.includes(\"agent-sandbox\") || slug.includes(\"kubernetes-sigs\")) {\n      return `OSEP-${number}: Kubernetes Agent Sandbox 支持`;\n    }\n    if (slug.includes(\"volume\")) {\n      return `OSEP-${number}: Volume 与 VolumeBinding 支持`;\n    }\n  }\n  if (slug.includes(\"fqdn\") && slug.includes(\"egress\")) {\n    return `OSEP-${number}: FQDN Egress Control`;\n  }\n  if (slug.includes(\"agent-sandbox\") || slug.includes(\"kubernetes-sigs\")) {\n    return `OSEP-${number}: Agent Sandbox on Kubernetes`;\n  }\n  if (slug.includes(\"volume\")) {\n    return `OSEP-${number}: Volume & VolumeBinding Support`;\n  }\n  const readable = slug\n    .split(\"-\")\n    .map((part) => (part.length <= 3 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)))\n    .join(\" \");\n  return `OSEP-${number}: ${readable}`;\n}\n\nfunction shortenTitleByRule(title) {\n  let next = normalizeTitleWhitespace(title);\n  next = next.replace(/^Alibaba\\s+/i, \"\");\n  next = next.replace(/^OpenSandbox\\s+/i, \"\");\n  next = next.replace(/\\bJavaScript\\/TypeScript\\b/g, \"JS\");\n  next = next.replace(/\\bJava\\/Kotlin\\b/g, \"Kotlin\");\n  next = next.replace(/\\s+Example$/i, \"\");\n  next = next.replace(/\\s+SDK for /i, \" \");\n  return normalizeTitleWhitespace(next);\n}\n\nfunction shortenTitleByRuleZh(title) {\n  let next = normalizeTitleWhitespace(title);\n  next = next.replace(/^Alibaba\\s+/i, \"\");\n  next = next.replace(/^OpenSandbox\\s+/i, \"\");\n  next = next.replace(/\\bJavaScript\\/TypeScript\\b/g, \"JS\");\n  next = next.replace(/\\bJava\\/Kotlin\\b/g, \"Kotlin\");\n  next = next.replace(/\\s+Example$/i, \" 示例\");\n  next = next.replace(/\\s+SDK for /i, \" \");\n  return normalizeTitleWhitespace(next);\n}\n\nfunction getShortTitle(repoRelPath, currentTitle, locale = \"en\") {\n  if (locale === \"zh\" && shortTitleByPathZh[repoRelPath]) {\n    return shortTitleByPathZh[repoRelPath];\n  }\n  if (locale !== \"zh\" && shortTitleByPath[repoRelPath]) {\n    return shortTitleByPath[repoRelPath];\n  }\n  if (/^oseps\\/0\\d{3}-.+\\.md$/i.test(repoRelPath)) {\n    return shortenOsepTitle(repoRelPath, currentTitle, locale);\n  }\n  if (locale === \"zh\") {\n    return shortenTitleByRuleZh(currentTitle);\n  }\n  return shortenTitleByRule(currentTitle);\n}\n\nfunction toYamlString(value) {\n  return `\"${String(value).replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"')}\"`;\n}\n\nfunction normalizeSlugFromPath(relPath) {\n  const normalized = relPath.replaceAll(\"\\\\\", \"/\");\n  const dirName = path.posix.dirname(normalized);\n  const baseName = path.posix.basename(normalized);\n  const lowerBase = baseName.toLowerCase();\n\n  if (lowerBase === \"readme.md\" || zhReadmePattern.test(baseName)) {\n    return dirName === \".\" ? \"overview/home\" : `${dirName}/readme`;\n  }\n  if (lowerBase === \"development.md\") {\n    return `${dirName}/development`;\n  }\n  return normalized.replace(/\\.md$/i, \"\");\n}\n\nfunction resolveZhCandidate(repoRelPath, readmeCandidatesByDir) {\n  const dir = path.posix.dirname(repoRelPath);\n  const candidates = readmeCandidatesByDir.get(dir) ?? [];\n  for (const candidate of candidates) {\n    if (candidate.toLowerCase() !== \"readme.md\") {\n      return `${dir}/${candidate}`;\n    }\n  }\n  return null;\n}\n\nfunction buildGeneratedAssetPath(locale, routeSlug, resolvedRepoPath) {\n  const normalized = resolvedRepoPath.replaceAll(\"\\\\\", \"/\");\n  if (!normalized.startsWith(\"docs/assets/\")) {\n    return null;\n  }\n  const generatedDir = path.posix.dirname(`generated/${locale}/${routeSlug}.md`);\n  const assetPath = normalized.replace(/^docs\\//, \"\");\n  let relativePath = path.posix.relative(generatedDir, assetPath);\n  if (!relativePath || relativePath === \"\") {\n    relativePath = \"./\";\n  }\n  if (!relativePath.startsWith(\".\") && !relativePath.startsWith(\"/\")) {\n    relativePath = `./${relativePath}`;\n  }\n  return relativePath;\n}\n\nfunction normalizeLinkTarget(target, sourceDirRel, isImage, routeSlug, locale) {\n  if (\n    target.startsWith(\"http://\") ||\n    target.startsWith(\"https://\") ||\n    target.startsWith(\"mailto:\") ||\n    target.startsWith(\"#\") ||\n    target.startsWith(\"data:\") ||\n    target.startsWith(\"/\")\n  ) {\n    return target;\n  }\n\n  const [rawPath, hashFragment] = target.split(\"#\");\n  const resolvedPath = path.posix.normalize(path.posix.join(sourceDirRel, rawPath));\n  const localAssetPath = isImage ? buildGeneratedAssetPath(locale, routeSlug, resolvedPath) : null;\n  if (localAssetPath) {\n    if (hashFragment) {\n      return `${localAssetPath}#${hashFragment}`;\n    }\n    return localAssetPath;\n  }\n\n  const urlBase = isImage\n    ? `${rawBaseUrl}/${resolvedPath}`\n    : fs.existsSync(path.join(repoRoot, resolvedPath)) &&\n      fs.statSync(path.join(repoRoot, resolvedPath)).isDirectory()\n      ? `${treeBaseUrl}/${resolvedPath}`\n      : `${blobBaseUrl}/${resolvedPath}`;\n\n  if (hashFragment) {\n    return `${urlBase}#${hashFragment}`;\n  }\n  return urlBase;\n}\n\nfunction rewriteRelativeLinks(markdown, sourceRelPath, routeSlug, locale) {\n  const sourceDirRel = path.posix.dirname(sourceRelPath);\n\n  const withMarkdownLinks = markdown.replace(\n    /(!?)\\[([^\\]]*?)\\]\\(([^)]+)\\)/g,\n    (_match, imageMark, text, linkValue) => {\n      const trimmed = linkValue.trim();\n      if (!trimmed) {\n        return _match;\n      }\n      const firstSpace = trimmed.search(/\\s/);\n      const target = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);\n      const trailing = firstSpace === -1 ? \"\" : trimmed.slice(firstSpace);\n      const rewrittenTarget = normalizeLinkTarget(target, sourceDirRel, imageMark === \"!\", routeSlug, locale);\n      return `${imageMark}[${text}](${rewrittenTarget}${trailing})`;\n    },\n  );\n\n  return withMarkdownLinks.replace(\n    /<img([^>]*?)src=([\"'])([^\"']+)\\2([^>]*)>/gi,\n    (matched, before, quote, src, after) => {\n      const rewritten = normalizeLinkTarget(src, sourceDirRel, true, routeSlug, locale);\n      return `<img${before}src=${quote}${rewritten}${quote}${after}>`;\n    },\n  );\n}\n\nfunction renderPageSource({ locale, title, sourceRelPath, routeSlug, passthrough = false }) {\n  const sourceAbsPath = path.join(repoRoot, sourceRelPath);\n  const sourceMarkdown = fs.readFileSync(sourceAbsPath, \"utf8\");\n  const displayTitle = title || readHeadingTitle(sourceAbsPath, path.posix.basename(sourceRelPath, \".md\"));\n\n  let body = sourceMarkdown;\n  if (!passthrough) {\n    body = rewriteRelativeLinks(sourceMarkdown, sourceRelPath, routeSlug, locale);\n  }\n\n  const sourceUrl = `${blobBaseUrl}/${sourceRelPath}`;\n  const sourceNotice =\n    locale === \"zh\"\n      ? `> 此页内容来自仓库源文件：[\\`${sourceRelPath}\\`](${sourceUrl})`\n      : `> This page is sourced from: [\\`${sourceRelPath}\\`](${sourceUrl})`;\n\n\n  return `---\\ntitle: ${toYamlString(displayTitle)}\\n---\\n\\n${body}\\n\\n---\\n\\n${sourceNotice}\\n`;\n}\n\nfunction prettifyPathTitle(repoRelPath) {\n  const dirPath = path.posix.dirname(repoRelPath);\n  if (dirPath === \".\" || dirPath === \"docs\") {\n    return \"Overview\";\n  }\n  return dirPath\n    .split(\"/\")\n    .map((part) =>\n      part\n        .replaceAll(\"-\", \" \")\n        .replaceAll(\"_\", \" \")\n        .replace(/\\b\\w/g, (ch) => ch.toUpperCase()),\n    )\n    .join(\" / \");\n}\n\nfunction collectAutoEntries() {\n  const readmeCandidatesByDir = new Map();\n  const entries = [];\n\n  for (const section of sectionDefinitions) {\n    for (const scanRoot of section.scanRoots) {\n      const absScanRoot = path.join(repoRoot, scanRoot);\n      if (!fs.existsSync(absScanRoot)) {\n        continue;\n      }\n      const files = walkMarkdownFiles(absScanRoot);\n      for (const absPath of files) {\n        const repoRelPath = toRepoRelative(absPath);\n        if (shouldIgnoreRepoPath(repoRelPath)) {\n          continue;\n        }\n        const fileName = path.posix.basename(repoRelPath);\n        const dirName = path.posix.dirname(repoRelPath);\n\n        if (zhReadmePattern.test(fileName)) {\n          const arr = readmeCandidatesByDir.get(dirName) ?? [];\n          arr.push(fileName);\n          readmeCandidatesByDir.set(dirName, arr);\n        }\n      }\n    }\n  }\n\n  for (const section of sectionDefinitions) {\n    for (const scanRoot of section.scanRoots) {\n      const absScanRoot = path.join(repoRoot, scanRoot);\n      if (!fs.existsSync(absScanRoot)) {\n        continue;\n      }\n      const files = walkMarkdownFiles(absScanRoot);\n      for (const absPath of files) {\n        const repoRelPath = toRepoRelative(absPath);\n        if (shouldIgnoreRepoPath(repoRelPath)) {\n          continue;\n        }\n        const fileName = path.posix.basename(repoRelPath);\n        if (zhReadmePattern.test(fileName) && !standardReadmePattern.test(fileName)) {\n          continue;\n        }\n\n        const isReadme = standardReadmePattern.test(fileName);\n        const isDevelopment = fileName === \"DEVELOPMENT.md\";\n        const isOsepDoc = section.id === \"community\" && /^0\\d{3}-.+\\.md$/i.test(fileName);\n        if (!isReadme && !(section.includeDevelopment && isDevelopment) && !isOsepDoc) {\n          continue;\n        }\n\n        const zhCandidate = isReadme ? resolveZhCandidate(repoRelPath, readmeCandidatesByDir) : null;\n        const entryKey = `auto:${section.id}:${repoRelPath}`;\n        const slug = normalizeSlugFromPath(repoRelPath);\n        const titleFallback = isDevelopment ? `${prettifyPathTitle(repoRelPath)} Development` : prettifyPathTitle(repoRelPath);\n        entries.push({\n          key: entryKey,\n          sectionId: section.id,\n          slug,\n          enPath: repoRelPath,\n          zhPath: zhCandidate,\n          titleEn: getShortTitle(repoRelPath, readHeadingTitle(absPath, titleFallback), \"en\"),\n          titleZh: getShortTitle(\n            repoRelPath,\n            readHeadingTitle(\n            zhCandidate ? path.join(repoRoot, zhCandidate) : absPath,\n            readHeadingTitle(absPath, titleFallback),\n            ),\n            \"zh\",\n          ),\n        });\n      }\n    }\n  }\n\n  const unique = new Map();\n  for (const item of entries) {\n    if (!unique.has(item.key)) {\n      unique.set(item.key, item);\n    }\n  }\n  return [...unique.values()].sort((a, b) => a.slug.localeCompare(b.slug));\n}\n\nfunction buildEntries() {\n  const autoEntries = collectAutoEntries();\n  const all = [...manualEntries, ...autoEntries];\n  const uniqueBySlug = new Map();\n\n  for (const item of all) {\n    if (uniqueBySlug.has(item.slug)) {\n      continue;\n    }\n    uniqueBySlug.set(item.slug, item);\n  }\n  return [...uniqueBySlug.values()];\n}\n\nfunction toSidebarItems(entries, locale) {\n  return entries\n    .map((entry) => ({\n      text: locale === \"zh\" ? entry.titleZh || entry.titleEn : entry.titleEn,\n      link: locale === \"zh\" ? `/zh/${entry.slug}` : `/${entry.slug}`,\n    }))\n    .sort((a, b) => a.link.localeCompare(b.link));\n}\n\nfunction buildOverviewSidebar(entries, locale) {\n  const overviewEntries = entries.filter((entry) => entry.sectionId === \"overview\");\n  const slugOrder = [\"overview/home\", \"overview/architecture\"];\n  const items = overviewEntries\n    .sort((a, b) => {\n      const ai = slugOrder.indexOf(a.slug);\n      const bi = slugOrder.indexOf(b.slug);\n      if (ai === -1 && bi === -1) return a.slug.localeCompare(b.slug);\n      if (ai === -1) return 1;\n      if (bi === -1) return -1;\n      return ai - bi;\n    })\n    .map((entry) => ({\n      text: locale === \"zh\" ? entry.titleZh || entry.titleEn : entry.titleEn,\n      link: locale === \"zh\" ? `/zh/${entry.slug}` : `/${entry.slug}`,\n    }));\n  if (items.length === 0) {\n    return [];\n  }\n  return [{ text: locale === \"zh\" ? \"Overview\" : \"Overview\", items }];\n}\n\nfunction buildModulesSidebar(entries, locale) {\n  const modules = entries.filter((entry) => entry.sectionId === \"modules\");\n  const byPrefix = new Map();\n  for (const entry of modules) {\n    const prefix = entry.slug.split(\"/\")[0];\n    const arr = byPrefix.get(prefix) ?? [];\n    arr.push(entry);\n    byPrefix.set(prefix, arr);\n  }\n\n  const order = [\"sdks\", \"specs\", \"design\", \"server\", \"components\", \"sandboxes\", \"kubernetes\"];\n  const blocks = [];\n  for (const prefix of order) {\n    const groupEntries = byPrefix.get(prefix);\n    if (!groupEntries || groupEntries.length === 0) {\n      continue;\n    }\n    blocks.push({\n      text: moduleGroupLabels[locale][prefix],\n      items: toSidebarItems(groupEntries, locale),\n    });\n  }\n  return blocks;\n}\n\nfunction buildExamplesSidebar(entries, locale) {\n  const items = toSidebarItems(entries.filter((entry) => entry.sectionId === \"examples\"), locale);\n  if (items.length === 0) {\n    return [];\n  }\n  return [{ text: locale === \"zh\" ? \"示例\" : \"Examples\", items }];\n}\n\nfunction buildCommunitySidebar(entries, locale) {\n  const blocks = [];\n  const communityEntries = entries.filter(\n    (entry) => entry.sectionId === \"community\" && entry.slug.startsWith(\"community/\"),\n  );\n  if (communityEntries.length > 0) {\n    blocks.push({\n      text: communityGroupLabels[locale].community,\n      items: toSidebarItems(communityEntries, locale),\n    });\n  }\n\n  const osepReadmeEntries = entries.filter((entry) => entry.sectionId === \"community\" && entry.slug === \"oseps/readme\");\n  const osepDocEntries = entries.filter(\n    (entry) => entry.sectionId === \"community\" && entry.slug.startsWith(\"oseps/\") && entry.slug !== \"oseps/readme\",\n  );\n  const sortedOsepDocs = osepDocEntries.sort((a, b) => a.slug.localeCompare(b.slug));\n  const osepItems = [...toSidebarItems(osepReadmeEntries, locale), ...toSidebarItems(sortedOsepDocs, locale)];\n  if (osepItems.length > 0) {\n    blocks.push({\n      text: communityGroupLabels[locale].oseps,\n      items: osepItems,\n    });\n  }\n\n  return blocks;\n}\n\nfunction buildSidebarByPath(entries, locale) {\n  const prefix = locale === \"zh\" ? \"/zh\" : \"\";\n  const overviewSidebar = buildOverviewSidebar(entries, locale);\n  const modulesSidebar = buildModulesSidebar(entries, locale);\n  const examplesSidebar = buildExamplesSidebar(entries, locale);\n  const communitySidebar = buildCommunitySidebar(entries, locale);\n\n  const sidebar = {\n    [`${prefix}/`]: overviewSidebar,\n    [`${prefix}/overview/`]: overviewSidebar,\n    [`${prefix}/examples/`]: examplesSidebar,\n    [`${prefix}/community/`]: communitySidebar,\n    [`${prefix}/oseps/`]: communitySidebar,\n  };\n\n  for (const modulesPrefix of [\"server\", \"components\", \"sandboxes\", \"kubernetes\", \"specs\", \"sdks\", \"design\"]) {\n    sidebar[`${prefix}/${modulesPrefix}/`] = modulesSidebar;\n  }\n  return sidebar;\n}\n\nfunction writeGeneratedPages(entries) {\n  rmIfExists(generatedRoot);\n  ensureDir(path.join(generatedRoot, \"en\"));\n  ensureDir(path.join(generatedRoot, \"zh\"));\n\n  const rewrites = {};\n  const pages = [];\n\n  for (const entry of entries) {\n    const enSourcePath = entry.enPath;\n    const zhSourcePath = entry.zhPath || entry.enPath;\n    const enGeneratedRel = `generated/en/${entry.slug}.md`;\n    const zhGeneratedRel = `generated/zh/${entry.slug}.md`;\n    const enGeneratedAbs = path.join(docsRoot, enGeneratedRel);\n    const zhGeneratedAbs = path.join(docsRoot, zhGeneratedRel);\n    ensureDir(path.dirname(enGeneratedAbs));\n    ensureDir(path.dirname(zhGeneratedAbs));\n\n    fs.writeFileSync(\n      enGeneratedAbs,\n      renderPageSource({\n        locale: \"en\",\n        title: entry.titleEn,\n        sourceRelPath: enSourcePath,\n        routeSlug: entry.slug,\n        passthrough: entry.passthrough === true,\n      }),\n      \"utf8\",\n    );\n\n    fs.writeFileSync(\n      zhGeneratedAbs,\n      renderPageSource({\n        locale: \"zh\",\n        title: entry.titleZh || entry.titleEn,\n        sourceRelPath: zhSourcePath,\n        routeSlug: entry.slug,\n        passthrough: entry.passthrough === true,\n      }),\n      \"utf8\",\n    );\n\n    rewrites[enGeneratedRel] = `${entry.slug}.md`;\n    rewrites[zhGeneratedRel] = `zh/${entry.slug}.md`;\n\n    pages.push({\n      key: entry.key,\n      slug: entry.slug,\n      en: enSourcePath,\n      zh: zhSourcePath,\n    });\n  }\n\n  return { rewrites, pages };\n}\n\nexport function buildManifest() {\n  const entries = buildEntries();\n  const { rewrites, pages } = writeGeneratedPages(entries);\n  const manifest = {\n    generatedAt: new Date().toISOString(),\n    pages,\n    nav: {\n      en: [\n        { text: \"Overview\", link: \"/overview/home\" },\n        { text: \"Project\", link: \"/sdks/sandbox/python/readme\" },\n        { text: \"Examples\", link: \"/examples/readme\" },\n        { text: \"Community\", link: \"/community/contributing\" },\n      ],\n      zh: [\n        { text: \"Overview\", link: \"/zh/overview/home\" },\n        { text: \"Project\", link: \"/zh/sdks/sandbox/python/readme\" },\n        { text: \"Examples\", link: \"/zh/examples/readme\" },\n        { text: \"Community\", link: \"/zh/community/contributing\" },\n      ],\n    },\n    sidebar: {\n      en: buildSidebarByPath(entries, \"en\"),\n      zh: buildSidebarByPath(entries, \"zh\"),\n    },\n    rewrites,\n  };\n\n  ensureDir(path.dirname(manifestPath));\n  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\\n`, \"utf8\");\n  return manifest;\n}\n\nexport function loadManifest() {\n  try {\n    if (!fs.existsSync(manifestPath)) {\n      return buildManifest();\n    }\n    const data = JSON.parse(fs.readFileSync(manifestPath, \"utf8\"));\n    if (!data || !data.generatedAt || !data.nav || !data.sidebar || !data.rewrites) {\n      return buildManifest();\n    }\n    return buildManifest();\n  } catch (_error) {\n    return buildManifest();\n  }\n}\n\nif (process.argv[1] === fileURLToPath(import.meta.url)) {\n  const manifest = buildManifest();\n  // Keep logging terse for CI output.\n  console.log(`docs manifest generated (${manifest.pages.length} pages)`);\n}\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import DefaultTheme from \"vitepress/theme\";\nimport \"./styles.css\";\n\nexport default DefaultTheme;\n"
  },
  {
    "path": "docs/.vitepress/theme/styles.css",
    "content": ":root {\n  --vp-c-brand-1: #2563eb;\n  --vp-c-brand-2: #1d4ed8;\n  --vp-c-brand-3: #1e40af;\n}\n\n.VPFeature {\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 14px;\n}\n\n.vp-doc blockquote {\n  border-left: 3px solid var(--vp-c-brand-1);\n}\n\n/* Keep README badge rows inline in VitePress docs pages */\n.vp-doc p[align=\"center\"] {\n  text-align: center;\n}\n\n.vp-doc p[align=\"center\"] a {\n  display: inline-flex;\n  align-items: center;\n  text-decoration: none;\n  margin: 2px 4px;\n}\n\n.vp-doc p[align=\"center\"] a img {\n  display: inline-block;\n  margin: 0;\n  vertical-align: middle;\n}\n\n.scenario-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n  gap: 14px;\n  margin: 16px 0 6px;\n}\n\n.vp-doc .scenario-card {\n  display: block;\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 12px;\n  padding: 14px;\n  text-decoration: none;\n  color: inherit;\n  background: var(--vp-c-bg-soft);\n  transition: border-color 0.2s ease, transform 0.2s ease;\n}\n\n.vp-doc .scenario-card:hover {\n  border-color: var(--vp-c-brand-1);\n  transform: translateY(-1px);\n  text-decoration: none;\n}\n\n.vp-doc .scenario-card h3 {\n  margin: 0 0 8px;\n  font-size: 16px;\n  text-decoration: none;\n}\n\n.vp-doc .scenario-card p {\n  margin: 0;\n  font-size: 14px;\n  line-height: 1.5;\n  color: var(--vp-c-text-2);\n  text-decoration: none;\n}\n"
  },
  {
    "path": "docs/README.md",
    "content": "# OpenSandbox Docs Site\n\nThis directory hosts the VitePress site for OpenSandbox.\n\n## Local development\n\n```bash\nnvm use 22\ncd docs\npnpm install\npnpm docs:dev\n```\n\n## Build\n\n```bash\nnvm use 22\ncd docs\npnpm install\npnpm docs:build\n```\n\n## Notes\n\n- Site content is generated from repository README and docs markdown files.\n- Run `pnpm docs:sync` to regenerate the manifest and routed pages.\n- Run `pnpm docs:spec` to regenerate `docs/public/api/spec-inline.js` from `specs/sandbox-lifecycle.yml`.\n"
  },
  {
    "path": "docs/README_zh.md",
    "content": "<div align=\"center\">\n  <img src=\"assets/logo.svg\" alt=\"OpenSandbox logo\" width=\"150\" />\n\n  <h1>OpenSandbox</h1>\n\n<p align=\"center\">\n  <a href=\"https://github.com/alibaba/OpenSandbox\">\n    <img src=\"https://img.shields.io/github/stars/alibaba/OpenSandbox.svg?style=social\" alt=\"GitHub stars\" />\n  </a>\n  <a href=\"https://deepwiki.com/alibaba/OpenSandbox\">\n    <img src=\"https://deepwiki.com/badge.svg\" alt=\"Ask DeepWiki\" />\n  </a>\n  <a href=\"https://www.apache.org/licenses/LICENSE-2.0.html\">\n    <img src=\"https://img.shields.io/badge/license-Apache%202.0-blue.svg\" alt=\"license\" />\n  </a>\n  <a href=\"https://badge.fury.io/py/opensandbox\">\n    <img src=\"https://badge.fury.io/py/opensandbox.svg\" alt=\"PyPI version\" />\n  </a>\n  <a href=\"https://badge.fury.io/js/@alibaba-group%2Fopensandbox\">\n    <img src=\"https://badge.fury.io/js/@alibaba-group%2Fopensandbox.svg\" alt=\"npm version\" />\n  </a>\n  <a href=\"https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox\">\n    <img src=\"https://img.shields.io/badge/CNCF-Landscape-0C66E4\" alt=\"CNCF Landscape\" />\n  </a>\n  <a href=\"https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11\">\n    <img src=\"https://img.shields.io/badge/DingTalk-Join-0089FF?logo=dingtalk&logoColor=white\" alt=\"DingTalk\" />\n  </a>\n  <a href=\"https://github.com/alibaba/OpenSandbox/actions\">\n    <img src=\"https://github.com/alibaba/OpenSandbox/actions/workflows/real-e2e.yml/badge.svg?branch=main\" alt=\"E2E Status\" />\n  </a>\n</p>\n\n  <hr />\n</div>\n\n中文 | [English](../README.md)\n\nOpenSandbox 是一个面向 AI 应用场景设计的「通用沙箱平台」，为LLM相关的能力（命令执行、文件操作、代码执行、浏览器操作、Agent 运行等）提供 **多语言 SDK、沙箱接口协议和沙箱运行时**。\n\nOpenSandbox 已进入 [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox)。\n\n## 核心特性\n\n- **多语言 SDK**：提供 Python、Java/Kotlin、JavaScript/TypeScript、C#/.NET 等语言的客户端 SDK，Go SDK 仍在规划中。\n- **沙箱协议**：定义了沙箱生命周期管理 API 和沙箱执行 API。你可以通过这些沙箱协议扩展自己的沙箱运行时。\n- **沙箱运行时**：沙箱全生命周期管理，支持 Docker 和[自研高性能 Kubernetes 运行时](../kubernetes)，实现本地运行、企业级大规模分布式沙箱调度。\n- **沙箱环境**：内置 Command、Filesystem、Code Interpreter 实现。并提供 Coding Agent（Claude Code 等）、浏览器自动化（Chrome、Playwright）和桌面环境（VNC、VS Code）等示例。\n- **网络策略**：提供统一的 [Ingress Gateway](../components/ingress) 实现，并支持多种路由策略；提供单实例级别的沙箱[出口网络限制](../components/egress)。\n- **强隔离安全**：支持 gVisor、Kata Containers 和 Firecracker 微虚拟机等安全容器运行时，为沙箱工作负载与宿主机之间提供增强的安全隔离。详见 [安全容器运行时指南](secure-container.md)。\n\n## 使用示例\n\n### 沙箱基础操作\n\n环境要求：\n\n- Docker（本地运行必需）\n- Python 3.10+（本地 runtime 和快速开始）\n\n#### 1. 安装并配置 Server\n\n```bash\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker-zh\n```\n\n> 如果需要开发或使用源码编译，可通过clone仓库进行开发。\n> \n> ```bash\n> git clone https://github.com/alibaba/OpenSandbox.git\n> cd OpenSandbox/server\n> uv sync\n> cp example.config.toml ~/.sandbox.toml # Copy configuration file\n> uv run python -m src.main # Start the service\n> ```\n\n#### 2. 启动沙箱 Server\n\n```bash\nopensandbox-server\n\n# Show help\nopensandbox-server -h\n```\n\n#### 3. 创建代码解释器，并在沙箱中执行命令\n\n安装 Code Interpreter SDK\n\n```bash\nuv pip install opensandbox-code-interpreter\n```\n\n创建沙箱并执行命令\n\n```python\nimport asyncio\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.models import WriteEntry\n\nasync def main() -> None:\n    # 1. Create a sandbox\n    sandbox = await Sandbox.create(\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n        entrypoint= [\"/opt/opensandbox/code-interpreter.sh\"],\n        env={\"PYTHON_VERSION\": \"3.11\"},\n        timeout=timedelta(minutes=10),\n    )\n\n    async with sandbox:\n\n        # 2. Execute a shell command\n        execution = await sandbox.commands.run(\"echo 'Hello OpenSandbox!'\")\n        print(execution.logs.stdout[0].text)\n\n        # 3. Write a file\n        await sandbox.files.write_files([\n            WriteEntry(path=\"/tmp/hello.txt\", data=\"Hello World\", mode=644)\n        ])\n\n        # 4. Read a file\n        content = await sandbox.files.read_file(\"/tmp/hello.txt\")\n        print(f\"Content: {content}\") # Content: Hello World\n\n        # 5. Create a code interpreter\n        interpreter = await CodeInterpreter.create(sandbox)\n\n        # 6. 执行 Python 代码（单次执行：直接传 language）\n        result = await interpreter.codes.run(\n              \"\"\"\n                  import sys\n                  print(sys.version)\n                  result = 2 + 2\n                  result\n              \"\"\",\n              language=SupportedLanguage.PYTHON,\n        )\n\n        print(result.result[0].text) # 4\n        print(result.logs.stdout[0].text) # 3.11.14\n\n    # 7. Cleanup the sandbox\n    await sandbox.kill()\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### 更多示例\n\nOpenSandbox 提供了丰富的示例来演示不同场景下的沙箱使用方式。所有示例代码位于 `examples/` 目录下。\n\n#### 🎯 基础示例\n\n- **[code-interpreter](../examples/code-interpreter/README.md)** - Code Interpreter SDK 的端到端沙箱流程示例。\n- **[aio-sandbox](../examples/aio-sandbox/README.md)** - 使用 OpenSandbox SDK 与 agent-sandbox 的一体化沙箱示例。\n- **[agent-sandbox](../examples/agent-sandbox/README.md)** - 通过 kubernetes-sigs/agent-sandbox 在 Kubernetes 上运行 OpenSandbox。\n\n#### 🤖 Coding Agent 集成\n\n在 OpenSandbox 中，集成各类 Coding Agent，包括 Claude Code、Google Gemini、OpenAI Codex、Kimi CLI 等。\n\n- **[claude-code](../examples/claude-code/README.md)** - 在 OpenSandbox 中运行 Claude Code。\n- **[gemini-cli](../examples/gemini-cli/README.md)** - 在 OpenSandbox 中运行 Google Gemini CLI。\n- **[codex-cli](../examples/codex-cli/README.md)** - 在 OpenSandbox 中运行 OpenAI Codex CLI。\n- **[kimi-cli](../examples/kimi-cli/README.md)** - 在 OpenSandbox 中运行 [Kimi CLI](https://github.com/MoonshotAI/kimi-cli)（Moonshot AI）。\n- **[langgraph](../examples/langgraph/README.md)** - 基于 LangGraph 状态机编排沙箱任务与回退重试。\n- **[google-adk](../examples/google-adk/README.md)** - 使用 Google ADK 通过 OpenSandbox 工具读写文件并执行命令。\n- **[nullclaw](../examples/nullclaw/README.md)** - 在沙箱中启动 Nullclaw Gateway。\n- **[openclaw](../examples/openclaw/README_zh.md)** - 在沙箱中启动 OpenClaw Gateway。\n\n#### 🌐 浏览器与桌面环境\n\n- **[chrome](../examples/chrome/README.md)** - 带 VNC 与 DevTools 的无头 Chromium，用于自动化/调试。\n- **[playwright](../examples/playwright/README.md)** - Playwright + Chromium 无头抓取与测试示例。\n- **[desktop](../examples/desktop/README.md)** - 通过 VNC 访问的完整桌面环境沙箱。\n- **[vscode](../examples/vscode/README.md)** - 在沙箱中运行 code-server（VS Code Web）进行远程开发。\n\n#### 🧠 机器学习与训练\n\n- **[rl-training](../examples/rl-training/README.md)** - 在沙箱中运行 DQN CartPole 训练，输出 checkpoint 与训练汇总。\n\n更多详细信息请参考 [examples](../examples/README.md) 和各示例目录下的 README 文件。\n\n## 项目结构\n\n| 目录 | 说明                                                |\n|------|---------------------------------------------------|\n| [`sdks/`](../sdks/) | 多语言 SDK（Python、Java/Kotlin、TypeScript/JavaScript、C#/.NET）      |\n| [`specs/`](../specs/) | OpenAPI 与生命周期规范                                   |\n| [`server/`](../server/README_zh.md) | Python FastAPI 沙箱生命周期服务，并集成多种运行时实现                |\n| [`kubernetes/`](../kubernetes/README-ZH.md) | Kubernetes 部署与示例                                  |\n| [`components/execd/`](../components/execd/README_zh.md) | 沙箱执行守护进程，负责命令和文件操作                                |\n| [`components/ingress/`](../components/ingress/README.md) | 沙箱流量入口代理                                          |\n| [`components/egress/`](../components/egress/README.md) | 沙箱网络 Egress 访问控制                                  |\n| [`sandboxes/`](../sandboxes/) | 沙箱运行时实现与镜像（如 code-interpreter）                    |\n| [`examples/`](../examples/README.md) | 集成示例和使用案例                                         |\n| [`oseps/`](../oseps/README.md) | OpenSandbox Enhancement Proposals                 |\n| [`docs/`](../docs/) | 架构和设计文档                                           |\n| [`tests/`](../tests/) | 跨组件端到端测试                                          |\n| [`scripts/`](../scripts/) | 开发和维护脚本                                           |\n\n详细架构请参阅 [docs/architecture.md](architecture.md)。\n\n## 文档\n\n- [docs/architecture.md](architecture.md) – 整体架构 & 设计理念\n- [oseps/README.md](../oseps/README.md) – OpenSandbox 增强提案 (OSEPs)\n- SDK\n  - Sandbox 基础 SDK（[Java\\Kotlin SDK](../sdks/sandbox/kotlin/README_zh.md)、[Python SDK](../sdks/sandbox/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/sandbox/javascript/README_zh.md)、[C#/.NET SDK](../sdks/sandbox/csharp/README_zh.md)）- 包含沙箱生命周期、命令执行、文件操作\n  - Code Interpreter SDK（[Java\\Kotlin SDK](../sdks/code-interpreter/kotlin/README_zh.md) 、[Python SDK](../sdks/code-interpreter/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/code-interpreter/javascript/README_zh.md)、[C#/.NET SDK](../sdks/code-interpreter/csharp/README_zh.md)）- 代码解释器\n- [specs/README.md](../specs/README_zh.md) - 包含沙箱生命周期 API 和沙箱执行 API 的 OpenAPI 定义\n- [server/README.md](../server/README_zh.md) - 包含沙箱 Server 的启动和配置，支持 Docker 与 Kubernetes Runtime\n\n## 许可证\n\n本项目采用 [Apache 2.0 License](../LICENSE) 开源。\n\n你可以在遵守许可条款的前提下，将 OpenSandbox 用于个人或商业项目。\n\n## 路线图 [2026.03]\n\n### SDK\n\n- **沙箱客户端连接池** - 客户端沙箱连接池管理，提供预配置的沙箱实例，以毫秒级速度获取沙箱环境。\n- **Go SDK** - Go 客户端 SDK，用于沙箱生命周期管理、命令执行和文件操作。\n\n### Sandbox Runtime\n\n- **持久化存储** - 沙箱的持久化存储挂载（参见 [Proposal 0003](../oseps/0003-volume-and-volumebinding-support.md)）。\n- **本地轻量级沙箱** - 为运行在 PC 上的 AI 工具提供轻量级沙箱。\n- **安全容器** - 为在容器内运行的 AI Agent 提供安全沙箱。\n\n### Deployment\n\n- **部署指南** - 自托管 Kubernetes 集群的部署指南。\n\n## 联系与讨论\n\n- Issue：通过 GitHub Issues 提交 bug、功能请求或设计讨论\n- 钉钉群：加入 [OpenSandbox 技术交流群](https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11)\n\n欢迎一起把 OpenSandbox 打造成 AI 场景下的通用沙箱基础设施。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#alibaba/OpenSandbox&type=date&legend=top-left)\n"
  },
  {
    "path": "docs/RELEASE_NOTE_TEMPLATE.md",
    "content": "# [component' name] [version]\n\n## What's New\n\nSome Docs if needed \n\n### ✨ Features\n- Feature-1 (#123)\n- Feature-2 (#456)\n\n### 🐛 Bug Fixes\n- Bug-2 (#456)\n\n### ⚠️ Breaking Changes\n- xxx (#789)\n\n### 📦 Misc\n- workflow update (#789)\n- deps update (#789)\n- tests update (#789)\n\n## 👥 Contributors\n\nThanks to these contributors ❤️\n\n- @alice\n- @bob\n"
  },
  {
    "path": "docs/architecture.md",
    "content": "# OpenSandbox Architecture\n\nOpenSandbox is a universal sandbox platform designed for AI application scenarios, providing a complete solution with multi-language SDKs, standardized sandbox protocols, and flexible runtime implementations. This document describes the overall architecture and design philosophy of OpenSandbox.\n\n## Architecture Overview\n\n![OpenSandbox Architecture](assets/architecture.svg)\n\nThe OpenSandbox architecture consists of four main layers:\n\n1. **SDKs Layer** - Client libraries for interacting with sandboxes\n2. **Specs Layer** - OpenAPI specifications defining the protocols\n3. **Runtime Layer** - Server implementations managing sandbox lifecycle\n4. **Sandbox Instances Layer** - Running sandbox containers with injected execution daemons\n\n## 1. OpenSandbox SDKs\n\nThe SDK layer provides high-level abstractions for developers to interact with sandboxes. It handles communication with both the Sandbox Lifecycle API and the Sandbox Execution API.\n\n### Core SDK Components\n\n#### 1.1 Sandbox\n\nThe `Sandbox` class is the primary entry point for managing sandbox lifecycle:\n\n- **Create**: Provision new sandbox instances from container images\n- **Manage**: Monitor sandbox state, renew expiration, retrieve endpoints\n- **Destroy**: Terminate sandbox instances when no longer needed\n\n**Key Features:**\n- Async/await support for non-blocking operations\n- Automatic state polling for provisioning progress\n- Resource quota management (CPU, memory, GPU)\n- Metadata and environment variable injection\n- TTL-based automatic expiration with renewal\n\n#### 1.2 Filesystem\n\nThe `Filesystem` component provides comprehensive file operations within sandboxes:\n\n- **CRUD Operations**: Create, read, update, and delete files and directories\n- **Bulk Operations**: Upload/download multiple files efficiently\n- **Search**: Glob-based file searching with pattern matching\n- **Permissions**: Manage file ownership, group, and mode (chmod)\n- **Metadata**: Retrieve file info including size, timestamps, permissions\n\n**Use Cases:**\n- Uploading code files and dependencies\n- Downloading execution results and artifacts\n- Managing workspace directories\n- Searching for files by pattern\n\n#### 1.3 Commands\n\nThe `Commands` component enables shell command execution within sandboxes:\n\n- **Foreground Execution**: Run commands synchronously with real-time output streaming\n- **Background Execution**: Launch long-running processes in detached mode\n- **Stream Support**: Capture stdout/stderr via Server-Sent Events (SSE)\n- **Process Control**: Interrupt running commands via context cancellation\n- **Working Directory**: Specify custom working directory for command execution\n\n**Use Cases:**\n- Running build commands (e.g., `npm install`, `pip install`)\n- Executing system utilities (e.g., `git`, `docker`)\n- Starting web servers or services\n- Running test suites\n\n#### 1.4 CodeInterpreter\n\nThe `CodeInterpreter` component provides stateful code execution across multiple programming languages:\n\n- **Multi-Language Support**: Python, Java, JavaScript, TypeScript, Go, Bash\n- **Session Management**: Maintain execution state across multiple code blocks\n- **Jupyter Integration**: Built on Jupyter kernel protocol for robust execution\n- **Result Streaming**: Real-time output via SSE with execution counts\n- **Error Handling**: Structured error responses with tracebacks\n\n**Key Features:**\n- Variable persistence across executions within same session\n- Display data in multiple MIME types (text, HTML, images)\n- Execution interruption support\n- Execution timing and performance metrics\n\n**Use Cases:**\n- Interactive coding environments (e.g., Jupyter notebooks)\n- AI code generation and execution\n- Data analysis and visualization\n- Educational coding platforms\n\n### SDK Language Support\n\nOpenSandbox provides SDKs in multiple languages:\n\n- **Python SDK** (`sdks/sandbox/python`, `sdks/code-interpreter/python`)\n- **Java/Kotlin SDK** (`sdks/sandbox/kotlin`, `sdks/code-interpreter/kotlin`)\n- **TypeScript SDK** (Roadmap)\n\nAll SDKs follow the same design patterns and provide consistent APIs across languages.\n\n## 2. OpenSandbox Specs\n\nThe Specs layer defines two core OpenAPI specifications that establish the contract between SDKs and runtime implementations.\n\n### 2.1 Sandbox Lifecycle Spec\n\n**File**: `specs/sandbox-lifecycle.yml`\n\nThe Lifecycle Spec defines the API for managing sandbox instances throughout their lifecycle.\n\n#### Core Operations\n\n| Operation | Endpoint | Description |\n|-----------|----------|-------------|\n| **Create** | `POST /sandboxes` | Create a new sandbox from a container image |\n| **List** | `GET /sandboxes` | List sandboxes with filtering and pagination |\n| **Get** | `GET /sandboxes/{id}` | Retrieve sandbox details and status |\n| **Delete** | `DELETE /sandboxes/{id}` | Terminate a sandbox |\n| **Pause** | `POST /sandboxes/{id}/pause` | Pause a running sandbox |\n| **Resume** | `POST /sandboxes/{id}/resume` | Resume a paused sandbox |\n| **Renew** | `POST /sandboxes/{id}/renew-expiration` | Extend sandbox TTL |\n| **Endpoint** | `GET /sandboxes/{id}/endpoints/{port}` | Get public URL for a port |\n\n### 2.2 Sandbox Execution Spec\n\n**File**: `specs/execd-api.yaml`\n\nThe Execution Spec defines the API for interacting with running sandbox instances. This API is implemented by the `execd` daemon injected into each sandbox.\n\n#### API Categories\n\n**Health**\n- `GET /ping` - Health check\n\n**Code Interpreting**\n- `POST /code/context` - Create execution context\n- `POST /code` - Execute code with streaming output\n- `DELETE /code` - Interrupt code execution\n\n**Command Execution**\n- `POST /command` - Execute shell command\n- `DELETE /command` - Interrupt command\n\n**Filesystem**\n- `GET /files/info` - Get file metadata\n- `DELETE /files` - Remove files\n- `POST /files/permissions` - Change permissions\n- `POST /files/mv` - Rename/move files\n- `GET /files/search` - Search files by glob pattern\n- `POST /files/replace` - Replace file content\n- `POST /files/upload` - Upload files\n- `GET /files/download` - Download files\n- `POST /directories` - Create directories\n- `DELETE /directories` - Remove directories\n\n**Metrics**\n- `GET /metrics` - Get system metrics snapshot\n- `GET /metrics/watch` - Stream metrics via SSE\n\n## 3. OpenSandbox Runtime\n\nThe Runtime layer implements the Sandbox Lifecycle Spec and manages the orchestration of sandbox containers.\n\n### 3.1 Server Architecture\n\n**Location**: `server/`\n\nThe OpenSandbox server is a FastAPI-based service providing:\n\n- **Lifecycle Management**: Create, monitor, pause, resume, and terminate sandboxes\n- **Pluggable Runtimes**: Docker (production-ready), Kubernetes (production-ready)\n- **Async Provisioning**: Background creation to reduce latency\n- **Automatic Expiration**: Configurable TTL with renewal support\n- **Access Control**: API key authentication\n- **Observability**: Unified status tracking with transition logging\n\n### 3.2 Runtime Implementations\n\n#### Docker Runtime (Ready)\n\n**Features:**\n- Direct Docker API integration\n- Two networking modes:\n  - **Host Mode**: Containers share host network (single instance)\n  - **Bridge Mode**: Isolated networking with HTTP routing\n- Container lifecycle management\n- Resource quota enforcement\n- Private registry authentication\n- Volume mounting for execd injection\n- Automatic cleanup on expiration\n\n**Key Responsibilities:**\n1. Pull container images (with auth support)\n2. Create containers with resource limits\n3. Inject execd binary and start script\n4. Monitor container state\n5. Handle pause/resume operations\n6. Clean up terminated containers\n\n#### Kubernetes Runtime (Ready)\n\n**Features:**\n- Built-in **[BatchSandbox](https://github.com/alibaba/OpenSandbox/tree/main/kubernetes)** runtime with sandbox pooling, high-throughput batch creation, and heterogeneous task orchestration; also compatible with **[SIG agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)** as an alternative runtime\n- Support for different secure container runtimes (e.g., kata-containers, gVisor)\n- Helm-based deployment for controller and server, see [documentation](https://github.com/alibaba/OpenSandbox/blob/main/kubernetes/charts/opensandbox/README.md)\n\n**Planned Features:**\n- Unified network storage mounting (ossfs, NAS, custom PVC) in both pooled and non-pooled modes\n- Pause/resume support\n\n#### Custom Runtime\n\nThe pluggable architecture allows implementing custom runtimes by:\n1. Implementing the Lifecycle Spec APIs\n2. Managing sandbox provisioning and cleanup\n3. Injecting execd into sandbox instances\n4. Reporting sandbox state transitions\n\n### 3.3 Networking and Routing\n\n#### Sandbox Router\n\n**Purpose**: Provides HTTP/HTTPS load balancing to sandbox instance ports.\n\n**Features:**\n- Dynamic endpoint generation based on sandbox ID and port\n- Supports both domain-based and wildcard routing\n- Reverse proxy to sandbox container ports\n- Automatic cleanup when sandbox terminates\n\n**Endpoint Format**: `{domain}/sandboxes/{sandboxId}/port/{port}`\n\n**Use Cases:**\n- Accessing web applications running in sandboxes\n- Connecting to development servers (e.g., VS Code Server)\n- Exposing APIs and services\n- VNC and remote desktop access\n\n## 4. Sandbox Instances\n\nSandbox instances are running containers that host user workloads with an injected execution daemon.\n\n### 4.1 Container Structure\n\nEach sandbox instance consists of:\n\n1. **Base Container**: User-specified image (e.g., `ubuntu:22.04`, `python:3.11`)\n2. **execd Daemon**: Injected execution agent implementing the Execution Spec\n3. **Entrypoint Process**: User-defined main process\n\n### 4.2 execd - Execution Daemon\n\n**Location**: `components/execd/`\n\nexecd is a Go-based HTTP daemon built on the Beego framework.\n\n#### Core Responsibilities\n\n1. **Code Execution**: Manage Jupyter kernel sessions for multi-language code execution\n2. **Command Execution**: Run shell commands with output streaming\n3. **File Operations**: Provide filesystem API for remote file management\n4. **Metrics Collection**: Monitor and report CPU, memory usage\n\n#### Architecture\n\n**Technology Stack:**\n- **Language**: Go 1.24+\n- **Web Framework**: Beego\n- **Jupyter Integration**: WebSocket-based Jupyter protocol client\n- **Streaming**: Server-Sent Events (SSE)\n\n**Package Structure:**\n- `pkg/flag/` - Configuration and CLI flags\n- `pkg/web/` - HTTP layer (controllers, models, router)\n- `pkg/runtime/` - Execution dispatcher\n- `pkg/jupyter/` - Jupyter kernel client\n- `pkg/util/` - Utilities and helpers\n\n#### Jupyter Integration\n\nexecd integrates with Jupyter Server running inside the container:\n\n1. **Session Management**: Create and maintain kernel sessions\n2. **WebSocket Communication**: Real-time bidirectional communication\n3. **Message Protocol**: Jupyter message spec implementation\n4. **Stream Parsing**: Parse execution results, outputs, errors\n\n**Supported Kernels:**\n- Python (IPython)\n- Java (IJava)\n- JavaScript (IJavaScript)\n- TypeScript (ITypeScript)\n- Go (gophernotes)\n- Bash\n\n### 4.3 Injection Mechanism\n\nThe execd daemon is injected into sandbox containers during creation:\n\n**Docker Runtime Injection Process:**\n\n1. **Pull execd Image**: Retrieve the execd container image\n2. **Extract Binary**: Copy execd binary from image to temporary location\n3. **Volume Mount**: Mount execd binary and startup script into target container\n4. **Entrypoint Override**: Modify container entrypoint to start execd first\n5. **User Process Launch**: execd forks and executes the user's entrypoint\n\n**Startup Sequence:**\n\n```bash\n# Container starts with modified entrypoint\n/opt/opensandbox/start.sh\n  ↓\n# Start Jupyter Server\njupyter notebook --port=54321 --no-browser --ip=0.0.0.0\n  ↓\n# Start execd daemon\n/opt/opensandbox/execd --jupyter-host=http://127.0.0.1:54321 --port=44772\n  ↓\n# Execute user entrypoint\nexec \"${USER_ENTRYPOINT[@]}\"\n```\n\n**Benefits:**\n- Transparent to user code\n- No image modification required\n- Dynamic injection at runtime\n- Works with any base image\n\n## 5. Communication Flow\n\n### 5.1 Sandbox Creation Flow\n\n```\nUser/SDK\n   │\n   │ 1. POST /sandboxes (image, entrypoint, resources)\n   ▼\nServer (Lifecycle API)\n   │\n   │ 2. Pull container image\n   │ 3. Inject execd binary\n   │ 4. Create container with entrypoint override\n   │ 5. Start container\n   ▼\nSandbox Instance\n   │\n   │ 6. Start execd daemon\n   │ 7. Start Jupyter Server\n   │ 8. Execute user entrypoint\n   ▼\nRunning (State)\n```\n\n### 5.2 Code Execution Flow\n\n```\nUser/SDK\n   │\n   │ 1. Create sandbox\n   │ 2. Get execd endpoint\n   ▼\nCodeInterpreter SDK\n   │\n   │ 3. POST /code/context (create session)\n   │ 4. POST /code (execute code)\n   ▼\nexecd (Execution API)\n   │\n   │ 5. Route to Jupyter runtime\n   ▼\nJupyter Runtime\n   │\n   │ 6. WebSocket to Jupyter Server\n   │ 7. Send execute_request\n   ▼\nJupyter Kernel (Python/Java/etc.)\n   │\n   │ 8. Execute code\n   │ 9. Stream output events\n   ▼\nexecd\n   │\n   │ 10. Convert to SSE events\n   │ 11. Stream to client\n   ▼\nCodeInterpreter SDK\n   │\n   │ 12. Parse events\n   │ 13. Return result to user\n   ▼\nUser/Application\n```\n\n### 5.3 File Operations Flow\n\n```\nUser/SDK\n   │\n   │ 1. Upload files\n   ▼\nFilesystem SDK\n   │\n   │ 2. POST /files/upload (multipart)\n   ▼\nexecd (Execution API)\n   │\n   │ 3. Write to filesystem\n   │ 4. Set permissions\n   ▼\nSandbox Container Filesystem\n```\n\n## 6. Design Principles\n\n### 6.1 Protocol-First Design\n\n- All interactions defined by OpenAPI specifications\n- Clear contracts between components\n- Enables polyglot implementations\n- Supports custom runtime implementations\n\n### 6.2 Separation of Concerns\n\n- **SDK**: Client-side abstraction and convenience\n- **Specs**: Protocol definition and documentation\n- **Runtime**: Sandbox orchestration and lifecycle\n- **execd**: In-sandbox execution and operations\n\n### 6.3 Extensibility\n\n- Pluggable runtime implementations\n- Custom sandbox images\n- Multiple SDK languages\n- Additional Jupyter kernels\n\n### 6.4 Security\n\n- API key authentication for lifecycle operations\n- Token-based authentication for execution operations\n- Isolated sandbox environments\n- Resource quota enforcement\n- Network isolation options\n\n### 6.5 Observability\n\n- Structured state transitions\n- Real-time metrics streaming\n- Comprehensive logging\n- Health check endpoints\n\n## 7. Use Cases\n\n### 7.1 AI Code Generation and Execution\n\nAI models (like Claude, GPT-4, Gemini) generate code that needs to be executed safely:\n\n- **Isolation**: Run untrusted AI-generated code in sandboxes\n- **Multi-Language**: Support various programming languages\n- **Iteration**: Maintain state across multiple code generations\n- **Feedback**: Capture execution results and errors for AI refinement\n\n**Examples**: [claude-code](../examples/claude-code/), [gemini-cli](../examples/gemini-cli/), [codex-cli](../examples/codex-cli/)\n\n### 7.2 Interactive Coding Environments\n\nBuild web-based coding platforms and notebooks:\n\n- **Code Execution**: Run code in isolated environments\n- **File Management**: Upload/download project files\n- **Terminal Access**: Execute shell commands\n- **Collaboration**: Share sandbox instances\n\n**Examples**: [code-interpreter](../examples/code-interpreter/)\n\n### 7.3 Browser Automation and Testing\n\nAutomate web browsers for testing and scraping:\n\n- **Headless Browsers**: Chrome, Playwright\n- **Remote Debugging**: DevTools protocol\n- **VNC Access**: Visual debugging\n- **Network Isolation**: Controlled environment\n\n**Examples**: [chrome](../examples/chrome/), [playwright](../examples/playwright/)\n\n### 7.4 Remote Development Environments\n\nProvide cloud-based development workspaces:\n\n- **VS Code Server**: Full IDE in browser\n- **Desktop Environments**: VNC-based desktops\n- **Tool Pre-installation**: Language runtimes, build tools\n- **Port Forwarding**: Access development servers\n\n**Examples**: [vscode](../examples/vscode/), [desktop](../examples/desktop/)\n\n### 7.5 Continuous Integration and Testing\n\nRun build and test pipelines in isolated environments:\n\n- **Reproducible Builds**: Consistent container images\n- **Parallel Execution**: Multiple sandbox instances\n- **Artifact Collection**: Download build outputs\n- **Resource Limits**: Prevent resource exhaustion\n\n## 8. Conclusion\n\nOpenSandbox provides a complete, production-ready platform for building AI-powered applications that require safe code execution, file management, and command execution in isolated environments. The architecture is designed to be:\n\n- **Universal**: Works with any container image\n- **Extensible**: Pluggable runtimes and custom implementations\n- **Developer-Friendly**: Multi-language SDKs with consistent APIs\n- **Production-Ready**: Robust lifecycle management and observability\n- **Secure**: Isolated environments with access control\n\nThe protocol-first design ensures that all components can evolve independently while maintaining compatibility. Whether you're building AI coding assistants, interactive notebooks, or remote development environments, OpenSandbox provides the foundation you need.\n\n## 9. References\n\n- [Contributing Guide](contributing.md)\n- [Sandbox Lifecycle Spec](../specs/sandbox-lifecycle.yml)\n- [Sandbox Execution Spec](../specs/execd-api.yaml)\n- [Server Documentation](../server/README.md)\n- [execd Documentation](../components/execd/README.md)\n- [Python SDK](../sdks/sandbox/python/README.md)\n- [Java/Kotlin SDK](../sdks/sandbox/kotlin/README.md)\n- [Examples](../examples/README.md)\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: OpenSandbox\n  text: Universal Sandbox Infrastructure for AI Applications\n  tagline: Securely run commands, filesystems, code interpreters, browsers, and developer tools in isolated runtime environments.\n  actions:\n    - theme: brand\n      text: Quick Start\n      link: /overview/home\n    - theme: alt\n      text: Explore Architecture\n      link: /overview/architecture\n\nfeatures:\n  - title: Sandbox Lifecycle and Runtime Management\n    details: Provision, monitor, renew, and terminate sandbox instances with Docker and Kubernetes-oriented runtime capabilities.\n  - title: Multi-Language SDKs and Unified APIs\n    details: Build with Python, Java/Kotlin, and JavaScript SDKs on top of standardized lifecycle and execution protocols.\n  - title: Powerful In-Sandbox Execution\n    details: Execute shell commands, manage files, run multi-language code interpreters, expose ports, and stream logs/metrics.\n  - title: Built for Real AI Workloads\n    details: Supports coding agents, browser automation, remote development, AI code execution, and RL training scenarios.\n---\n\n## Typical Scenarios\n\nOpenSandbox is now listed in the [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox).\n\n<div class=\"scenario-grid\">\n  <a class=\"scenario-card\" href=\"./examples/claude-code/readme\">\n    <h3>Coding Agents</h3>\n    <p>Run Claude Code, Gemini CLI, Codex, and other agent tools in isolated sandboxes.</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/playwright/readme\">\n    <h3>Browser Automation</h3>\n    <p>Execute Chrome and Playwright workloads with controlled runtime, filesystem, and networking.</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/vscode/readme\">\n    <h3>Remote Development</h3>\n    <p>Host VS Code Web and desktop-like environments for secure cloud development workflows.</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/code-interpreter/readme\">\n    <h3>AI Code Execution</h3>\n    <p>Run model-generated code safely, stream outputs, and iterate quickly with reproducible environments.</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/rl-training/readme\">\n    <h3>RL Training</h3>\n    <p>Launch reinforcement learning tasks with managed sandbox lifecycle and resource controls.</p>\n  </a>\n</div>\n\nExplore all scenario references in [Examples](./examples/readme).\n"
  },
  {
    "path": "docs/manual-cleanup-refactor-guide.md",
    "content": "# Manual Cleanup Refactor Guide\n\n## Background\n\nGitHub issue: `alibaba/OpenSandbox#442`\n\nIssue summary:\n\n- Support non-expiring sandboxes\n- Let callers manage cleanup explicitly\n- Keep existing TTL-based behavior for current users\n- Work across Docker and Kubernetes runtimes where supported\n\nCurrent implementation does not support this. TTL is a hard requirement in:\n\n- API request/response models\n- Docker runtime scheduling and restore logic\n- Kubernetes workload creation and renew flows\n\nThis document captures the recommended refactor direction before implementation starts.\n\n## Refactor Goal\n\nIntroduce a manual cleanup mode without adding a new top-level mode field for now.\n\nChosen semantic:\n\n- `timeout` present: sandbox uses TTL behavior\n- `timeout` omitted or `null`: sandbox uses manual cleanup behavior\n\nNon-goals for this refactor:\n\n- Do not support magic values like `timeout=0` or `timeout=-1`\n- Do not redesign the lifecycle API beyond what is required for manual cleanup\n- Do not overload `renew_expiration` to switch a sandbox from manual mode back to TTL mode\n\n## Compatibility and Rollout\n\nThis refactor is compatible through a controlled upgrade path, not through strict protocol backward compatibility.\n\nImportant compatibility fact:\n\n- Once manual cleanup is enabled in an environment, lifecycle responses may contain `expiresAt=null`\n- Lifecycle responses may also serialize other nullable fields explicitly as `null` instead of omitting them\n- Older SDKs that assume `expiresAt` is always a timestamp may fail when they call `create`, `get`, or `list`\n- Older schema-generated clients may also fail if they assume fields such as `metadata`, `status.reason`,\n  `status.message`, or `status.lastTransitionAt` are always omitted or always non-null\n- Existing TTL-based callers are unaffected as long as they do not encounter manual-cleanup sandboxes\n\nRecommended rollout order:\n\n1. Upgrade all SDKs/clients that read lifecycle API responses\n2. Upgrade the server\n3. Only then start creating sandboxes with `timeout` omitted or `null`\n\nOperational rule:\n\n- Do not create manual-cleanup sandboxes in a shared environment until all readers of the lifecycle API have been upgraded\n\nThis should be called out explicitly in release notes and upgrade documentation.\n\n## Why This Approach\n\nCompared with adding `expirationMode`, using `timeout: Optional[int]` is the smallest compatible change that still maps cleanly to the feature request.\n\nAdvantages:\n\n- Smaller API and SDK surface change\n- Easier migration from the current TTL-only model\n- Preserves current behavior for existing clients that already send `timeout`\n\nTradeoffs:\n\n- Mode becomes implicit rather than explicit\n- `timeout == null` can mean either deliberate manual mode or missing input\n- Future expansion beyond `ttl/manual` may require a second API refactor\n\nFor the current scope, these tradeoffs are acceptable.\n\n## Current State\n\n### API layer\n\nTTL is currently mandatory.\n\nRelevant files:\n\n- `server/src/api/schema.py`\n- `specs/sandbox-lifecycle.yml`\n\nCurrent constraints:\n\n- `CreateSandboxRequest.timeout` is required and bounded to `60-86400`\n- `CreateSandboxResponse.expiresAt` is required\n- `Sandbox.expiresAt` is required\n- `RenewSandboxExpirationRequest.expiresAt` is required and assumes the sandbox already has TTL semantics\n\n### Docker runtime\n\nRelevant file:\n\n- `server/src/services/docker.py`\n\nCurrent behavior:\n\n- Creation always computes `expires_at = created_at + timeout`\n- Creation always schedules expiration via in-process timer\n- Existing sandboxes are restored from the expiration label on server startup\n- Sandbox read/list responses always expose `expiresAt`\n- `renew_expiration()` only supports extending TTL\n\n### Kubernetes runtime\n\nRelevant files:\n\n- `server/src/services/k8s/kubernetes_service.py`\n- `server/src/services/k8s/batchsandbox_provider.py`\n- `server/src/services/k8s/agent_sandbox_provider.py`\n\nCurrent behavior:\n\n- Creation always computes `expires_at = created_at + timeout`\n- BatchSandbox writes `spec.expireTime`\n- agent-sandbox writes `spec.shutdownTime`\n- `renew_expiration()` patches those fields\n- Sandbox read/list responses expose `expiresAt`\n\n## Target API Semantics\n\n### Create request\n\n`CreateSandboxRequest.timeout` should become optional.\n\nRules:\n\n- `timeout` omitted or `null` means manual cleanup mode\n- `timeout` present means TTL mode\n- If present, `timeout` must still satisfy `60 <= timeout <= 86400`\n- `timeout=0` and `timeout<0` remain invalid\n\nSuggested request examples:\n\nTTL mode:\n\n```json\n{\n  \"image\": { \"uri\": \"python:3.11\" },\n  \"timeout\": 3600,\n  \"resourceLimits\": {},\n  \"entrypoint\": [\"sleep\", \"infinity\"]\n}\n```\n\nManual cleanup mode:\n\n```json\n{\n  \"image\": { \"uri\": \"python:3.11\" },\n  \"resourceLimits\": {},\n  \"entrypoint\": [\"sleep\", \"infinity\"]\n}\n```\n\n### Response models\n\n`expiresAt` should become nullable in:\n\n- `CreateSandboxResponse`\n- `Sandbox`\n\nRules:\n\n- TTL sandbox: `expiresAt` contains an RFC 3339 timestamp\n- Manual sandbox: `expiresAt` is `null`\n\n### Renew expiration API\n\nDo not use `renew_expiration` as a mode switch.\n\nRecommended behavior:\n\n- TTL sandbox: renew works as it does today\n- Manual sandbox: renew fails clearly\n\nRecommended response:\n\n- `409 Conflict` preferred\n- `400 Bad Request` acceptable if existing error handling makes that much simpler\n\nRecommended error message:\n\n- `\"Sandbox <id> does not have automatic expiration enabled.\"`\n\n## Implementation Strategy\n\n## 1. API and schema updates\n\nFiles to update:\n\n- `server/src/api/schema.py`\n- `specs/sandbox-lifecycle.yml`\n\nRequired changes:\n\n- Make `CreateSandboxRequest.timeout` optional\n- Make `CreateSandboxResponse.expiresAt` optional\n- Make `Sandbox.expiresAt` optional\n- Update field descriptions to document manual cleanup behavior\n- Update request/response examples in the OpenAPI spec\n\nRecommended validation rule:\n\n- No custom mode field\n- Validation only enforces bounds when `timeout` is not `None`\n\n## 2. Docker runtime refactor\n\nFile to update:\n\n- `server/src/services/docker.py`\n\n### Target behavior\n\nFor manual sandboxes:\n\n- No expiration timestamp is computed\n- No expiration label is written\n- A dedicated runtime marker should be written (for example `opensandbox.io/manual-cleanup=true`)\n- No expiration timer is scheduled\n- Sandbox survives server restart without restoration warnings\n- Read/list responses return `expiresAt=None`\n\n### Concrete refactor points\n\n#### Creation context\n\nCurrent logic:\n\n- `_prepare_creation_context()` always returns a concrete `expires_at`\n\nTarget logic:\n\n- Return `expires_at: Optional[datetime]`\n- `None` when `request.timeout is None`\n\n#### Label building\n\nCurrent logic:\n\n- Expiration label is assumed to exist\n\nTarget logic:\n\n- Only write `SANDBOX_EXPIRES_AT_LABEL` when `expires_at is not None`\n- Write a dedicated manual-cleanup label/annotation when `expires_at is None`\n\n#### Provisioning\n\nCurrent logic:\n\n- `_provision_sandbox()` always schedules expiration\n\nTarget logic:\n\n- Only call `_schedule_expiration()` when `expires_at is not None`\n\n#### Sandbox reconstruction\n\nCurrent logic:\n\n- `_container_to_sandbox()` falls back to a concrete `expires_at`\n\nTarget logic:\n\n- Manual sandbox should produce `expiresAt=None`\n- Avoid fallback behavior that fabricates an expiration timestamp from `created_at`\n\n#### Restore path\n\nCurrent logic:\n\n- `_restore_existing_sandboxes()` warns when a sandbox is missing the expiration label\n\nTarget logic:\n\n- Missing expiration label should only be treated as valid when the manual-cleanup marker is present\n- Continue warning on sandboxes that have neither an expiration label nor a manual-cleanup marker\n- Only restore timers for TTL sandboxes that actually carry expiration metadata\n\n#### Renew path\n\nCurrent logic:\n\n- `renew_expiration()` assumes every sandbox has TTL enabled\n\nTarget logic:\n\n- Reject renewal if the manual-cleanup marker is present\n- Continue treating \"missing expiration metadata without manual marker\" as malformed state rather than silently converting it to manual mode\n\n## 3. Kubernetes service refactor\n\nFiles to update:\n\n- `server/src/services/k8s/kubernetes_service.py`\n- `server/src/services/k8s/workload_provider.py`\n- `server/src/services/k8s/batchsandbox_provider.py`\n- `server/src/services/k8s/agent_sandbox_provider.py`\n\n### Key risk\n\nKubernetes support depends on the underlying CRDs.\n\nOpen question:\n\n- Can BatchSandbox omit `spec.expireTime`?\n- Can agent-sandbox omit `spec.shutdownTime`?\n\nThis must be confirmed before claiming end-to-end support.\n\n### Recommended capability design\n\nAdd a provider capability check:\n\n- `supports_manual_cleanup() -> bool`\n\nPersist the chosen mode on workload metadata as well:\n\n- TTL sandbox: keep expiration field populated\n- Manual sandbox: omit expiration field and write a provider-neutral marker (label or annotation)\n\nRationale:\n\n- Docker can support manual cleanup immediately\n- Kubernetes providers may differ based on CRD semantics\n- The server should fail clearly when the selected provider cannot represent a non-expiring sandbox\n\n### Service-layer behavior\n\nIn `KubernetesSandboxService.create_sandbox()`:\n\n- Compute `expires_at: Optional[datetime]`\n- If `request.timeout is None` and provider does not support manual cleanup, fail early with a clear message\n\nSuggested message:\n\n- `\"Manual cleanup mode is not supported by the current Kubernetes workload provider.\"`\n\n### BatchSandbox provider behavior\n\nIf supported by the CRD:\n\n- Make `expires_at` optional in provider interfaces\n- Omit `spec.expireTime` when `expires_at is None`\n- `get_expiration()` should return `None` when the field is absent\n- `update_expiration()` should reject manual sandboxes instead of silently enabling TTL\n\nIf not supported by the CRD:\n\n- Return `False` from `supports_manual_cleanup()`\n- Keep current `expireTime` behavior unchanged\n\n### agent-sandbox provider behavior\n\nIf supported by the CRD:\n\n- Make `expires_at` optional in provider interfaces\n- Omit `spec.shutdownTime` when `expires_at is None`\n- `get_expiration()` should return `None` when the field is absent\n- `update_expiration()` should reject manual sandboxes\n\nIf not supported by the CRD:\n\n- Return `False` from `supports_manual_cleanup()`\n- Keep current `shutdownTime` behavior unchanged\n\n## 4. Interface changes\n\nFiles likely affected:\n\n- `server/src/services/sandbox_service.py`\n- `server/src/services/k8s/workload_provider.py`\n\nRequired updates:\n\n- Any method signature currently assuming `expires_at: datetime` should be reviewed\n- Provider creation/update/get-expiration flows should allow `Optional[datetime]` where needed\n- Abstract service docs should describe manual cleanup semantics\n\n## Error Handling Guidance\n\nRecommended failure cases:\n\n### Unsupported runtime/provider\n\nCase:\n\n- User omits `timeout`\n- Provider cannot represent non-expiring sandbox\n\nResponse:\n\n- HTTP 400\n\nMessage:\n\n- `\"Manual cleanup mode is not supported by the current runtime/provider.\"`\n\n### Renew called for manual sandbox\n\nResponse:\n\n- HTTP 409 preferred\n\nMessage:\n\n- `\"Sandbox <id> does not have automatic expiration enabled.\"`\n\n### Invalid timeout values\n\nKeep current behavior:\n\n- Reject `timeout=0`\n- Reject negative values\n- Reject values above max bound\n\n## Compatibility Plan\n\nThis refactor should preserve backward compatibility for current users.\n\nExpected compatibility behavior:\n\n- Existing clients sending `timeout` continue to work unchanged\n- Existing responses for TTL sandboxes remain unchanged\n- New manual-cleanup behavior is opt-in via omission of `timeout`\n\nCompatibility caveat:\n\n- Any generated SDKs may need regeneration because `timeout` and `expiresAt` types change from required to optional\n- Generated SDKs should also tolerate explicit `null` values in optional lifecycle fields, not only missing fields\n- Cross-SDK request shapes do not need to be byte-for-byte identical if language constraints differ. In particular, the\n  C# SDK may use an explicit `ManualCleanup` flag instead of `timeout=null` so it can keep \"unset means use default TTL\"\n  distinct from \"explicitly request manual cleanup\".\n\n## Testing Plan\n\n### API/schema tests\n\nFiles likely affected:\n\n- `server/tests/test_schema.py`\n- route tests covering create/get/list/renew\n\nAdd coverage for:\n\n- Create request without `timeout`\n- Create request with valid `timeout`\n- Reject `timeout=0`\n- Create response with `expiresAt=null`\n- Sandbox model with `expiresAt=null`\n\n### Docker tests\n\nFile likely affected:\n\n- `server/tests/test_docker_service.py`\n\nAdd coverage for:\n\n- Manual sandbox creation does not schedule expiration\n- Manual sandbox creation does not write expiration label\n- Manual sandbox get/list returns `expiresAt=None`\n- Server restart restore path ignores manual sandboxes without warning\n- Renew expiration on manual sandbox fails clearly\n- TTL sandbox behavior remains unchanged\n\n### Kubernetes service tests\n\nFiles likely affected:\n\n- `server/tests/k8s/test_kubernetes_service.py`\n- `server/tests/k8s/test_batchsandbox_provider.py`\n- `server/tests/k8s/test_agent_sandbox_provider.py`\n\nAdd coverage for:\n\n- Manual mode rejected when provider capability is false\n- Manual mode omits expiration fields when provider capability is true\n- Manual mode writes the runtime marker when provider capability is true\n- `get_expiration()` returns `None` when expiration field is absent\n- Renew expiration fails for manual sandboxes\n- TTL sandbox behavior remains unchanged\n\n### Spec/SDK validation\n\nFollow-up checks:\n\n- Regenerate or validate OpenAPI docs if needed\n- Verify generated SDKs handle optional `timeout` and nullable `expiresAt`\n\n## Suggested Implementation Order\n\n1. Update schema models in `server/src/api/schema.py`\n2. Update OpenAPI spec in `specs/sandbox-lifecycle.yml`\n3. Refactor Docker runtime to support `expires_at: Optional[datetime]`\n4. Add Kubernetes provider capability plumbing\n5. Implement Kubernetes manual mode only where confirmed supported\n6. Add and update tests\n7. Regenerate SDK/spec artifacts if required by repo workflow\n\n## Open Questions Before Coding\n\nThese should be resolved early in the branch:\n\n1. Does BatchSandbox allow `spec.expireTime` to be omitted?\n2. Does agent-sandbox allow `spec.shutdownTime` to be omitted?\n3. Should renew-on-manual return `400` or `409`?\n4. Should list/get expose any explicit hint that a sandbox is manual, or is `expiresAt=null` sufficient?\n\nRecommended implementation default for questions 1 and 2 until confirmed:\n\n- Return `False` from `supports_manual_cleanup()` for both Kubernetes providers\n- Enable Kubernetes manual mode only after CRD behavior is verified by tests or upstream documentation\n\nRecommended answer for question 4:\n\n- `expiresAt=null` is sufficient for the first iteration\n\n## Summary\n\nThe smallest practical refactor is:\n\n- Make `timeout` optional\n- Treat missing `timeout` as manual cleanup mode\n- Make `expiresAt` nullable\n- Support manual mode in Docker immediately\n- Gate Kubernetes support behind provider capability and CRD validation\n- Keep `renew_expiration()` TTL-only\n\nThis preserves current behavior while creating a clear path to non-expiring sandboxes with limited API churn.\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"opensandbox-docs\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@9.15.0\",\n  \"scripts\": {\n    \"docs:sync\": \"node .vitepress/scripts/docs-manifest.mjs\",\n    \"docs:spec\": \"node ../scripts/spec-doc/generate-spec.js --output docs/public/api/spec-inline.js\",\n    \"docs:dev\": \"pnpm docs:sync && pnpm docs:spec && vitepress dev\",\n    \"docs:build\": \"pnpm docs:sync && pnpm docs:spec && vitepress build\",\n    \"docs:preview\": \"vitepress preview\"\n  },\n  \"devDependencies\": {\n    \"vitepress\": \"^1.6.4\"\n  }\n}\n"
  },
  {
    "path": "docs/secure-container.md",
    "content": "# Secure Container Runtime Guide\n\nThis guide explains how to use secure container runtimes with OpenSandbox to provide hardware-level isolation for executing untrusted AI-generated code.\n\n## Table of Contents\n\n- [Overview](#overview)\n- [Server Configuration](#server-configuration)\n- [Docker Mode](#docker-mode)\n- [Kubernetes Mode](#kubernetes-mode)\n- [User Guide](#user-guide)\n- [Administrator Guide](#administrator-guide)\n- [Troubleshooting and Best Practices](#troubleshooting-and-best-practices)\n\n---\n\n## Overview\n\n### What are Secure Container Runtimes?\n\nSecure container runtimes provide stronger isolation than the standard runc runtime used by Docker and containerd. They add additional security layers through different mechanisms:\n\n| Runtime | Isolation Mechanism | Startup Overhead | Memory Overhead | Best For |\n|---------|---------------------|------------------|-----------------|----------|\n| **runc** (default) | Process-level cgroups | ~0ms | Minimal | Trusted workloads, local development |\n| **gVisor** | User-space kernel (syscall interception) | ~10-50ms | ~50MB | General workloads with low overhead |\n| **Kata (QEMU)** | Full VM with QEMU hypervisor | ~500ms | ~20-50MB | Maximum compatibility and isolation |\n| **Kata (Firecracker)** | MicroVM with Firecracker hypervisor | ~125ms | ~5MB | High density, minimal footprint |\n| **Kata (CLH)** | Cloud Hypervisor | ~200ms | ~10-20MB | Balanced performance and isolation |\n\n### Why Use Secure Runtimes?\n\nOpenSandbox is designed to execute untrusted code generated by AI models (Claude, GPT-4, Gemini, etc.). Secure runtimes provide:\n\n1. **Container Escape Protection**: Prevents malicious code from breaking out of the container\n2. **Kernel-Level Isolation**: Each sandbox gets its own kernel context\n3. **Multi-Tenant Safety**: Different users' sandboxes are strongly isolated\n4. **Compliance**: Meets security requirements for regulated industries\n\n### Supported Runtime Types\n\nOpenSandbox supports the following secure runtime types through server-level configuration:\n\n- `\"gvisor\"` - Google gVisor with runsc\n- `\"kata\"` - Kata Containers with QEMU hypervisor (default)\n- `\"firecracker\"` - Kata Containers with Firecracker hypervisor\n- `\"\"` (empty) - Standard runc (default, no secure runtime)\n\n### Key Design Principle\n\n**Server-Level Configuration**: The secure runtime is configured once at the server level by administrators. All sandboxes on that server transparently use the configured runtime. SDK users and API callers require **no code changes**.\n\n---\n\n## Server Configuration\n\nSecure runtimes are configured through the `~/.sandbox.toml` configuration file. The server validates the configured runtime at startup and will refuse to start if the runtime is unavailable.\n\n### Configuration File\n\nEdit `~/.sandbox.toml`:\n\n```toml\n[runtime]\ntype = \"docker\"  # or \"kubernetes\"\nexecd_image = \"opensandbox/execd:latest\"\n\n# Secure container runtime configuration\n# When enabled, ALL sandboxes on this server use the specified runtime\n[secure_runtime]\n# Runtime type: \"\", \"gvisor\", \"kata\", \"firecracker\"\ntype = \"\"\n\n# Docker mode: OCI runtime name (e.g., \"runsc\" for gVisor, \"kata-runtime\" for Kata)\n# Required when runtime.type = \"docker\" and type is not empty\ndocker_runtime = \"runsc\"\n\n# Kubernetes mode: RuntimeClass name (e.g., \"gvisor\", \"kata-qemu\", \"kata-fc\")\n# Required when runtime.type = \"kubernetes\" and type is not empty\nk8s_runtime_class = \"gvisor\"\n```\n\n### Configuration Examples\n\n#### Example 1: gVisor on Docker\n\n```toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"gvisor\"\ndocker_runtime = \"runsc\"\nk8s_runtime_class = \"gvisor\"\n```\n\n#### Example 2: Kata Containers on Kubernetes\n\n```toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"kata\"\ndocker_runtime = \"kata-runtime\"\nk8s_runtime_class = \"kata-qemu\"\n```\n\n#### Example 3: Kata + Firecracker on Kubernetes\n\n```toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"firecracker\"\ndocker_runtime = \"\"  # Not supported in Docker mode\nk8s_runtime_class = \"kata-fc\"\n```\n\n### Startup Validation\n\nWhen the server starts, it automatically validates that the configured secure runtime is available:\n\n```bash\n$ opensandbox-server\nINFO     Validating secure runtime for Docker backend\nINFO     Docker OCI runtime 'runsc' is available: {...}\nINFO     Application startup complete.\n```\n\nIf the runtime is not available, the server will refuse to start with a clear error message:\n\n```\nERROR    Configured Docker runtime 'runsc' is not available.\n        Available runtimes: runc.\n        Please install and configure it in /etc/docker/daemon.json.\n```\n\n---\n\n## Docker Mode\n\nDocker mode is fully supported for secure container runtimes.\n\n### Prerequisites\n\n- Docker daemon installed and running\n- Secure runtime installed on the host\n\n### gVisor Setup for Docker\n\n#### Step 1: Install gVisor runsc\n\nFor Docker mode, you only need to install the **runsc** OCI runtime:\n\n```bash\n# Ubuntu/Debian\ncurl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg\necho \"deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main\" | \\\n  sudo tee /etc/apt/sources.list.d/gvisor.list\nsudo apt-get update && sudo apt-get install -y runsc\n\n# Verify installation\nrunsc --version\n```\n\n> **Note**: For Docker mode, only `runsc` is required. The `containerd-shim-runsc-v1` is only needed for Kubernetes/containerd.\n>\n> **Reference**: See [gVisor Installation Guide](https://gvisor.dev/docs/user_guide/install/) for other distributions and installation methods.\n\n#### Step 2: Configure Docker daemon\n\nUse the `runsc install` command to automatically configure Docker daemon:\n\n```bash\nsudo runsc install\n```\n\nOr manually edit `/etc/docker/daemon.json`:\n\n```json\n{\n  \"runtimes\": {\n    \"runsc\": {\n      \"path\": \"/usr/bin/runsc\",\n      \"runtimeArgs\": [\n        \"--platform=systrap\",\n        \"--network=host\"\n      ]\n    }\n  }\n}\n```\n\nRestart Docker:\n\n```bash\nsudo systemctl restart docker\n```\n\n> **Reference**: See [gVisor Docker Quick Start](https://gvisor.dev/docs/user_guide/quick_start/docker/) for more details.\n\n#### Step 3: Configure OpenSandbox Server\n\nEdit `~/.sandbox.toml`:\n\n```toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"gvisor\"\ndocker_runtime = \"runsc\"\n```\n\n#### Step 4: Start Server and Verify\n\n```bash\nopensandbox-server\n```\n\nCreate a test sandbox:\n\n```bash\ncurl -X POST http://localhost:8080/v1/sandboxes \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"image\": {\"uri\": \"python:3.11\"},\n    \"timeout\": 3600,\n    \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n    \"entrypoint\": [\"python\", \"-u\", \"-c\", \"import time\\nwhile True: print('hello from gVisor!'); time.sleep(1)\"],\n    \"metadata\": {\n      \"name\": \"gvisor-docker-sandbox\"\n    }\n  }'\n```\n\nVerify the runtime:\n\n```bash\ndocker ps --format \"{{.ID}}\\t{{.Image}}\\t{{.Names}}\"\ndocker inspect <container_id> | grep -A2 Runtime\n# Expected output:\n# \"Runtime\": \"runsc\",\n```\n\n### Kata Containers Setup for Docker\n\n#### System Requirements\n\nKata Containers requires hardware virtualization support. Verify your system meets the following requirements:\n\n**Hardware Virtualization Support:**\n```bash\n# Check if CPU supports hardware virtualization (VT-x for Intel, AMD-V for AMD)\nlscpu | grep Virtualization\n# Expected output: Virtualization: VT-x (Intel) or AMD-V (AMD)\n\n# Alternatively on Intel\ngrep -E --color=auto 'vmx|svm' /proc/cpuinfo\n# Expected: vmx (Intel) or svm (AMD) flags present\n```\n\n**KVM Module:**\n```bash\n# Check if KVM module is loaded\nlsmod | grep kvm\n# Expected: kvm_intel (Intel) or kvm_amd (AMD)\n\n# If not loaded, load KVM module\nsudo modprobe kvm_intel  # For Intel\n# or\nsudo modprobe kvm_amd    # For AMD\n```\n\n**Kernel Requirements:**\n- Linux kernel 5.10 or later recommended\n- KVM enabled in kernel config\n\n**Docker Requirements:**\n- Docker 20.10 or later\n- `/etc/docker/daemon.json` configured for Kata runtime\n\n#### Installation\n\nDownload and install Kata Containers static binaries from GitHub releases:\n\n```bash\n# Find the latest release at https://github.com/kata-containers/kata-containers/releases\nKATA_VERSION=\"3.27.0\"\nwget https://github.com/kata-containers/kata-containers/releases/download/${KATA_VERSION}/kata-static-${KATA_VERSION}-amd64.tar.zst\n\n# Extract to root directory - Kata will be installed in /opt/kata\nzstd -d kata-static-${KATA_VERSION}-amd64.tar.zst\ntar -xvf kata-static-${KATA_VERSION}-amd64.tar -C /\n\n# Create symbolic links for PATH access\nsudo ln -sf /opt/kata/bin/kata-runtime /usr/local/bin/kata-runtime\nsudo ln -sf /opt/kata/bin/containerd-shim-kata-v2 /usr/local/bin/containerd-shim-kata-v2\n\n# Verify installation\nkata-runtime --version\n```\n\n#### Configure Docker Daemon\n\nEdit `/etc/docker/daemon.json` to register Kata as a runtime:\n\n```json\n{\n  \"default-runtime\": \"runc\",\n  \"runtimes\": {\n    \"kata\": {\n      \"runtimeType\": \"io.containerd.kata.v2\"\n    }\n  }\n}\n```\n\nRestart Docker to apply changes:\n\n```bash\nsudo systemctl restart docker\n\n# Verify Kata is available in Docker\ndocker info | grep -A5 Runtimes\n# Expected output should include \"io.containerd.runc.v2 kata\"\n```\n\n#### Configure OpenSandbox Server\n\nEdit `~/.sandbox.toml`:\n\n```toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"kata\"\ndocker_runtime = \"kata\"\n```\n\n#### Verify Installation\n\n**Test with OpenSandbox API**\n\nCreate a sandbox and verify it's running in a VM by checking the kernel:\n\n```bash\n# Create a test sandbox\ncurl --location 'http://127.0.0.1:8080/v1/sandboxes' \\\n  --header 'Content-Type: application/json' \\\n  --data '{\n    \"image\": {\"uri\": \"ubuntu:latest\"},\n    \"timeout\": 3600,\n    \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n    \"entrypoint\": [\"/bin/bash\", \"-c\", \"while true; do uname -a; sleep 1; done\"],\n    \"metadata\": {\n      \"name\": \"kata-sandbox\"\n    }\n  }'\n```\n\nCheck the container's kernel to verify VM isolation:\n\n```bash\n# Get the container ID\ndocker ps | grep kata-sandbox\n\n# Check the kernel inside the container (should be different from host)\ndocker exec <container_id> uname -a\n# Expected output: Linux <hostname> 5.10.x-generic #x86_64 ... (Kata VM kernel)\n\n# Compare with host kernel\nuname -a\n# Host kernel might be different version or have different hostname\n```\n\n**Key Indicators of Kata VM:**\n- Container runs in a separate kernel with different hostname\n- Kernel version is typically `5.10.x` (Kata's guest kernel)\n- Host process list shows `qemu-system-x86_64` or similar hypervisor process\n---\n\n## Kubernetes Mode\n\nKubernetes mode supports secure runtimes through RuntimeClass resources.\n\n### Prerequisites\n\n- Kubernetes cluster with containerd runtime\n- Secure runtime installed on all nodes\n- RuntimeClass CRDs created\n\n### gVisor Setup for Kubernetes\n\n#### Step 1: Install gVisor Components on All Nodes\n\nFor Kubernetes with containerd, you need to install **two** components:\n\n1. **runsc** - the gVisor OCI runtime\n2. **containerd-shim-runsc-v1** - the containerd shim for gVisor\n\n```bash\n# On each node - Ubuntu/Debian\ncurl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg\necho \"deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main\" | \\\n  sudo tee /etc/apt/sources.list.d/gvisor.list\nsudo apt-get update\n\n# Install both gVisor components\nsudo apt-get install -y runsc containerd-shim-runsc-v1\n\n# Verify installation\nrunsc --version\ncontainerd-shim-runsc-v1 --version\n```\n> **Reference**: See [gVisor Installation Guide](https://gvisor.dev/docs/user_guide/containerd/configuration/) for complete installation instructions and other distributions.\n\n#### Step 2: Configure containerd\n\nEdit `/etc/containerd/config.toml`:\n\n```toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc]\n          runtime_type = \"io.containerd.runsc.v1\"\n          [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc.options]\n            TypeUrl = \"io.containerd.runsc.v1.options\"\n            ConfigPath = \"/etc/containerd/runsc.toml\"\n```\n\n```bash\nsudo tee /etc/containerd/runsc.toml > /dev/null <<'EOF'\n[runsc]\n  platform = \"ptrace\"\nEOF\n```\n\nRestart containerd:\n\n```bash\nsudo systemctl restart containerd\n```\n\n#### Step 3: Create RuntimeClass CRD\n\n```yaml\n# gvisor-runtimeclass.yaml\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc\nscheduling:\n  nodeSelector:\n    kubernetes.io/arch: amd64\n```\n\n```bash\nkubectl apply -f gvisor-runtimeclass.yaml\n```\n\n#### Step 4: Configure OpenSandbox Server\n\nEdit `~/.sandbox.toml`:\n\n```toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"gvisor\"\nk8s_runtime_class = \"gvisor\"\n```\n\n#### Step 5: Verify Installation\n\n```bash\n# Test the RuntimeClass\nkubectl run test-gvisor --restart=Never --image=hello-world --runtime-class=gvisor\nkubectl logs test-gvisor\nkubectl delete pod test-gvisor\n```\n\n### Kata Containers Setup for Kubernetes\n\n#### Step 1: Install Kata Containers\n\nFollow the [official Kata Containers installation guide](https://github.com/kata-containers/kata-containers/blob/main/tools/packaging/kata-deploy/helm-chart/README.md).\n\nQuick installation using Helm:\n\n```bash\n# Install kata-deploy which will set up Kata Containers via DaemonSet\nhelm install kata-deploy \"oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy\" --version \"3.27.0\" --namespace kube-system --create-namespace\n\n# Wait for kata-deploy pods to be ready\nkubectl wait --for=condition=ready pod -l name=kata-deploy -n kube-system --timeout=300s\n```\n\n> **Note**: The `kata-deploy` DaemonSet will automatically configure containerd on all nodes. Manual containerd configuration is not required when using kata-deploy.\n\n#### Step 2: Verify Installation\n\nCheck that Kata Containers is installed and RuntimeClasses are created:\n\n```bash\n# Check RuntimeClasses\nkubectl get runtimeclass\n\n# Expected output:\n# NAME         HANDLER     AGE\n# kata         kata-qemu   10m\n# kata-qemu    kata-qemu   10m\n# kata-clh     kata-clh    10m\n# kata-fc      kata-fc     10m\n\n# Test Kata with a simple pod\nkubectl run test-kata --restart=Never --image=hello-world --runtime-class=kata-qemu\nkubectl logs test-kata\nkubectl delete pod test-kata\n```\n\n### Creating Pools for Different Runtimes (Optional)\n\nWhen using Pool CRDs for pre-warmed sandboxes, create separate pools for each runtime type:\n\n```yaml\n# gvisor-pool.yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: gvisor-pool\n  labels:\n    runtime: gvisor\nspec:\n  template:\n    spec:\n      runtimeClassName: gvisor\n      containers:\n        - name: sandbox-container\n          image: opensandbox/code-interpreter:v1.0.2\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\n---\n\n## User Guide\n\nThis section is for AI application developers using OpenSandbox.\n\n### No Code Changes Required\n\n**Important**: The secure runtime is configured at the server level. Your code does not need to change.\n\nSimply create a sandbox using the OpenSandbox Lifecycle API - the server automatically applies the configured secure runtime:\n\n**Create a test sandbox:**\n\n```bash\ncurl -X POST http://localhost:8080/v1/sandboxes \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"image\": {\"uri\": \"python:3.11\"},\n    \"timeout\": 3600,\n    \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n    \"entrypoint\": [\"python\", \"-u\", \"-c\", \"import time\\nwhile True: print(\\\"hello from secure sandbox!\\\"); time.sleep(1)\"],\n    \"metadata\": {\n      \"name\": \"my-secure-sandbox\"\n    }\n  }'\n```\n\n**Response:**\n\n```json\n{\n  \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n  \"status\": \"running\"\n}\n```\n\nThe sandbox will automatically use the secure runtime configured on the server (gVisor, Kata, or runc).\n\n### How It Works\n\n1. **Administrator** configures the secure runtime in `~/.sandbox.toml`\n2. **Server** validates the runtime at startup\n3. **Server** automatically injects the runtime into each sandbox:\n   - Docker mode: Adds `runtime` to HostConfig\n   - Kubernetes mode: Adds `runtimeClassName` to Pod spec\n4. **User** creates sandboxes via API - no runtime parameter needed\n\n### Verifying Runtime Isolation\n\nAfter creating a sandbox, verify the runtime being used:\n\n**Docker mode:**\n```bash\ndocker ps --format \"{{.ID}}\\t{{.Image}}\\t{{.Names}}\"\ndocker inspect <container_id> | grep -A2 Runtime\n# Expected output for gVisor:\n# \"Runtime\": \"runsc\",\n```\n\n**Kubernetes mode:**\n```bash\nkubectl get pod <pod-name> -o jsonpath='{.spec.runtimeClassName}'\n# Expected output for gVisor:\n# gvisor\n```\n\n---\n\n## Administrator Guide\n\nThis section is for platform operators and SREs managing secure runtime infrastructure.\n\n### Prerequisites\n\nSecure runtimes must be installed and configured on your infrastructure **before** configuring OpenSandbox. OpenSandbox does not install runtimes automatically.\n\n### Installation Summary\n\n| Runtime | Docker | Kubernetes |\n|---------|--------|------------|\n| gVisor | Install runsc → Configure daemon.json | Install runsc → Configure containerd → Create RuntimeClass |\n| Kata (QEMU) | Install kata-runtime → Configure daemon.json | Install Kata → Configure containerd → Create RuntimeClass |\n| Kata (Firecracker) | Not supported | Install Kata → Configure containerd → Create RuntimeClass |\n\n### Configuration Validation\n\nThe server validates secure runtime configuration at startup:\n\n1. **Docker mode**: Checks if the runtime exists in Docker daemon's runtime list\n2. **Kubernetes mode**: Checks if the RuntimeClass exists in the cluster\n\nIf validation fails, the server refuses to start with a clear error message.\n\n### Security Best Practices\n\n1. **Default to gVisor**: Provides good security with acceptable performance for most workloads\n2. **Use Kata for Untrusted Code**: Maximum isolation for completely unknown code\n3. **Regular Updates**: Keep runtimes updated for security patches\n4. **Test Compatibility**: Validate your workloads with the chosen runtime before production\n5. **Monitor Resources**: Secure runtimes have higher memory overhead\n\n### Runtime Selection Guidelines\n\n| Use Case | Recommended Runtime | Reasoning |\n|----------|---------------------|-----------|\n| Development/Testing | runc (default) | Fastest startup, lowest overhead |\n| Production AI Code Execution | gVisor | Good balance of security and performance |\n| High-Security Requirements | Kata (QEMU) | Maximum isolation, full compatibility |\n| High-Density Multi-Tenant | Kata (Firecracker) | Minimal memory overhead per sandbox |\n| Untrusted Network Code | gVisor or Kata | Syscall filtering prevents network attacks |\n\n---\n\n## Troubleshooting and Best Practices\n\n### Common Issues\n\n#### 1. Runtime Not Found (Docker)\n\n**Error**: `Configured Docker runtime 'runsc' is not available.`\n\n**Solution**: Ensure the runtime is configured in `/etc/docker/daemon.json` and Docker has been restarted:\n\n```bash\nsudo systemctl restart docker\ndocker info | grep -A5 Runtimes\n```\n\n#### 2. RuntimeClass Not Found (Kubernetes)\n\n**Error**: `RuntimeClass 'gvisor' does not exist.`\n\n**Solution**: Create the RuntimeClass CRD:\n\n```bash\nkubectl get runtimeclass\nkubectl apply -f gvisor-runtimeclass.yaml\n```\n\n#### 3. Syscall Compatibility Issues\n\n**Error**: Container exits with code 1, no logs\n\n**Cause**: gVisor doesn't implement all syscalls. Some applications may not be compatible.\n\n**Solution**: Check the [gVisor compatibility guide](https://gvisor.dev/docs/user_guide/compatibility/). Try using Kata (QEMU) which has better compatibility.\n\n#### 4. Pod Stuck in ContainerCreating\n\n**Cause**: RuntimeClass handler not configured on the node.\n\n**Solution**: Verify containerd configuration:\n\n```bash\n# On the node\nsudo containerd config dump\nsudo systemctl restart containerd\n```\n\n### Compatibility Matrix\n\n| Feature | runc | gVisor | Kata (QEMU) | Kata (CLH) | Kata (FC) |\n|---------|------|--------|-------------|------------|-----------|\n| Syscall Compatibility | Full | Partial | Full | Full | Limited |\n| GPU Support | Yes | No | Yes | Yes | No |\n| IPv6 | Yes | Yes | Yes | Yes | Yes |\n| Privileged Mode | Yes | No | Yes | Yes | No |\n| Docker Volume | Yes | Yes | Yes | Yes | Yes |\n| Systemd | Yes | No | Yes | Yes | No |\n\n### Getting Help\n\n- **Documentation**: [OpenSandbox GitHub](https://github.com/alibaba/OpenSandbox)\n- **Issues**: Report bugs via [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues)\n- **Design Document**: See [OSEP-0004](/oseps/0004-secure-container-runtime) for complete design details\n"
  },
  {
    "path": "docs/single_host_network.md",
    "content": "# Single-host network in OpenSandbox\n\nDetailed routing for a single-host deployment: how execd’s proxy gives every sandbox access to HTTP and WebSocket ports through one exposed host port.\n\n![Single-host sandbox routing](assets/single_host_network.png)\n\n## Single-host routing model\n- Every sandbox container starts `execd` listening on container port `44772`. `execd` bundles a lightweight reverse proxy that intercepts requests with the `/proxy/{port}` prefix and forwards them to `127.0.0.1:{port}` inside the same container.\n- The Docker runtime binds only the host side of the execd proxy port (labeled `opensandbox.io/embedding-proxy-port`). Callers use `get_endpoint(..., port=X)` to receive `{public_host}:{host_proxy_port}/proxy/{X}`, and execd transparently routes the request back to the sandbox service on port `X`.\n- Because the proxy preserves `Upgrade`, `Connection`, and other HTTP headers, HTTP, Server-Sent Events, and WebSocket traffic share the same mapped host port without additional configuration.\n- With this setup, a single host port per sandbox suffices to reach **all** container ports. You can safely run many sandboxes on one machine without worrying about overlapping host port allocations.\n- When the caller lives inside the same Docker network (e.g., another container or Kubernetes pod), use `get_endpoint(..., resolve_internal=True)` to bypass the host mapping and return the sandbox IP (e.g., `172.17.0.3:5900`) instead.\n- The diagram above shows the routing path: host traffic hits the proxy port, execd rewrites the request towards the target container port, and upstream services remain isolated within the sandbox.\n\n## Network modes\n\n### Host network mode (single-host constraints)\n- Containers share the host network stack (`network_mode=host`) so sandbox ports are directly accessible on the host.\n- Because each sandbox binds its ports on the host, this mode practically limits you to one sandbox instance per host unless you reserve dedicated ports per sandbox.\n- `get_endpoint(..., port=X)` returns `{public_host}:{X}` with no `/proxy/` prefix, so the caller needs to know the exact host port and the host must manage firewall rules for each sandbox port.\n\n### Bridge network mode (default for single-host deployments)\n- Docker places sandboxes on an isolated bridge network, preventing container ports from being reachable without explicit mapping.\n- For single-host scaling, OpenSandbox maps only execd’s proxy port (`44772`) and, optionally, port `8080`. Any other container port stays private and is reached via the proxy.\n- The reverse proxy label (`opensandbox.io/embedding-proxy-port`) identifies a host port that fronts `execd`. `get_endpoint(..., port=X)` returns `{public_host}:{host_proxy_port}/proxy/{X}`, so all internal ports can share the same host binding.\n- Port `8080` may also receive a direct host binding (`opensandbox.io/http-port`), providing a conventional HTTP endpoint without the proxy path when required.\n- This bridge setup lets a single machine host many sandboxes without port conflicts, because the same host proxy port can multiplex requests for HTTP, SSE, WebSocket, VNC, etc.\n\n## Operational notes\n- If execd’s proxy port (`44772`) or the optional `8080` host mapping is missing, `get_endpoint` responds with HTTP 500 and a message stating which mapping was unavailable.\n- Always keep the `/proxy/{port}` prefix (including any additional path or query string) when embedding URLs in browser-based clients or SDKs so that execd can correctly dispatch the request.\n- This proxy-based approach means additional ports never need to be published on the host, simplifying firewall management and improving security.\n"
  },
  {
    "path": "docs/zh/index.md",
    "content": "---\nlayout: home\n\nhero:\n  name: OpenSandbox\n  text: 面向 AI 应用的通用沙箱基础设施\n  tagline: 在隔离运行时中安全执行命令、文件操作、代码解释器、浏览器与开发工具。\n  actions:\n    - theme: brand\n      text: 快速开始\n      link: /zh/overview/home\n    - theme: alt\n      text: 查看架构\n      link: /zh/overview/architecture\n\nfeatures:\n  - title: 沙箱全生命周期与运行时管理\n    details: 支持沙箱实例创建、监控、续期与销毁，覆盖 Docker 与 Kubernetes 场景。\n  - title: 多语言 SDK 与统一协议\n    details: 提供 Python、Java/Kotlin、JavaScript SDK，并基于统一的生命周期与执行协议进行开发。\n  - title: 强大的沙箱内执行能力\n    details: 支持命令执行、文件系统操作、多语言代码解释、端口暴露以及日志/指标流式获取。\n  - title: 面向真实 AI 工作负载\n    details: 适配 Coding Agent、浏览器自动化、远程开发、AI 代码执行与强化学习训练等场景。\n---\n\n## 典型落地场景\n\nOpenSandbox 已进入 [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox)。\n\n<div class=\"scenario-grid\">\n  <a class=\"scenario-card\" href=\"./examples/claude-code/readme\">\n    <h3>Coding Agent</h3>\n    <p>在隔离沙箱中运行 Claude Code、Gemini CLI、Codex 等工具链。</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/playwright/readme\">\n    <h3>浏览器自动化</h3>\n    <p>运行 Chrome、Playwright 等工作负载，结合可控运行时、文件系统与网络策略。</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/vscode/readme\">\n    <h3>远程开发环境</h3>\n    <p>提供 VS Code Web 与桌面化开发环境，提升云端开发的安全性与一致性。</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/code-interpreter/readme\">\n    <h3>AI 代码执行</h3>\n    <p>安全执行模型生成代码，流式采集输出并在可复现环境中快速迭代。</p>\n  </a>\n  <a class=\"scenario-card\" href=\"./examples/rl-training/readme\">\n    <h3>强化学习训练</h3>\n    <p>在可控资源下运行 RL 训练任务，并利用沙箱生命周期能力管理训练过程。</p>\n  </a>\n</div>\n\n更多场景请查看 [示例](./examples/readme)。\n"
  },
  {
    "path": "examples/README.md",
    "content": "# OpenSandbox Examples\n\nExamples for common OpenSandbox use cases. Each subdirectory contains runnable code and documentation.\n\n## Integrations / Sandboxes\n- 🧰 [**aio-sandbox**](aio-sandbox): All-in-one sandbox setup using OpenSandbox SDK and agent-sandbox\n- <img src=\"https://kubernetes.io/icons/favicon-32.png\" alt=\"Kubernetes\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**agent-sandbox**](agent-sandbox): Create a kubernetes-sigs/agent-sandbox instance and run a command\n- 🧪 [**code-interpreter**](code-interpreter): Code Interpreter SDK singleton example\n- 💾 [**host-volume-mount**](host-volume-mount): Mount host directories into sandboxes (read-write, read-only, subpath)\n- ☁️ [**docker-ossfs-volume-mount**](docker-ossfs-volume-mount): Mount OSSFS volumes in Docker runtime (inline credentials, subpath, sharing)\n- 🎯 [**rl-training**](rl-training): Reinforcement learning training loop inside a sandbox\n- <img src=\"https://img.shields.io/badge/-%20-D97757?logo=claude&logoColor=white&style=flat-square\" alt=\"Claude\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**claude-code**](claude-code): Call Claude (Anthropic) API/CLI within the sandbox\n- <img src=\"https://geminicli.com/favicon.ico\" alt=\"Google Gemini\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**gemini-cli**](gemini-cli): Call Google Gemini within the sandbox\n- <img src=\"https://developers.openai.com/favicon.png\" alt=\"OpenAI\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**codex-cli**](codex-cli): Call OpenAI/Codex-like models within the sandbox\n- <img src=\"https://www.kimi.com/favicon.ico\" alt=\"Kimi\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**kimi-cli**](kimi-cli): Call Kimi Code CLI (Moonshot AI) within the sandbox\n- <img src=\"https://img.shields.io/badge/-%20-1C3C3C?logo=langgraph&logoColor=white&style=flat-square\" alt=\"LangGraph\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**langgraph**](langgraph): LangGraph agent orchestrating sandbox lifecycle + tools\n- <img src=\"https://google.github.io/adk-docs/assets/agent-development-kit.png\" alt=\"Google ADK\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**google-adk**](google-adk): Google ADK agent calling OpenSandbox tools\n- 🦞 [**nullclaw**](nullclaw): Launch a Nullclaw Gateway inside a sandbox\n- 🦞 [**openclaw**](openclaw): Run an OpenClaw Gateway inside a sandbox\n- 🖥️ [**desktop**](desktop): Launch VNC desktop (Xvfb + x11vnc) for VNC client connections\n- <img src=\"https://playwright.dev/img/playwright-logo.svg\" alt=\"Playwright\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**playwright**](playwright): Launch headless browser (Playwright + Chromium) to scrape web content\n- <img src=\"https://code.visualstudio.com/assets/favicon.ico\" alt=\"VS Code\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**vscode**](vscode): Launch code-server (VS Code Web) to provide browser access\n- <img src=\"https://www.google.com/chrome/static/images/chrome-logo.svg\" alt=\"Google Chrome\" width=\"16\" height=\"16\" style=\"display:inline-block;width:16px;height:16px;vertical-align:middle;margin-right:4px;\" /> [**chrome**](chrome): Launch headless Chromium with DevTools port exposed for remote debugging\n\n## How to Run\n- Set basic environment variables (e.g., `export SANDBOX_DOMAIN=...`, `export SANDBOX_API_KEY=...`)\n- Add provider-specific variables as needed (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `KIMI_API_KEY`, etc.; model selection is optional)\n- Navigate to the example directory and install dependencies: `pip install -r requirements.txt` (or refer to the Dockerfile in the directory)\n- Then execute: `python main.py`\n- To run in a container, build and run using the `Dockerfile` in the directory\n- Summary: First set required environment variables via `export`, then run `python main.py` in the corresponding directory, or build/run the Docker image for that directory.\n"
  },
  {
    "path": "examples/agent-sandbox/README.md",
    "content": "# Agent-Sandbox Example\n\nThis example creates a sandbox backed by `kubernetes-sigs/agent-sandbox` and\nexecutes `echo hello world` via the OpenSandbox Python SDK.\n\n## Prerequisites\n\n- A Kubernetes cluster with the agent-sandbox controller and CRDs installed.\n- OpenSandbox server configured with Kubernetes runtime and `workload_provider = \"agent-sandbox\"`.\n- Sandbox image should include `bash` (default example uses `ubuntu:22.04`).\n\n## Start OpenSandbox server\n\n1. Install the server package and fetch the example config for agent-sandbox:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\n```\n\n2. Update `~/.sandbox.toml` with the following sections:\n\n```toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[kubernetes]\nnamespace = \"default\"\n# kubeconfig_path = \"/absolute/path/to/kubeconfig\"  # optional if running in-cluster\nworkload_provider = \"agent-sandbox\"\n\n[agent_sandbox]\nshutdown_policy = \"Delete\"\n```\n\n3. Start the server:\n\n```shell\nopensandbox-server\n```\n\n## Run the example\n\n```shell\nuv pip install opensandbox\nuv run python examples/agent-sandbox/main.py\n```\n\n## Expected output\n\n```text\ncommand output: hello world\n```\n"
  },
  {
    "path": "examples/agent-sandbox/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\"SANDBOX_IMAGE\", \"ubuntu:22.04\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        timeout=timedelta(minutes=10),\n    )\n\n    async with sandbox:\n        execution = await sandbox.commands.run(\"echo hello world\")\n        stdout = execution.logs.stdout[0].text if execution.logs.stdout else \"\"\n        print(f\"command output: {stdout}\")\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/aio-sandbox/README.md",
    "content": "# All-in-One (AIO) Sandbox Example\n\nThis example demonstrates how to create and access an [All-in-One (AIO) Sandbox](https://github.com/agent-infra/sandbox) via OpenSandbox.\n\n## Start OpenSandbox server [local]\n\nYou can find the latest version [here](https://github.com/agent-infra/sandbox/pkgs/container/sandbox).\n\nYou can pre-pull the target image which is used in the example.\n\n### Notes (Docker runtime requirement)\n\nThe server is configured with `runtime.type = \"docker\"` by default, so it **must** be able to connect to a running Docker daemon.\n\n- **Docker Desktop**: ensure Docker Desktop is running, then verify with `docker version`.\n- **Colima (macOS)**: start it first (`colima start`) and export the socket before starting the server:\n\n```shell\nexport DOCKER_HOST=\"unix://${HOME}/.colima/default/docker.sock\"\n```\n\n\n```shell\n# pre-pull target image\ndocker pull ghcr.io/agent-infra/sandbox:latest\n```\n\nThen, start the OpenSandbox server, you can obtain stdout log from terminal.\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n> Note: `opensandbox-server` runs in the foreground and will keep the current terminal session busy. The example code lives in this repository—clone it and, in a new terminal window/tab, `cd` into the project root before running the AIO sandbox creation steps below.\nIf you see errors like `FileNotFoundError: [Errno 2] No such file or directory` from `docker/transport/unixconn.py`, it usually means the Docker unix socket is missing / Docker daemon is not running.\n\n## Create and Access the AIO Sandbox Instance\n\nThis example uses a fixed configuration for quick start:\n- OpenSandbox server: `http://localhost:8080`\n- Image: `ghcr.io/agent-infra/sandbox:latest`\n- AIO port: `8080`\n- Timeout: `300s`\n\nInstall dependencies with uv under project root:\n```shell\nuv pip install opensandbox agent-sandbox==0.0.18\n```\n\nRun the example (it will create a sandbox via OpenSandbox, wait until it's Running, then connect to it via agent-sandbox):\n```shell\nuv run python examples/aio-sandbox/main.py\n```\n\nSubsequently, you will instantiate an AIO sandbox, navigate to Google, capture a screenshot, and download it to your local environment.\n\n```text\nCreating AIO sandbox with image=ghcr.io/agent-infra/sandbox:latest on OpenSandbox server http://localhost:8080...\n[check] sandbox ready after 7.1s\nAIO portal endpoint: 127.0.0.1:56123\ntotal 52\ndrwxr-x--- 10 gem  gem  4096 Dec 15 13:22 .\ndrwxr-xr-x  1 root root 4096 Dec 15 13:22 ..\n-rw-r--r--  1 gem  gem   220 Jan  7  2022 .bash_logout\n-rw-r--r--  1 gem  gem    27 Dec 15 13:22 .bashrc\ndrwxr-xr-x  5 gem  gem  4096 Dec 15 13:22 .cache\ndrwxrwxr-x  6 gem  gem  4096 Dec 15 13:22 .config\ndrwxr-xr-x  2 gem  gem  4096 Dec 15 13:22 .ipython\ndrwxr-xr-x  4 gem  gem  4096 Dec 15 13:22 .jupyter\ndrwxrwxr-x  4 gem  gem  4096 Dec 15 13:22 .local\ndrwxr-xr-x  3 gem  gem  4096 Dec 15 13:22 .npm\ndrwxrwxr-x  3 gem  gem  4096 Dec 15 13:22 .npm-global\ndrwx------  3 gem  gem  4096 Dec 15 13:22 .pki\n-rw-r--r--  1 gem  gem   807 Jan  7  2022 .profile\n-rw-rw-r--  1 gem  gem     0 Dec 15 13:22 .Xauthority\nexport TERM=xterm-256color\n\nScreenshot saved to sandbox_screenshot.png\n```\n\n## More examples\n\nFor more examples of using the AIO Sandbox, refer to agents-infra/sandbox [examples](https://github.com/agent-infra/sandbox/tree/main/examples).\n\n## References\n- [AIO Sandbox](https://github.com/agent-infra/sandbox/tree/main)\n- [AIO Sandbox Python SDK](https://github.com/agent-infra/sandbox/tree/main/sdk/python)\n"
  },
  {
    "path": "examples/aio-sandbox/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nCreate an AIO sandbox via OpenSandbox SDK, then connect to it with agent-sandbox SDK.\n\nThis example is intentionally hard-coded for simplicity:\n- OpenSandbox server: http://localhost:8080\n- Image: ghcr.io/agent-infra/sandbox:latest\n- AIO port: 8080\n- Timeout: 300s\n\"\"\"\n\nimport time\nfrom datetime import timedelta\n\nimport requests\nfrom agent_sandbox import Sandbox as AioSandboxClient\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\n\n\ndef check_aio_process(sbx: SandboxSync) -> bool:\n    \"\"\"\n    Health check: poll aio process at /v1/shell/sessions until it returns 200.\n\n    Returns:\n        True  when ready\n        False on timeout or any exception\n    \"\"\"\n    try:\n        endpoint = sbx.get_endpoint(8080)\n        start = time.perf_counter()\n        url = f\"http://{endpoint.endpoint}/v1/shell/sessions\"\n        for _ in range(150):  # max for ~30s\n            try:\n                resp = requests.get(url, timeout=1)\n                if resp.status_code == 200:\n                    elapsed = time.perf_counter() - start\n                    print(f\"[check] sandbox ready after {elapsed:.1f}s\")\n                    return True\n            except Exception as exc:\n                # print(f\"[check] aio sandbox check health failed: {exc}\")\n                pass\n            time.sleep(0.2)\n        return False\n    except Exception as exc:\n        print(f\"[check] failed: {exc}\")\n        return False\n\n\ndef main() -> None:\n    server = \"http://localhost:8080\"\n    image = \"ghcr.io/agent-infra/sandbox:latest\"\n    timeout_seconds = 300\n\n    print(f\"Creating AIO sandbox with image={image} on OpenSandbox server {server}...\")\n    sandbox = SandboxSync.create(\n        image=image,\n        timeout=timedelta(seconds=timeout_seconds),\n        metadata={\"example\": \"aio-sandbox\"},\n        entrypoint=[\"/opt/gem/run.sh\"],\n        connection_config=ConnectionConfigSync(domain=server),\n        health_check=check_aio_process,\n    )\n\n    with sandbox:\n        endpoint = sandbox.get_endpoint(8080)\n        print(f\"AIO portal endpoint: {endpoint.endpoint}\")\n\n        client = AioSandboxClient(base_url=f\"http://{endpoint.endpoint}\")\n        home_dir = client.sandbox.get_context().home_dir\n\n        result = client.shell.exec_command(command=\"ls -la\", timeout=10)\n        print(result.data.output)\n\n        content = client.file.read_file(file=f\"{home_dir}/.bashrc\")\n        print(content.data.content)\n\n        screenshot_path = \"sandbox_screenshot.png\"\n        with open(screenshot_path, \"wb\") as f:\n            for chunk in client.browser.screenshot():\n                f.write(chunk)\n        print(f\"Screenshot saved to {screenshot_path}\")\n\n        # kill sandbox finally\n        sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/chrome/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM golang:1.25.4 AS builder\n\nWORKDIR /build\n\nCOPY go.mod go.sum ./\n\nRUN go mod download\n\nCOPY . .\n\nRUN CGO_ENABLED=0 go build -o /build/entrypoint main.go\n\n#----------------------\n# Use a base image with a minimal set of packages.\nFROM debian:13-slim\n\n#----------------------\n# Install prerequisites, chromium, VNC, and X11 utilities.\nRUN set -eux; \\\n    apt-get update; \\\n    apt-get install -y --no-install-recommends \\\n      ca-certificates \\\n      wget \\\n      xdg-utils \\\n      chromium \\\n      tigervnc-standalone-server \\\n      x11-utils; \\\n    rm -rf /var/lib/apt/lists/*\n\n# Create a non-root user to run Chrome.\nRUN groupadd -r chrome && useradd -r -g chrome -G audio,video chrome \\\n    && mkdir -p /home/chrome/Downloads && chown -R chrome:chrome /home/chrome\n\n# Precreate X11 stuff\nRUN mkdir -p /tmp/.X11-unix\nRUN chmod 1777 /tmp/.X11-unix\n\nCOPY --chmod=a+rx chrome.sh /chrome.sh\nCOPY --from=builder --chmod=a+rx /build/entrypoint /entrypoint\n\nWORKDIR /home/chrome\n\nUSER chrome\n\n\nENTRYPOINT [ \"/entrypoint\" ]\n"
  },
  {
    "path": "examples/chrome/README.md",
    "content": "# Chrome Browser in OpenSandbox\n\nThis example runs Chrome Browser with OpenSandbox runtime.\n\nThe image starts a VNC server (`Xtigervnc :1`) and launches Chromium with remote debugging enabled on port `9222`.\n\n## Getting Chrome image\n\nYou can build the image from source or pull it from Docker Hub.\n\n### Build from source\n\n```shell\ndocker build -t opensandbox/chrome .\n```\n\n### Pull an existing image\n\n```shell\ndocker pull opensandbox/chrome:latest\n\n# use acr from china\n# docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/chrome:latest\n```\n\n## Start OpenSandbox server\n\nStart the OpenSandbox server and tail stdout from the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and access a Chrome sandbox\n\nBuild/pull the image above, then create a sandbox with image `opensandbox/chrome:latest` and an entrypoint that keeps it\nalive (e.g., `[\"/bin/sh\", \"-c\", \"sleep infinity\"]`), or reuse `tail -f /dev/null`. Make sure the runtime exposes ports\n`5901` and `9222` for VNC/DevTools.\n\n```shell\nuv pip install opensandbox\nuv run python examples/chrome/main.py\n```\n\nThen fetch endpoints for 5901/9222 to connect with a VNC client or DevTools, like:\n\n```text\nexecd daemon running with endpoint='127.0.0.1:48379/proxy/44772'\nVNC running with endpoint='127.0.0.1:48379/proxy/5901'\nDevTools running with endpoint='127.0.0.1:48379/proxy/9222'/json\n```\n\n```text\n[ {\n   \"description\": \"\",\n   \"devtoolsFrontendUrl\": \"https://chrome-devtools-frontend.appspot.com/serve_rev/@71a0dbd6672e2ccb6d1008376cbb7acd315cb8d6/inspector.html?ws=127.0.0.1:52302/devtools/page/2215AF60AC345E4BA6D822389CFC743B\",\n   \"faviconUrl\": \"https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico\",\n   \"id\": \"2215AF60AC345E4BA6D822389CFC743B\",\n   \"title\": \"Google\",\n   \"type\": \"page\",\n   \"url\": \"https://www.google.com.hk/\",\n   \"webSocketDebuggerUrl\": \"ws://127.0.0.1:52302/devtools/page/2215AF60AC345E4BA6D822389CFC743B\"\n} ]\n```\n\nOr you can use it by MCP client, more information please refer\nto: [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp).\n\n## Reference\n\n- [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp)\n"
  },
  {
    "path": "examples/chrome/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm chrome-builder || true\n\ndocker buildx create --use --name chrome-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\ndocker buildx build \\\n  -t opensandbox/chrome:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/chrome:${TAG} \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "examples/chrome/chrome.sh",
    "content": "#!/bin/bash\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -euo pipefail\n\n# There are a lot of interesting flags we could use here: https://github.com/microsoft/playwright/blob/20023ab33a1dc04db2d5a3f753760eef33339e73/packages/playwright-core/src/server/chromium/chromiumSwitches.ts#L47\nflags=()\n\nflags+=(--no-sandbox) # We can't use sandbox in a container\n\nflags+=(--disable-gpu)           # We don't (normally) have a GPU\nflags+=(--disable-dev-shm-usage) # We don't (normally) have a shared memory filesystem\n\nflags+=(--no-default-browser-check) # Avoids hanging with a \"set chrome as default browser\" dialog\nflags+=(--no-first-run)             # Avoids hanging with a \"set chrome as default browser\" dialog\n\nflags+=(--start-maximized) # We're the only thing running, use the whole screen\n\nflags+=(--disable-field-trial-config) # Keeps things consistent and a little faster (?)\n\nflags+=(--remote-debugging-port=9222)     # Enable remote debugging\nflags+=(--user-data-dir=/tmp/chrome-data) # DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.\n\n# Launch Chrome\nexec chromium \"${flags[@]}\" \"https://www.google.com\"\n"
  },
  {
    "path": "examples/chrome/go.mod",
    "content": "module github.com/alibaba/opensandbox/chrome-box\n\ngo 1.22\n"
  },
  {
    "path": "examples/chrome/go.sum",
    "content": ""
  },
  {
    "path": "examples/chrome/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc main() {\n\tctx := context.Background()\n\tif err := run(ctx); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run(ctx context.Context) error {\n\n\tctx, cancel := context.WithCancel(ctx)\n\tdefer cancel()\n\n\tvnc := &VNCServer{}\n\n\terrs := make(chan error, 10)\n\n\tgo func() {\n\t\tif err := vnc.Run(ctx); err != nil {\n\t\t\tlog.Println(err, \"VNC server exited with error\")\n\t\t\terrs <- fmt.Errorf(\"VNC server exited with error: %w\", err)\n\t\t\tcancel()\n\t\t}\n\t}()\n\n\tif err := vnc.WaitForReady(ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to wait for VNC server: %w\", err)\n\t}\n\n\tchrome := &Chrome{}\n\tgo func() {\n\t\tif err := chrome.Run(ctx); err != nil {\n\t\t\tlog.Println(err, \"Chrome exited with error\")\n\t\t\terrs <- fmt.Errorf(\"Chrome exited with error: %w\", err)\n\t\t\tcancel()\n\t\t}\n\t}()\n\n\tif err := chrome.WaitForReady(ctx); err != nil {\n\t\treturn fmt.Errorf(\"failed to wait for Chrome: %w\", err)\n\t}\n\tlog.Println(\"Chrome and VNC server are running\")\n\n\t<-ctx.Done()\n\terrs <- ctx.Err()\n\n\t// Return the first error (or nil))\n\treturn <-errs\n}\n\ntype Chrome struct {\n}\n\nfunc (c *Chrome) Run(ctx context.Context) error {\n\tcmd := exec.CommandContext(ctx, \"/chrome.sh\")\n\tcmd.Stdout = os.Stdout\n\tcmd.Stderr = os.Stderr\n\n\tvar env []string\n\tfor _, e := range os.Environ() {\n\t\tif strings.HasPrefix(e, \"DISPLAY=\") {\n\t\t\tcontinue\n\t\t}\n\t\tenv = append(env, e)\n\t}\n\tenv = append(env, \"DISPLAY=:1\")\n\tcmd.Env = env\n\n\treturn cmd.Run()\n}\n\nfunc (c *Chrome) WaitForReady(ctx context.Context) error {\n\tu := \"http://localhost:9222/json/version\"\n\n\thttpClient := &http.Client{}\n\thttpClient.Timeout = 200 * time.Millisecond\n\n\tfor {\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(ctx, \"GET\", u, nil)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create HTTP request: %w\", err)\n\t\t}\n\n\t\t// Send the HTTP request\n\t\tresponse, err := httpClient.Do(req)\n\t\tif err != nil {\n\t\t\tlog.Println(\"Waiting for Chrome to be ready\", \"url\", u, \"info\", err)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\t\tdefer response.Body.Close()\n\n\t\t// Check for HTTP 200 OK\n\t\tif response.StatusCode != http.StatusOK {\n\t\t\tlog.Println(\"Waiting for Chrome to be ready\", \"url\", u, \"status\", response.Status)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\n\t\tb, err := io.ReadAll(response.Body)\n\t\tif err != nil {\n\t\t\tlog.Println(\"Waiting for Chrome to be ready\", \"url\", u, \"info\", err)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Println(\"Chrome is ready\", \"url\", u, \"response\", string(b))\n\t\tbreak\n\t}\n\treturn nil\n}\n\ntype VNCServer struct {\n}\n\nfunc (v *VNCServer) Run(ctx context.Context) error {\n\tcmd := exec.CommandContext(ctx, \"Xtigervnc\", \":1\", \"-geometry\", \"1280x1024\")\n\tcmd.Stdout = os.Stdout\n\tcmd.Stderr = os.Stderr\n\n\tlog.Println(\"Starting VNC server\", \"command\", cmd.String())\n\tif err := cmd.Start(); err != nil {\n\t\treturn fmt.Errorf(\"failed to start VNC server: %w\", err)\n\t}\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tif err := cmd.Process.Kill(); err != nil {\n\t\t\tlog.Println(err, \"failed to kill VNC server\")\n\t\t}\n\t}()\n\n\tif err := cmd.Wait(); err != nil {\n\t\treturn fmt.Errorf(\"VNC server exited with error: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (v *VNCServer) WaitForReady(ctx context.Context) error {\n\tfor {\n\t\tif ctx.Err() != nil {\n\t\t\treturn ctx.Err()\n\t\t}\n\n\t\tcmd := exec.CommandContext(ctx, \"xdpyinfo\", \"-display\", \":1\")\n\t\tvar stdout bytes.Buffer\n\t\tcmd.Stdout = &stdout\n\t\tvar stderr bytes.Buffer\n\t\tcmd.Stderr = &stderr\n\t\tif err := cmd.Run(); err != nil {\n\t\t\tlog.Println(\"Waiting for VNC server to be ready\", \"info\", err)\n\t\t\ttime.Sleep(100 * time.Millisecond)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Println(\"VNC is ready\")\n\t\tbreak\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "examples/chrome/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nfrom datetime import timedelta\n\nfrom opensandbox.sandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import SandboxException\n\nasync def main():\n    try:\n        sandbox = await Sandbox.create(\n            image=\"opensandbox/chrome:latest\",\n            timeout=timedelta(minutes=5),\n            entrypoint=[\"/entrypoint\"],\n            metadata={\"examples.opensandbox.io\": \"chrome\"},\n            connection_config=ConnectionConfig(\n                domain=\"localhost:8080\"\n            )\n        )\n\n        # Got execd process endpoint\n        execd = await sandbox.get_endpoint(44772)\n        print(f\"execd daemon running with {execd.endpoint}\")\n\n        vnc = await sandbox.get_endpoint(5901)\n        print(f\"VNC running with {vnc.endpoint}\")\n\n        devtools = await sandbox.get_endpoint(9222)\n        print(f\"DevTools running with {devtools.endpoint}/json\")\n\n    except SandboxException as e:\n        # Handle Sandbox specific exceptions\n        print(f\"Sandbox Error: [{e.error.code}] {e.error.message}\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/claude-code/README.md",
    "content": "# Claude Code Example\n\nAccess Claude via the `claude-cli` npm package in OpenSandbox.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Node.js):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nThen start the local OpenSandbox server, stdout logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Claude Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / ANTHROPIC_AUTH_TOKEN)\nuv run python examples/claude-code/main.py\n```\n\nThe script installs the Claude CLI (`npm i -g @anthropic-ai/claude-code@latest`) at runtime (Node.js is already in the code-interpreter image), then sends a simple request `claude \"Compute 1+1=?.\"`. Auth is passed via `ANTHROPIC_AUTH_TOKEN`, and you can override endpoint/model with `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL`.\n\n![Claude Code screenshot](./screenshot.jpg)\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `ANTHROPIC_AUTH_TOKEN`: Your Anthropic auth token (required)\n- `ANTHROPIC_BASE_URL`: Anthropic API endpoint (optional; e.g., self-hosted proxy)\n- `ANTHROPIC_MODEL`: Model name (default: `claude_sonnet4`)\n\n## References\n- [claude-code](https://www.npmjs.com/package/claude-code) - NPM package for Claude Code CLI\n"
  },
  {
    "path": "examples/claude-code/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_execution_logs(execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    claude_auth_token = _required_env(\"ANTHROPIC_AUTH_TOKEN\")\n    claude_base_url = os.getenv(\"ANTHROPIC_BASE_URL\")\n    claude_model_name = os.getenv(\"ANTHROPIC_MODEL\", \"claude_sonnet4\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject Claude settings into container environment for CLI access\n    env = {\n        \"ANTHROPIC_AUTH_TOKEN\": claude_auth_token,\n        \"ANTHROPIC_BASE_URL\": claude_base_url,\n        \"ANTHROPIC_MODEL\": claude_model_name,\n        \"IS_SANDBOX\": \"1\",\n    }\n    # Drop None values to avoid overriding defaults inside CLI\n    env = {k: v for k, v in env.items() if v is not None}\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # Install Claude CLI (Node.js is already in the code-interpreter image)\n        install_exec = await sandbox.commands.run(\n            \"npm i -g @anthropic-ai/claude-code@latest\"\n        )\n        await _print_execution_logs(install_exec)\n\n        # Use Claude CLI to send a message\n        run_exec = await sandbox.commands.run(\n            'claude \"Compute 1+1=?.\"'\n        )\n        await _print_execution_logs(run_exec)\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/code-interpreter/README.md",
    "content": "# Code Interpreter Sandbox\n\nComplete demonstration of running Python code using the Code Interpreter SDK.\n\n## Getting Code Interpreter image\n\nPull the prebuilt image from a registry:\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\n## Start OpenSandbox server [local]\n\nStart the local OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and access the Code Interpreter Sandbox\n\n```shell\n# Install OpenSandbox packages\nuv pip install opensandbox opensandbox-code-interpreter\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY)\nuv run python examples/code-interpreter/main.py\n```\n\nThe script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance.\n\n## Environment variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n\n## Example output\n\n```text\n=== Python example ===\n[Python stdout] Hello from Python!\n\n[Python result] {'py': '3.14.2', 'sum': 4}\n\n=== Java example ===\n[Java stdout] Hello from Java!\n\n[Java stdout] 2 + 3 = 5\n\n[Java result] 5\n\n=== Go example ===\n[Go stdout] Hello from Go!\n3 + 4 = 7\n\n\n=== TypeScript example ===\n[TypeScript stdout] Hello from TypeScript!\n\n[TypeScript stdout] sum = 6\n```\n\n# Code Interpreter Sandbox from pool\n\n## Start OpenSandbox server [k8s]\n\nInstall the k8s OpenSandbox operator, and create a pool:\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  labels:\n    app.kubernetes.io/name: sandbox-k8s\n    app.kubernetes.io/managed-by: kustomize\n  name: pool-sample\n  namespace: opensandbox\nspec:\n  template:\n    metadata:\n      labels:\n        app: example\n    spec:\n      volumes:\n        - name: sandbox-storage\n          emptyDir: { }\n        - name: opensandbox-bin\n          emptyDir: { }\n      initContainers:\n        - name: task-executor-installer\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:v0.1.0\n          command: [ \"/bin/sh\", \"-c\" ]\n          args:\n            - |\n              cp /workspace/server /opt/opensandbox/bin/task-executor && \n              chmod +x /opt/opensandbox/bin/task-executor\n          volumeMounts:\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n        - name: execd-installer\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\n          command: [ \"/bin/sh\", \"-c\" ]\n          args:\n            - |\n              cp ./execd /opt/opensandbox/bin/execd && \n              cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh &&\n              chmod +x /opt/opensandbox/bin/execd &&\n              chmod +x /opt/opensandbox/bin/bootstrap.sh\n          volumeMounts:\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n      containers:\n        - name: sandbox\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n          command:\n          - \"/bin/sh\"\n          - \"-c\"\n          - |\n            /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1\n          env:\n          - name: SANDBOX_MAIN_CONTAINER\n            value: main\n          - name: EXECD_ENVS\n            value: /opt/opensandbox/.env\n          - name: EXECD\n            value: /opt/opensandbox/bin/execd\n          volumeMounts:\n            - name: sandbox-storage\n              mountPath: /var/lib/sandbox\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n      tolerations:\n        - operator: \"Exists\"\n  capacitySpec:\n    bufferMax: 3\n    bufferMin: 1\n    poolMax: 5\n    poolMin: 0\n```\n\nStart the k8s OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\n\n# replace with your k8s cluster config, kubeconfig etc.\nopensandbox-server init-config ~/.sandbox.toml --example k8s\ncurl -o ~/batchsandbox-template.yaml https://raw.githubusercontent.com/alibaba/OpenSandbox/main/server/example.batchsandbox-template.yaml\n\nopensandbox-server\n```\n\n## Create and access the Code Interpreter Sandbox\n\n```shell\n# Install OpenSandbox packages\nuv pip install opensandbox opensandbox-code-interpreter\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY)\nuv run python examples/code-interpreter/main_use_pool.py\n```\n\nThe script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance.\n\n## Environment variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n\n## Example output\n\n```text\n=== Verify Environment Variable ===\n[ENV Check] TEST_ENV value: test\n\n[ENV Result] 'test'\n\n=== Java example ===\n[Java stdout] Hello from Java!\n\n[Java stdout] 2 + 3 = 5\n\n[Java result] 5\n\n=== Go example ===\n[Go stdout] Hello from Go!\n3 + 4 = 7\n\n\n=== TypeScript example ===\n[TypeScript stdout] Hello from TypeScript!\n\n[TypeScript stdout] sum = 6\n```\n"
  },
  {
    "path": "examples/code-interpreter/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"]\n    )\n\n    async with sandbox:\n        interpreter = await CodeInterpreter.create(sandbox=sandbox)\n\n        # Python example: show runtime info and return a simple calculation.\n        py_exec = await interpreter.codes.run(\n            \"import platform\\n\"\n            \"print('Hello from Python!')\\n\"\n            \"result = {'py': platform.python_version(), 'sum': 2 + 2}\\n\"\n            \"result\",\n            language=SupportedLanguage.PYTHON,\n        )\n        print(\"\\n=== Python example ===\")\n        for msg in py_exec.logs.stdout:\n            print(f\"[Python stdout] {msg.text}\")\n        if py_exec.result:\n            for res in py_exec.result:\n                print(f\"[Python result] {res.text}\")\n\n        # Java example: print to stdout and return the final result line.\n        java_exec = await interpreter.codes.run(\n            \"System.out.println(\\\"Hello from Java!\\\");\\n\"\n            \"int result = 2 + 3;\\n\"\n            \"System.out.println(\\\"2 + 3 = \\\" + result);\\n\"\n            \"result\",\n            language=SupportedLanguage.JAVA,\n        )\n        print(\"\\n=== Java example ===\")\n        for msg in java_exec.logs.stdout:\n            print(f\"[Java stdout] {msg.text}\")\n        if java_exec.result:\n            for res in java_exec.result:\n                print(f\"[Java result] {res.text}\")\n        if java_exec.error:\n            print(f\"[Java error] {java_exec.error.name}: {java_exec.error.value}\")\n\n        # Go example: print logs and demonstrate a main function structure.\n        go_exec = await interpreter.codes.run(\n            \"package main\\n\"\n            \"import \\\"fmt\\\"\\n\"\n            \"func main() {\\n\"\n            \"    fmt.Println(\\\"Hello from Go!\\\")\\n\"\n            \"    sum := 3 + 4\\n\"\n            \"    fmt.Println(\\\"3 + 4 =\\\", sum)\\n\"\n            \"}\",\n            language=SupportedLanguage.GO,\n        )\n        print(\"\\n=== Go example ===\")\n        for msg in go_exec.logs.stdout:\n            print(f\"[Go stdout] {msg.text}\")\n        if go_exec.error:\n            print(f\"[Go error] {go_exec.error.name}: {go_exec.error.value}\")\n\n        # TypeScript example: use typing and sum an array.\n        ts_exec = await interpreter.codes.run(\n            \"console.log('Hello from TypeScript!');\\n\"\n            \"const nums: number[] = [1, 2, 3];\\n\"\n            \"console.log('sum =', nums.reduce((a, b) => a + b, 0));\",\n            language=SupportedLanguage.TYPESCRIPT,\n        )\n        print(\"\\n=== TypeScript example ===\")\n        for msg in ts_exec.logs.stdout:\n            print(f\"[TypeScript stdout] {msg.text}\")\n        if ts_exec.error:\n            print(f\"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}\")\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/code-interpreter/main_use_pool.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        extensions={\"poolRef\":\"pool-sample\"},\n        entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n        env={\n            \"TEST_ENV\": \"test\",\n        },\n    )\n\n    async with sandbox:\n        interpreter = await CodeInterpreter.create(sandbox=sandbox)\n\n        # Verify environment variable is set\n        print(\"\\n=== Verify Environment Variable ===\")\n        env_check = await interpreter.codes.run(\n            \"import os\\n\"\n            \"test_env = os.getenv('TEST_ENV', 'NOT_SET')\\n\"\n            \"print(f'TEST_ENV value: {test_env}')\\n\"\n            \"test_env\",\n            language=SupportedLanguage.PYTHON,\n        )\n        for msg in env_check.logs.stdout:\n            print(f\"[ENV Check] {msg.text}\")\n        if env_check.result:\n            for res in env_check.result:\n                print(f\"[ENV Result] {res.text}\")\n\n        # Java example: print to stdout and return the final result line.\n        java_exec = await interpreter.codes.run(\n            \"System.out.println(\\\"Hello from Java!\\\");\\n\"\n            \"int result = 2 + 3;\\n\"\n            \"System.out.println(\\\"2 + 3 = \\\" + result);\\n\"\n            \"result\",\n            language=SupportedLanguage.JAVA,\n        )\n        print(\"\\n=== Java example ===\")\n        for msg in java_exec.logs.stdout:\n            print(f\"[Java stdout] {msg.text}\")\n        if java_exec.result:\n            for res in java_exec.result:\n                print(f\"[Java result] {res.text}\")\n        if java_exec.error:\n            print(f\"[Java error] {java_exec.error.name}: {java_exec.error.value}\")\n\n        # Go example: print logs and demonstrate a main function structure.\n        go_exec = await interpreter.codes.run(\n            \"package main\\n\"\n            \"import \\\"fmt\\\"\\n\"\n            \"func main() {\\n\"\n            \"    fmt.Println(\\\"Hello from Go!\\\")\\n\"\n            \"    sum := 3 + 4\\n\"\n            \"    fmt.Println(\\\"3 + 4 =\\\", sum)\\n\"\n            \"}\",\n            language=SupportedLanguage.GO,\n        )\n        print(\"\\n=== Go example ===\")\n        for msg in go_exec.logs.stdout:\n            print(f\"[Go stdout] {msg.text}\")\n        if go_exec.error:\n            print(f\"[Go error] {go_exec.error.name}: {go_exec.error.value}\")\n\n        # TypeScript example: use typing and sum an array.\n        ts_exec = await interpreter.codes.run(\n            \"console.log('Hello from TypeScript!');\\n\"\n            \"const nums: number[] = [1, 2, 3];\\n\"\n            \"console.log('sum =', nums.reduce((a, b) => a + b, 0));\",\n            language=SupportedLanguage.TYPESCRIPT,\n        )\n        print(\"\\n=== TypeScript example ===\")\n        for msg in ts_exec.logs.stdout:\n            print(f\"[TypeScript stdout] {msg.text}\")\n        if ts_exec.error:\n            print(f\"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}\")\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/codex-cli/README.md",
    "content": "# Codex/OpenAI CLI Example\n\nUse the official `@openai/codex` npm package to call OpenAI/Codex-like models in OpenSandbox.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Node.js):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nStart the local OpenSandbox server, logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Codex Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / OPENAI_API_KEY)\nuv run python examples/codex-cli/main.py\n```\n\nThe script installs the Codex CLI (`npm install -g @openai/codex@latest`) at runtime (Node.js is already in the code-interpreter image), then executes a simple request `codex exec \"Compute 1+1 and return JSON with keys result and reasoning.\" --skip-git-repo-check`. Auth is passed via `OPENAI_API_KEY`; you can override endpoint/model with `OPENAI_BASE_URL` / `OPENAI_MODEL`.\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `OPENAI_API_KEY`: Your OpenAI API key (required)\n- `OPENAI_BASE_URL`: OpenAI API endpoint (default: `https://api.openai.com/v1`)\n- `OPENAI_MODEL`: Model to use (default: `gpt-4o-mini`)\n\n## References\n- [@openai/codex](https://www.npmjs.com/package/@openai/codex) - Official OpenAI Codex CLI\n"
  },
  {
    "path": "examples/codex-cli/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_execution_logs(execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    openai_api_key = _required_env(\"OPENAI_API_KEY\")\n    openai_base_url = os.getenv(\"OPENAI_BASE_URL\", \"https://api.openai.com/v1\")\n    openai_model = os.getenv(\"OPENAI_MODEL\", \"gpt-4o-mini\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject OpenAI settings into container environment for CLI access\n    env = {\n        \"OPENAI_API_KEY\": openai_api_key,\n        \"OPENAI_BASE_URL\": openai_base_url,\n        \"OPENAI_MODEL\": openai_model,\n    }\n    # Drop None values to avoid overriding defaults inside CLI\n    env = {k: v for k, v in env.items() if v is not None}\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # Install Codex CLI (Node.js is already in the code-interpreter image)\n        install_exec = await sandbox.commands.run(\n            \"npm install -g @openai/codex@latest\"\n        )\n        await _print_execution_logs(install_exec)\n\n        # Use Codex CLI to execute a command\n        run_exec = await sandbox.commands.run(\n            'codex exec \"Compute 1+1=?.\" --skip-git-repo-check'\n        )\n        await _print_execution_logs(run_exec)\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/desktop/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM ubuntu:22.04\n\n# Default to English; install locale support first, then other deps\nENV DEBIAN_FRONTEND=noninteractive \\\n    LANG=en_US.UTF-8 \\\n    LC_ALL=en_US.UTF-8 \\\n    LANGUAGE=en_US:en\n\n#----------------------\n# Install locales first, then remaining dependencies\nRUN apt-get update \\\n    && apt-get install -y locales \\\n    && locale-gen en_US.UTF-8 \\\n    && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 LANGUAGE=en_US:en \\\n    && apt-get install -y \\\n        python3 \\\n        python3-pip \\\n        python3-websockify \\\n        xvfb \\\n        x11vnc \\\n        xfce4 \\\n        xfce4-terminal \\\n        dbus-x11 \\\n        xterm \\\n        novnc \\\n        fonts-dejavu-core \\\n        net-tools \\\n        ca-certificates \\\n        --no-install-recommends \\\n    && sed -i 's/DEFAULT_LOCALE = null;/DEFAULT_LOCALE = \"en\";/' /usr/share/novnc/app/localization.js \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Precreate X11 stuff\nRUN mkdir -p /tmp/.X11-unix\nRUN chmod 1777 /tmp/.X11-unix\n\n#----------------------\n# Create a non-root user\nRUN groupadd -r desktop && useradd -r -g desktop -G audio,video desktop \\\n    && mkdir -p /home/desktop && chown -R desktop:desktop /home/desktop\n\n#----------------------\n# Configure user, etc\n\nWORKDIR /home/desktop\n\nUSER desktop\n\n# Default to bash\nCMD [\"bash\"]\n"
  },
  {
    "path": "examples/desktop/README.md",
    "content": "# Desktop(VNC) Example\n\nLaunch Xvfb + x11vnc + fluxbox in OpenSandbox to provide a VNC-accessible desktop environment.\n\n## Build the Desktop Sandbox Image\n\nThe Dockerfile in this directory builds a sandbox image with desktop and VNC components pre-installed:\n\n```shell\ncd examples/desktop\ndocker build -t opensandbox/desktop:latest .\n```\n\nThis image includes:\n- Xvfb (virtual framebuffer X server)\n- x11vnc (VNC server)\n- XFCE desktop (panel, file manager, terminal)\n- Non-root user (desktop) for security\n\n## Start OpenSandbox server [local]\n\nPre-pull the desktop image:\n\n```shell\ndocker pull opensandbox/desktop:latest\n```\n\nStart the local OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Desktop Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\nuv run python examples/desktop/main.py\n```\n\nThe script starts the desktop stack (Xvfb + XFCE + x11vnc) and also launches noVNC/websockify. It prints:\n- VNC endpoint (`endpoint.endpoint`) for native VNC clients, password from `VNC_PASSWORD` (default: `opensandbox`)\n- noVNC URL for browsers (`/vnc.html?host=...&port=...&path=...`)\n\nThe sandbox stays alive for 5 minutes by default; interrupt sooner with Ctrl+C. Uses the prebuilt desktop image by default.\n\n![Desktop shell](./screenshot_shell.jpg)\n![noVNC connect](./screenshot_connect.jpg)\n![noVNC password](./screenshot_password.jpg)\n![Desktop UI](./screenshot_desktop.jpg)\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `opensandbox/desktop:latest`)\n- `VNC_PASSWORD`: Password for VNC access (default: `opensandbox`)\n\n## References\n\n- [noVNC](https://github.com/novnc/noVNC)\n- [x11vnc](https://github.com/LibVNC/x11vnc)\n"
  },
  {
    "path": "examples/desktop/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm desktop-builder || true\n\ndocker buildx create --use --name desktop-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\ndocker buildx build \\\n  -t opensandbox/desktop:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/desktop:${TAG} \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "examples/desktop/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.execd import RunCommandOpts\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_logs(label: str, execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[{label} stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[{label} stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[{label} error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"opensandbox/desktop:latest\",\n    )\n    python_version = os.getenv(\"PYTHON_VERSION\", \"3.11\")\n    vnc_password = os.getenv(\"VNC_PASSWORD\", \"opensandbox\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env={\n            \"PYTHON_VERSION\": python_version,\n            \"VNC_PASSWORD\": vnc_password,\n        },\n    )\n\n    async with sandbox:\n        # Desktop and VNC components are pre-installed in the image, just start them\n        # Start virtual display, window manager, and VNC server (in background)\n        xvfb_exec = await sandbox.commands.run(\n            \"Xvfb :0 -screen 0 1280x800x24\",\n            opts=RunCommandOpts(background=True),\n        )\n        await _print_logs(\"xvfb\", xvfb_exec)\n\n        # Start XFCE session (provides panel, file manager, terminal)\n        xfce_exec = await sandbox.commands.run(\n            \"DISPLAY=:0 dbus-launch startxfce4\",\n            opts=RunCommandOpts(background=True),\n        )\n        await _print_logs(\"xfce\", xfce_exec)\n\n        vnc_exec = await sandbox.commands.run(\n            \"x11vnc -display :0 \"\n            \"-passwd \\\"$VNC_PASSWORD\\\" \"\n            \"-forever -shared -rfbport 5900\",\n            opts=RunCommandOpts(background=True),\n        )\n        await _print_logs(\"x11vnc\", vnc_exec)\n\n        # Start noVNC/websockify to expose VNC over WebSocket/HTTP\n        novnc_exec = await sandbox.commands.run(\n            \"/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900\",\n            opts=RunCommandOpts(background=True),\n        )\n        await _print_logs(\"novnc\", novnc_exec)\n\n        endpoint_vnc = await sandbox.get_endpoint(5900)\n        endpoint_novnc = await sandbox.get_endpoint(6080)\n\n        # Build noVNC URL with host/port/path for routed endpoint, e.g., host:port/proxy/6080\n        novnc_host_port, novnc_path = endpoint_novnc.endpoint.split(\"/\", 1)\n        novnc_host, novnc_port = novnc_host_port.split(\":\")\n        novnc_url = (\n            f\"http://{endpoint_novnc.endpoint}/vnc.html\"\n            f\"?host={novnc_host}&port={novnc_port}&path={novnc_path}\"\n        )\n\n        print(\"\\nVNC endpoint (native clients):\")\n        print(f\"  {endpoint_vnc.endpoint}\")\n        print(f\"Password: {vnc_password}\")\n\n        print(\"\\nnoVNC (browser):\")\n        print(f\"  {novnc_url}\")\n        print(f\"Password: {vnc_password}\")\n\n        print(\"\\nKeeping sandbox alive for 5 minutes. Press Ctrl+C to exit sooner.\")\n        try:\n            await asyncio.sleep(300)\n        except KeyboardInterrupt:\n            print(\"Stopping...\")\n        finally:\n            await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/docker-ossfs-volume-mount/README.md",
    "content": "# Docker OSSFS Volume Mount Example\n\nThis example demonstrates how to use the new SDK `ossfs` volume model to mount Alibaba Cloud OSS into sandboxes on Docker runtime.\n\n## What this example covers\n\n1. **Basic read-write mount** on an OSSFS backend.\n2. **Cross-sandbox sharing** on the same OSSFS backend path.\n3. **Two mounts, different OSS prefixes via `subPath`**.\n\n## Prerequisites\n\n### 1) Start OpenSandbox server (Docker runtime)\n\nMake sure your server host has:\n\n- Linux host OS (OSSFS backend is not supported when OpenSandbox Server runs on Windows)\n- `ossfs` installed\n- FUSE support enabled\n- writable local mount root for OSSFS (default `storage.ossfs_mount_root=/mnt/ossfs`)\n\n`storage.ossfs_mount_root` is **optional** if you use the default `/mnt/ossfs`.\nEven with on-demand mounting, the runtime still needs a deterministic host-side\nbase directory to place dynamic mounts (`<mount_root>/<bucket>/<subPath?>`).\n\nOptional config example:\n\n```toml\n[runtime]\ntype = \"docker\"\n\n[storage]\nossfs_mount_root = \"/mnt/ossfs\"\n```\n\nThen start the server:\n\n```bash\nopensandbox-server\n```\n\n### 2) Install Python SDK\n\n```bash\nuv pip install opensandbox\n```\n\nIf your PyPI version does not include OSSFS volume models yet, install from source:\n\n```bash\npip install -e sdks/sandbox/python\n```\n\n### 3) Prepare OSS credentials and target path\n\n```bash\nexport SANDBOX_DOMAIN=localhost:8080\nexport SANDBOX_API_KEY=your-api-key\nexport SANDBOX_IMAGE=ubuntu\n\nexport OSS_BUCKET=your-bucket\nexport OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com\nexport OSS_ACCESS_KEY_ID=your-ak\nexport OSS_ACCESS_KEY_SECRET=your-sk\n```\n\n## Run\n\n```bash\nuv run python examples/docker-ossfs-volume-mount/main.py\n```\n\n## Minimal SDK usage snippet\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import OSSFS, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"your-bucket\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                # version=\"2.0\",   # optional, default is \"2.0\"\n                accessKeyId=\"your-ak\",\n                accessKeySecret=\"your-sk\",\n            ),\n            mountPath=\"/mnt/data\",\n            subPath=\"train\",      # optional\n            readOnly=False,       # optional\n        )\n    ],\n)\n```\n\n## Notes\n\n- Current implementation supports **inline credentials only** (`accessKeyId`/`accessKeySecret`).\n- Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets.\n- `ossfs.version` exists in API/SDK with enum `\"1.0\" | \"2.0\"`, and defaults to `\"2.0\"` when omitted.\n- Docker runtime now applies **version-specific mount argument encoding**:\n  - `1.0`: mounts via `ossfs ... -o <option>`.\n  - `2.0`: mounts via `ossfs2 mount ... -c <config-file>` where options are written as `--<option>` config lines.\n- `options` values must be **raw payloads** without leading `-` (for example: `allow_other`, `umask=0022`).\n\n## References\n\n- [OSEP-0003: Volume and VolumeBinding Support](../../oseps/0003-volume-and-volumebinding-support.md)\n- [Sandbox Lifecycle API Spec](../../specs/sandbox-lifecycle.yml)\n"
  },
  {
    "path": "examples/docker-ossfs-volume-mount/README_zh.md",
    "content": "# Docker OSSFS 挂载示例\n\n本示例演示如何使用新版 SDK 的 `ossfs` volume 模型，在 Docker 运行时将阿里云 OSS 挂载到沙箱容器。\n\n## 覆盖场景\n\n1. **基础读写挂载**（OSSFS backend）。\n2. **跨沙箱共享数据**（同一 OSSFS backend path）。\n3. **通过 `subPath` 挂载不同 OSS prefix**。\n\n## 前置条件\n\n### 1) 启动 OpenSandbox 服务（Docker runtime）\n\n请确保服务端主机满足：\n\n- Linux 主机系统（OpenSandbox Server 运行在 Windows 时不支持 OSSFS backend）\n- 已安装 `ossfs`\n- 已启用 FUSE\n- 已有可写的 OSSFS 本地挂载根目录（默认 `storage.ossfs_mount_root=/mnt/ossfs`）\n\n`storage.ossfs_mount_root` 是**可选配置**（使用默认值时可不写）。\n即使是按需动态挂载，运行时仍需要一个确定的宿主机根目录来放置挂载点：\n`<mount_root>/<bucket>/<subPath?>`。\n\n可选配置示例：\n\n```toml\n[runtime]\ntype = \"docker\"\n\n[storage]\nossfs_mount_root = \"/mnt/ossfs\"\n```\n\n启动服务：\n\n```bash\nopensandbox-server\n```\n\n### 2) 安装 Python SDK\n\n```bash\nuv pip install opensandbox\n```\n\n如果当前 PyPI 版本还不包含 OSSFS 相关模型，可从源码安装：\n\n```bash\npip install -e sdks/sandbox/python\n```\n\n### 3) 配置 OSS 参数\n\n```bash\nexport SANDBOX_DOMAIN=localhost:8080\nexport SANDBOX_API_KEY=your-api-key\nexport SANDBOX_IMAGE=ubuntu\n\nexport OSS_BUCKET=your-bucket\nexport OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com\nexport OSS_ACCESS_KEY_ID=your-ak\nexport OSS_ACCESS_KEY_SECRET=your-sk\n```\n\n## 运行\n\n```bash\nuv run python examples/docker-ossfs-volume-mount/main.py\n```\n\n## SDK 最小示例\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import OSSFS, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"your-bucket\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                # version=\"2.0\",   # 可选，默认 \"2.0\"\n                accessKeyId=\"your-ak\",\n                accessKeySecret=\"your-sk\",\n            ),\n            mountPath=\"/mnt/data\",\n            subPath=\"train\",      # 可选\n            readOnly=False,       # 可选\n        )\n    ],\n)\n```\n\n## 说明\n\n- 当前实现仅支持**内联凭据**（`accessKeyId` / `accessKeySecret`）。\n- Docker 运行时采用**按需挂载**（mount-or-reuse），不是预挂载所有 bucket。\n- API/SDK 中 `ossfs.version` 字段存在，枚举为 `\"1.0\"` / `\"2.0\"`，省略时默认 `\"2.0\"`。\n- Docker 运行时已按 `version` 区分挂载参数编码：\n  - `1.0`：通过 `ossfs ... -o <option>` 挂载。\n  - `2.0`：通过 `ossfs2 mount ... -c <config-file>` 挂载，`options` 以 `--<option>` 配置项写入配置文件。\n- `options` 必须是**不带前缀 `-` 的原始参数值**（例如：`allow_other`、`umask=0022`）。\n\n## 参考\n\n- [OSEP-0003: Volume 与 VolumeBinding 支持](../../oseps/0003-volume-and-volumebinding-support.md)\n- [Sandbox Lifecycle API 规范](../../specs/sandbox-lifecycle.yml)\n"
  },
  {
    "path": "examples/docker-ossfs-volume-mount/main.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nDocker OSSFS Volume Mount Example\n=================================\n\nDemonstrates how to create OSSFS volumes with the new SDK model and mount them\ninto sandboxes on Docker runtime.\n\nScenarios:\n1) Basic read-write mount on OSSFS backend.\n2) Cross-sandbox data sharing on same OSSFS backend path.\n3) Two volumes use different OSS prefixes via subPath.\n\"\"\"\n\nimport asyncio\nimport os\nfrom datetime import timedelta\nfrom uuid import uuid4\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\ntry:\n    from opensandbox.models.sandboxes import OSSFS, Volume\nexcept ImportError:\n    print(\n        \"ERROR: Your installed opensandbox SDK does not include OSSFS/Volume models.\\n\"\n        \"       Please install the latest SDK from source:\\n\"\n        \"\\n\"\n        \"           pip install -e sdks/sandbox/python\\n\"\n    )\n    raise SystemExit(1)\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name, \"\").strip()\n    if not value:\n        raise RuntimeError(f\"Missing required environment variable: {name}\")\n    return value\n\n\ndef build_ossfs() -> OSSFS:\n    return OSSFS(\n        bucket=_required_env(\"OSS_BUCKET\"),\n        endpoint=_required_env(\"OSS_ENDPOINT\"),\n        accessKeyId=_required_env(\"OSS_ACCESS_KEY_ID\"),\n        accessKeySecret=_required_env(\"OSS_ACCESS_KEY_SECRET\"),\n    )\n\n\nasync def print_exec(sandbox: Sandbox, command: str) -> str:\n    result = await sandbox.commands.run(command)\n    stdout = \"\\n\".join(msg.text for msg in result.logs.stdout).strip()\n    stderr = \"\\n\".join(msg.text for msg in result.logs.stderr).strip()\n    if stdout:\n        print(stdout)\n    if stderr:\n        print(stderr)\n    if result.error:\n        raise RuntimeError(f\"Command failed: {result.error.name}: {result.error.value}\")\n    return stdout\n\n\nasync def demo_basic_mount(config: ConnectionConfig, image: str, run_id: str) -> None:\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 1: Basic OSSFS Read-Write Mount\")\n    print(\"=\" * 60)\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=3),\n        volumes=[\n            Volume(\n                name=\"oss-root\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/oss\",\n                readOnly=False,\n            )\n        ],\n    )\n    async with sandbox:\n        try:\n            await print_exec(sandbox, \"mkdir -p /mnt/oss/opensandbox-demo\")\n            await print_exec(\n                sandbox,\n                f\"echo 'hello-{run_id}' > /mnt/oss/opensandbox-demo/basic.txt\",\n            )\n            print(\"[verify] read file from mounted OSSFS path:\")\n            await print_exec(sandbox, \"cat /mnt/oss/opensandbox-demo/basic.txt\")\n        finally:\n            await sandbox.kill()\n\n\nasync def demo_cross_sandbox_sharing(config: ConnectionConfig, image: str, run_id: str) -> None:\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 2: Cross-Sandbox Sharing\")\n    print(\"=\" * 60)\n    writer = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=3),\n        volumes=[\n            Volume(\n                name=\"oss-root-writer\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/oss\",\n            )\n        ],\n    )\n    async with writer:\n        try:\n            await print_exec(\n                writer,\n                f\"echo 'from-writer-{run_id}' > /mnt/oss/opensandbox-demo/shared.txt\",\n            )\n        finally:\n            await writer.kill()\n\n    reader = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=3),\n        volumes=[\n            Volume(\n                name=\"oss-root-reader\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/oss\",\n                readOnly=True,\n            )\n        ],\n    )\n    async with reader:\n        try:\n            print(\"[verify] sandbox B reads file created by sandbox A:\")\n            await print_exec(reader, \"cat /mnt/oss/opensandbox-demo/shared.txt\")\n        finally:\n            await reader.kill()\n\n\nasync def demo_subpath_mounts(config: ConnectionConfig, image: str, run_id: str) -> None:\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 3: Different OSS Prefixes via subPath\")\n    print(\"=\" * 60)\n    setup = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=3),\n        volumes=[\n            Volume(\n                name=\"oss-root-setup\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/oss\",\n            )\n        ],\n    )\n    async with setup:\n        try:\n            await print_exec(\n                setup,\n                \"mkdir -p /mnt/oss/opensandbox-demo/subpath-a /mnt/oss/opensandbox-demo/subpath-b\",\n            )\n        finally:\n            await setup.kill()\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=3),\n        volumes=[\n            Volume(\n                name=\"oss-a\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/a\",\n                subPath=\"opensandbox-demo/subpath-a\",\n            ),\n            Volume(\n                name=\"oss-b\",\n                ossfs=build_ossfs(),\n                mountPath=\"/mnt/b\",\n                subPath=\"opensandbox-demo/subpath-b\",\n            ),\n        ],\n    )\n    async with sandbox:\n        try:\n            await print_exec(sandbox, f\"echo 'A-{run_id}' > /mnt/a/file.txt\")\n            await print_exec(sandbox, f\"echo 'B-{run_id}' > /mnt/b/file.txt\")\n            print(\"[verify] subPath A content:\")\n            await print_exec(sandbox, \"cat /mnt/a/file.txt\")\n            print(\"[verify] subPath B content:\")\n            await print_exec(sandbox, \"cat /mnt/b/file.txt\")\n        finally:\n            await sandbox.kill()\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\"SANDBOX_IMAGE\", \"ubuntu\")\n    run_id = uuid4().hex[:8]\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(minutes=5),\n    )\n\n    print(f\"OpenSandbox server : {domain}\")\n    print(f\"Sandbox image      : {image}\")\n    print(f\"OSS bucket         : {_required_env('OSS_BUCKET')}\")\n    print(f\"OSS endpoint       : {_required_env('OSS_ENDPOINT')}\")\n\n    await demo_basic_mount(config, image, run_id)\n    await demo_cross_sandbox_sharing(config, image, run_id)\n    await demo_subpath_mounts(config, image, run_id)\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"All OSSFS scenarios completed successfully.\")\n    print(\"=\" * 60)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/docker-pvc-volume-mount/README.md",
    "content": "# Docker PVC (Named Volume) Mount Example\n\nThis example demonstrates how to mount Docker named volumes into sandbox containers using the OpenSandbox `pvc` backend. In Docker runtime, `pvc.claimName` maps to a Docker named volume -- providing a more convenient and secure alternative to host-path bind mounts for sharing data across sandboxes.\n\n> **What is `pvc`?** The `pvc` backend is a runtime-neutral abstraction. In Kubernetes it maps to a PersistentVolumeClaim; in Docker it maps to a named volume. The same API request works on both runtimes. See [OSEP-0003](../../oseps/0003-volume-and-volumebinding-support.md) for the design.\n\n## Why Named Volumes over Host Paths?\n\n| | Host path (`host` backend) | Named volume (`pvc` backend) |\n|---|---|---|\n| **Security** | Exposes host filesystem paths | Docker manages storage location; no host path exposed |\n| **Setup** | Requires `allowed_host_paths` allowlist | No allowlist needed |\n| **Cross-sandbox sharing** | All containers must agree on a host path | Reference the same volume name |\n| **Portability** | Tied to host directory structure | Works on any Docker host |\n| **Lifecycle** | User manages host directories | `docker volume create/rm` |\n\n## Scenarios\n\n| # | Scenario | Description |\n|---|----------|-------------|\n| 1 | **Read-write mount** | Mount a named volume for bidirectional file I/O |\n| 2 | **Read-only mount** | Mount a named volume that sandboxes cannot modify |\n| 3 | **Cross-sandbox sharing** | Two sandboxes share data through the same named volume |\n| 4 | **SubPath mount** | Mount only a subdirectory of a named volume (consistent with K8s PVC subPath) |\n\n## Prerequisites\n\n### 1. Start OpenSandbox Server\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n### 2. Create a Docker Named Volume\n\n```shell\n# Create the named volume\ndocker volume create opensandbox-pvc-demo\n\n# Seed it with a marker file via a temporary container\ndocker run --rm -v opensandbox-pvc-demo:/data alpine \\\n  sh -c \"echo 'hello-from-named-volume' > /data/marker.txt\"\n```\n\n### 3. Install Python SDK\n\n```shell\nuv pip install opensandbox\n```\n\n### 4. Pull the Sandbox Image\n\n```shell\ndocker pull ubuntu:latest\n```\n\n## Run\n\n```shell\nuv run python examples/docker-pvc-volume-mount/main.py\n```\n\nThe script automatically creates the named volume and seeds it with test data. You can also specify a custom volume name or image:\n\n```shell\nSANDBOX_IMAGE=ubuntu SANDBOX_DOMAIN=localhost:8080 uv run python examples/docker-pvc-volume-mount/main.py\n```\n\n## Expected Output\n\n```text\nOpenSandbox server : localhost:8080\nSandbox image      : ubuntu\nDocker volume      : opensandbox-pvc-demo\n  Ensuring Docker named volume 'opensandbox-pvc-demo' exists...\n  Created volume 'opensandbox-pvc-demo' with marker.txt\n\n============================================================\nScenario 1: Read-Write PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  Mount path : /mnt/data\n\n  [1] Reading marker file from named volume:\n  hello-from-named-volume\n\n  [2] Writing a file from inside the sandbox:\n  -> Written: /mnt/data/sandbox-output.txt\n\n  [3] Reading back the written file:\n  written-by-sandbox\n\n  [4] Listing volume contents:\n  ...\n  -rw-r--r-- 1 root root   ... marker.txt\n  -rw-r--r-- 1 root root   ... sandbox-output.txt\n\n  Scenario 1 completed.\n\n============================================================\nScenario 2: Read-Only PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  Mount path : /mnt/readonly\n\n  [1] Reading marker.txt from read-only mount:\n  hello-from-named-volume\n\n  [2] Attempting to write (should fail):\n  touch: cannot touch '/mnt/readonly/should-fail.txt': Read-only file system\n  Write denied (expected)\n\n  Scenario 2 completed.\n\n============================================================\nScenario 3: Cross-Sandbox Sharing via PVC (Named Volume)\n============================================================\n  Volume name: opensandbox-pvc-demo\n\n  [Sandbox A] Creating sandbox and writing data...\n  [Sandbox A] Wrote /mnt/shared/cross-sandbox.txt\n\n  [Sandbox B] Creating sandbox and reading data...\n  [Sandbox B] Reading file written by Sandbox A:\n  message-from-sandbox-a\n\n  Cross-sandbox data sharing verified!\n\n  Scenario 3 completed.\n\n============================================================\nScenario 4: SubPath PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  SubPath    : datasets/train\n  Mount path : /mnt/training-data\n\n  [1] Listing mounted subpath content:\n  ...\n  -rw-r--r-- 1 root root   ... data.csv\n\n  [2] Reading data.csv:\n  id,value\n  1,100\n  2,200\n\n  [3] Verifying volume root is NOT visible:\n  marker.txt at mount root: NOT-FOUND\n  -> Confirmed: subPath isolation is working correctly\n\n  Scenario 4 completed.\n\n============================================================\nAll scenarios completed successfully!\n============================================================\n```\n\n## SDK Usage Quick Reference\n\n### Python (async)\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import PVC, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            pvc=PVC(claimName=\"my-named-volume\"),\n            mountPath=\"/mnt/data\",\n            readOnly=False,       # optional, default is False\n            subPath=\"datasets/train\",  # optional, mount a subdirectory\n        ),\n    ],\n)\n```\n\n### Python (sync)\n\n```python\nfrom opensandbox import SandboxSync\nfrom opensandbox.models.sandboxes import PVC, Volume\n\nsandbox = SandboxSync.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            pvc=PVC(claimName=\"my-named-volume\"),\n            mountPath=\"/mnt/data\",\n            subPath=\"datasets/train\",  # optional\n        ),\n    ],\n)\n```\n\n### JavaScript / TypeScript\n\n```typescript\nimport { Sandbox } from \"@alibaba-group/opensandbox\";\n\nconst sandbox = await Sandbox.create({\n  image: \"ubuntu\",\n  volumes: [\n    {\n      name: \"my-data\",\n      pvc: { claimName: \"my-named-volume\" },\n      mountPath: \"/mnt/data\",\n      readOnly: false,\n      subPath: \"datasets/train\",  // optional\n    },\n  ],\n});\n```\n\n### Java / Kotlin\n\n```java\nVolume volume = Volume.builder()\n    .name(\"my-data\")\n    .pvc(PVC.of(\"my-named-volume\"))\n    .mountPath(\"/mnt/data\")\n    .readOnly(false)\n    .subPath(\"datasets/train\")  // optional\n    .build();\n\nSandbox sandbox = Sandbox.builder()\n    .image(\"ubuntu\")\n    .volume(volume)\n    .build();\n```\n\n## Cleanup\n\n```shell\ndocker volume rm opensandbox-pvc-demo\n```\n\n## References\n\n- [OSEP-0003: Volume and VolumeBinding Support](../../oseps/0003-volume-and-volumebinding-support.md) -- Design proposal\n- [Sandbox Lifecycle API Spec](../../specs/sandbox-lifecycle.yml) -- OpenAPI schema for volume definitions\n- [Host Volume Mount Example](../host-volume-mount/) -- Host path bind mount example (alternative approach)\n"
  },
  {
    "path": "examples/docker-pvc-volume-mount/README_zh.md",
    "content": "# Docker PVC（命名卷）挂载示例\n\n本示例演示如何使用 OpenSandbox 的 `pvc` 后端将 Docker 命名卷（named volume）挂载到沙箱容器中。在 Docker 运行时下，`pvc.claimName` 映射为 Docker 命名卷 —— 相比宿主机路径绑定挂载（host path），命名卷更安全、更便于跨沙箱共享数据。\n\n> **什么是 `pvc`？** `pvc` 后端是一个运行时无关的抽象。在 Kubernetes 中它映射为 PersistentVolumeClaim；在 Docker 中它映射为命名卷。同一个 API 请求可在两种运行时上工作。详见 [OSEP-0003](../../oseps/0003-volume-and-volumebinding-support.md) 设计文档。\n\n## 为什么使用命名卷而非宿主机路径？\n\n| | 宿主机路径（`host` 后端） | 命名卷（`pvc` 后端） |\n|---|---|---|\n| **安全性** | 暴露宿主机文件系统路径 | Docker 管理存储位置，不暴露宿主机路径 |\n| **配置** | 需要 `allowed_host_paths` 白名单 | 无需白名单配置 |\n| **跨沙箱共享** | 所有容器必须约定同一宿主机路径 | 引用相同的卷名即可 |\n| **可移植性** | 依赖宿主机目录结构 | 在任何 Docker 主机上均可使用 |\n| **生命周期** | 用户手动管理宿主机目录 | `docker volume create/rm` 管理 |\n\n## 演示场景\n\n| # | 场景 | 说明 |\n|---|------|------|\n| 1 | **读写挂载** | 挂载命名卷，支持双向文件读写 |\n| 2 | **只读挂载** | 挂载命名卷，沙箱不可修改 |\n| 3 | **跨沙箱共享** | 两个沙箱通过同一命名卷共享数据，无需暴露宿主机路径 |\n| 4 | **SubPath 挂载** | 仅挂载命名卷的子目录（与 K8s PVC subPath 语义一致） |\n\n## 前置条件\n\n### 1. 启动 OpenSandbox 服务\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n### 2. 创建 Docker 命名卷\n\n```shell\n# 创建命名卷\ndocker volume create opensandbox-pvc-demo\n\n# 通过临时容器写入一个标记文件\ndocker run --rm -v opensandbox-pvc-demo:/data alpine \\\n  sh -c \"echo 'hello-from-named-volume' > /data/marker.txt\"\n```\n\n### 3. 安装 Python SDK\n\n```shell\nuv pip install opensandbox\n```\n\n### 4. 拉取沙箱镜像\n\n```shell\ndocker pull registry.cn-hangzhou.aliyuncs.com/acs/ubuntu:latest\n```\n\n## 运行\n\n```shell\nSANDBOX_IMAGE=registry.cn-hangzhou.aliyuncs.com/acs/ubuntu:latest \\\n  uv run python examples/docker-pvc-volume-mount/main.py\n```\n\n脚本会自动创建命名卷并写入测试数据。也可以通过环境变量自定义镜像和服务地址：\n\n```shell\nSANDBOX_IMAGE=ubuntu SANDBOX_DOMAIN=localhost:8080 \\\n  uv run python examples/docker-pvc-volume-mount/main.py\n```\n\n## 预期输出\n\n```text\nOpenSandbox server : localhost:8080\nSandbox image      : ubuntu\nDocker volume      : opensandbox-pvc-demo\n  Ensuring Docker named volume 'opensandbox-pvc-demo' exists...\n  Created volume 'opensandbox-pvc-demo' with marker.txt\n\n============================================================\nScenario 1: Read-Write PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  Mount path : /mnt/data\n\n  [1] Reading marker file from named volume:\n  hello-from-named-volume\n\n  [2] Writing a file from inside the sandbox:\n  -> Written: /mnt/data/sandbox-output.txt\n\n  [3] Reading back the written file:\n  written-by-sandbox\n\n  [4] Listing volume contents:\n  ...\n  -rw-r--r-- 1 root root   ... marker.txt\n  -rw-r--r-- 1 root root   ... sandbox-output.txt\n\n  Scenario 1 completed.\n\n============================================================\nScenario 2: Read-Only PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  Mount path : /mnt/readonly\n\n  [1] Reading marker.txt from read-only mount:\n  hello-from-named-volume\n\n  [2] Attempting to write (should fail):\n  touch: cannot touch '/mnt/readonly/should-fail.txt': Read-only file system\n  Write denied (expected)\n\n  Scenario 2 completed.\n\n============================================================\nScenario 3: Cross-Sandbox Sharing via PVC (Named Volume)\n============================================================\n  Volume name: opensandbox-pvc-demo\n\n  [Sandbox A] Creating sandbox and writing data...\n  [Sandbox A] Wrote /mnt/shared/cross-sandbox.txt\n\n  [Sandbox B] Creating sandbox and reading data...\n  [Sandbox B] Reading file written by Sandbox A:\n  message-from-sandbox-a\n\n  Cross-sandbox data sharing verified!\n\n  Scenario 3 completed.\n\n============================================================\nScenario 4: SubPath PVC (Named Volume) Mount\n============================================================\n  Volume name: opensandbox-pvc-demo\n  SubPath    : datasets/train\n  Mount path : /mnt/training-data\n\n  [1] Listing mounted subpath content:\n  ...\n  -rw-r--r-- 1 root root   ... data.csv\n\n  [2] Reading data.csv:\n  id,value\n  1,100\n  2,200\n\n  [3] Verifying volume root is NOT visible:\n  marker.txt at mount root: NOT-FOUND\n  -> Confirmed: subPath isolation is working correctly\n\n  Scenario 4 completed.\n\n============================================================\nAll scenarios completed successfully!\n============================================================\n```\n\n## 各 SDK 用法速览\n\n### Python（异步）\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import PVC, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            pvc=PVC(claimName=\"my-named-volume\"),\n            mountPath=\"/mnt/data\",\n            readOnly=False,       # 可选，默认为 False\n            subPath=\"datasets/train\",  # 可选，挂载子目录\n        ),\n    ],\n)\n```\n\n### Python（同步）\n\n```python\nfrom opensandbox import SandboxSync\nfrom opensandbox.models.sandboxes import PVC, Volume\n\nsandbox = SandboxSync.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            pvc=PVC(claimName=\"my-named-volume\"),\n            mountPath=\"/mnt/data\",\n            subPath=\"datasets/train\",  # 可选\n        ),\n    ],\n)\n```\n\n### JavaScript / TypeScript\n\n```typescript\nimport { Sandbox } from \"@alibaba-group/opensandbox\";\n\nconst sandbox = await Sandbox.create({\n  image: \"ubuntu\",\n  volumes: [\n    {\n      name: \"my-data\",\n      pvc: { claimName: \"my-named-volume\" },\n      mountPath: \"/mnt/data\",\n      readOnly: false,\n      subPath: \"datasets/train\",  // 可选\n    },\n  ],\n});\n```\n\n### Java / Kotlin\n\n```java\nVolume volume = Volume.builder()\n    .name(\"my-data\")\n    .pvc(PVC.of(\"my-named-volume\"))\n    .mountPath(\"/mnt/data\")\n    .readOnly(false)\n    .subPath(\"datasets/train\")  // 可选\n    .build();\n\nSandbox sandbox = Sandbox.builder()\n    .image(\"ubuntu\")\n    .volume(volume)\n    .build();\n```\n\n## 清理\n\n```shell\ndocker volume rm opensandbox-pvc-demo\n```\n\n## 参考资料\n\n- [OSEP-0003: Volume 与 VolumeBinding 支持](../../oseps/0003-volume-and-volumebinding-support.md) — 设计提案\n- [Sandbox Lifecycle API 规范](../../specs/sandbox-lifecycle.yml) — Volume 定义的 OpenAPI 规范\n- [宿主机目录挂载示例](../host-volume-mount/) — Host path 绑定挂载示例（替代方案）\n"
  },
  {
    "path": "examples/docker-pvc-volume-mount/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nDocker PVC (Named Volume) Mount Example\n========================================\n\nDemonstrates how to mount Docker named volumes into sandbox containers using\nthe OpenSandbox ``pvc`` backend.  In Docker runtime the ``pvc`` backend maps\n``claimName`` to a Docker named volume -- providing a more convenient and\nsecure alternative to host-path bind mounts for sharing data across sandboxes.\n\nFour scenarios are demonstrated:\n\n1. **Read-write mount**        - Mount a named volume for bidirectional file I/O.\n2. **Read-only mount**         - Mount a named volume as read-only.\n3. **Cross-sandbox sharing**   - Two sandboxes share data through the same named\n   volume without exposing any host path.\n4. **SubPath mount**           - Mount only a subdirectory of a named volume,\n   keeping the same API as Kubernetes PVC subPath.\n\nPrerequisites:\n- OpenSandbox server running with Docker runtime\n- Docker named volume created before running this script (see README.md)\n\"\"\"\n\nimport asyncio\nimport os\nimport subprocess\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\ntry:\n    from opensandbox.models.sandboxes import PVC, Volume\nexcept ImportError:\n    print(\n        \"ERROR: Your installed opensandbox SDK does not include Volume/PVC models.\\n\"\n        \"       Volume support requires the latest SDK from source.\\n\"\n        \"       Please install from the local repository:\\n\"\n        \"\\n\"\n        \"           pip install -e sdks/sandbox/python\\n\"\n        \"\\n\"\n        \"       See README.md for details.\"\n    )\n    raise SystemExit(1)\n\n\nVOLUME_NAME = \"opensandbox-pvc-demo\"\n\n\nasync def print_exec(sandbox: Sandbox, command: str) -> str | None:\n    \"\"\"Run a command in the sandbox and print/return stdout.\"\"\"\n    result = await sandbox.commands.run(command)\n    if result.error:\n        print(f\"  [error] {result.error.name}: {result.error.value}\")\n        return None\n    text = \"\\n\".join(msg.text for msg in result.logs.stdout)\n    if text:\n        print(f\"  {text}\")\n    return text\n\n\ndef ensure_named_volume() -> None:\n    \"\"\"Create the Docker named volume and seed it with test data.\"\"\"\n    print(f\"  Ensuring Docker named volume '{VOLUME_NAME}' exists...\")\n    subprocess.run(\n        [\"docker\", \"volume\", \"rm\", VOLUME_NAME],\n        capture_output=True,\n    )\n    subprocess.run(\n        [\"docker\", \"volume\", \"create\", VOLUME_NAME],\n        check=True,\n        capture_output=True,\n    )\n    # Seed the volume with a marker file and subpath test data\n    subprocess.run(\n        [\n            \"docker\", \"run\", \"--rm\",\n            \"-v\", f\"{VOLUME_NAME}:/data\",\n            \"alpine\",\n            \"sh\", \"-c\",\n            \"echo 'hello-from-named-volume' > /data/marker.txt && \"\n            \"mkdir -p /data/datasets/train && \"\n            \"echo 'id,value' > /data/datasets/train/data.csv && \"\n            \"echo '1,100' >> /data/datasets/train/data.csv && \"\n            \"echo '2,200' >> /data/datasets/train/data.csv\",\n        ],\n        check=True,\n        capture_output=True,\n    )\n    print(f\"  Created volume '{VOLUME_NAME}' with marker.txt and datasets/train/\")\n\n\nasync def demo_readwrite_mount(config: ConnectionConfig, image: str) -> None:\n    \"\"\"\n    Scenario 1: Read-write named volume mount.\n\n    Mount a Docker named volume into the sandbox at /mnt/data.\n    Write a file inside the sandbox, then read it back to verify.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 1: Read-Write PVC (Named Volume) Mount\")\n    print(\"=\" * 60)\n    print(f\"  Volume name: {VOLUME_NAME}\")\n    print(f\"  Mount path : /mnt/data\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"demo-data\",\n                pvc=PVC(claimName=VOLUME_NAME),\n                mountPath=\"/mnt/data\",\n                readOnly=False,\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # Read the seeded marker file\n            print(\"\\n  [1] Reading marker file from named volume:\")\n            await print_exec(sandbox, \"cat /mnt/data/marker.txt\")\n\n            # Write a new file\n            print(\"\\n  [2] Writing a file from inside the sandbox:\")\n            await print_exec(\n                sandbox,\n                \"echo 'written-by-sandbox' > /mnt/data/sandbox-output.txt\",\n            )\n            print(\"  -> Written: /mnt/data/sandbox-output.txt\")\n\n            # Read it back\n            print(\"\\n  [3] Reading back the written file:\")\n            await print_exec(sandbox, \"cat /mnt/data/sandbox-output.txt\")\n\n            # List all files\n            print(\"\\n  [4] Listing volume contents:\")\n            await print_exec(sandbox, \"ls -la /mnt/data/\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 1 completed.\")\n\n\nasync def demo_readonly_mount(config: ConnectionConfig, image: str) -> None:\n    \"\"\"\n    Scenario 2: Read-only named volume mount.\n\n    Mount the same named volume as read-only.  Verify reads succeed but\n    writes are rejected by the container runtime.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 2: Read-Only PVC (Named Volume) Mount\")\n    print(\"=\" * 60)\n    print(f\"  Volume name: {VOLUME_NAME}\")\n    print(f\"  Mount path : /mnt/readonly\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"readonly-vol\",\n                pvc=PVC(claimName=VOLUME_NAME),\n                mountPath=\"/mnt/readonly\",\n                readOnly=True,\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # Read the marker file\n            print(\"\\n  [1] Reading marker.txt from read-only mount:\")\n            await print_exec(sandbox, \"cat /mnt/readonly/marker.txt\")\n\n            # Attempt to write (should fail)\n            print(\"\\n  [2] Attempting to write (should fail):\")\n            result = await sandbox.commands.run(\n                \"touch /mnt/readonly/should-fail.txt 2>&1 || echo 'Write denied (expected)'\"\n            )\n            for msg in result.logs.stdout:\n                print(f\"  {msg.text}\")\n            for msg in result.logs.stderr:\n                print(f\"  {msg.text}\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 2 completed.\")\n\n\nasync def demo_cross_sandbox_sharing(config: ConnectionConfig, image: str) -> None:\n    \"\"\"\n    Scenario 3: Cross-sandbox data sharing via named volume.\n\n    Two sandboxes mount the same named volume.  Sandbox A writes a file,\n    then Sandbox B reads it -- demonstrating data sharing without any host\n    path exposure.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 3: Cross-Sandbox Sharing via PVC (Named Volume)\")\n    print(\"=\" * 60)\n    print(f\"  Volume name: {VOLUME_NAME}\")\n\n    volume_spec = Volume(\n        name=\"shared-vol\",\n        pvc=PVC(claimName=VOLUME_NAME),\n        mountPath=\"/mnt/shared\",\n        readOnly=False,\n    )\n\n    # --- Sandbox A: write ---\n    print(\"\\n  [Sandbox A] Creating sandbox and writing data...\")\n    sandbox_a = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[volume_spec],\n    )\n    async with sandbox_a:\n        try:\n            await print_exec(\n                sandbox_a,\n                \"echo 'message-from-sandbox-a' > /mnt/shared/cross-sandbox.txt\",\n            )\n            print(\"  [Sandbox A] Wrote /mnt/shared/cross-sandbox.txt\")\n        finally:\n            await sandbox_a.kill()\n\n    # --- Sandbox B: read ---\n    print(\"\\n  [Sandbox B] Creating sandbox and reading data...\")\n    sandbox_b = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[volume_spec],\n    )\n    async with sandbox_b:\n        try:\n            print(\"  [Sandbox B] Reading file written by Sandbox A:\")\n            text = await print_exec(sandbox_b, \"cat /mnt/shared/cross-sandbox.txt\")\n            if text and \"message-from-sandbox-a\" in text:\n                print(\"\\n  Cross-sandbox data sharing verified!\")\n        finally:\n            await sandbox_b.kill()\n\n    print(\"\\n  Scenario 3 completed.\")\n\n\nasync def demo_subpath_mount(config: ConnectionConfig, image: str) -> None:\n    \"\"\"\n    Scenario 4: SubPath mount on a named volume.\n\n    Mount only a subdirectory (datasets/train) of the named volume.  The server\n    resolves the volume's host-side Mountpoint via ``docker volume inspect`` and\n    appends the subPath, producing a standard bind mount.  This keeps the API\n    consistent with Kubernetes PVC subPath semantics.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 4: SubPath PVC (Named Volume) Mount\")\n    print(\"=\" * 60)\n    print(f\"  Volume name: {VOLUME_NAME}\")\n    print(f\"  SubPath    : datasets/train\")\n    print(f\"  Mount path : /mnt/training-data\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"train-data\",\n                pvc=PVC(claimName=VOLUME_NAME),\n                mountPath=\"/mnt/training-data\",\n                readOnly=True,\n                subPath=\"datasets/train\",\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # List contents -- should only show the subpath\n            print(\"\\n  [1] Listing mounted subpath content:\")\n            await print_exec(sandbox, \"ls -la /mnt/training-data/\")\n\n            # Read the CSV data\n            print(\"\\n  [2] Reading data.csv:\")\n            await print_exec(sandbox, \"cat /mnt/training-data/data.csv\")\n\n            # Verify the root marker.txt is NOT visible (we're inside datasets/train)\n            print(\"\\n  [3] Verifying volume root is NOT visible:\")\n            result = await sandbox.commands.run(\"test -f /mnt/training-data/marker.txt && echo FOUND || echo NOT-FOUND\")\n            text = \"\\n\".join(msg.text for msg in result.logs.stdout)\n            print(f\"  marker.txt at mount root: {text}\")\n            if \"NOT-FOUND\" in text:\n                print(\"  -> Confirmed: subPath isolation is working correctly\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 4 completed.\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\"SANDBOX_IMAGE\", \"ubuntu\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(minutes=3),\n    )\n\n    print(f\"OpenSandbox server : {config.domain}\")\n    print(f\"Sandbox image      : {image}\")\n    print(f\"Docker volume      : {VOLUME_NAME}\")\n\n    # Ensure the named volume exists with seed data\n    ensure_named_volume()\n\n    await demo_readwrite_mount(config, image)\n    await demo_readonly_mount(config, image)\n    await demo_cross_sandbox_sharing(config, image)\n    await demo_subpath_mount(config, image)\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"All scenarios completed successfully!\")\n    print(\"=\" * 60)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/gemini-cli/README.md",
    "content": "# Gemini CLI Example\n\nCall Google Gemini via the `@google/gemini-cli` npm package in OpenSandbox.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Node.js):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nStart the local OpenSandbox server, logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Gemini Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / GEMINI_API_KEY)\nuv run python examples/gemini-cli/main.py\n```\n\nThe script installs the Gemini CLI (`npm install -g @google/gemini-cli@latest`) at runtime (Node.js is already in the code-interpreter image), then sends a simple request `gemini \"Compute 1+1=?.\"`. Auth is passed via `GEMINI_API_KEY`; you can override endpoint/model with `GEMINI_BASE_URL` / `GEMINI_MODEL`.\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `GEMINI_API_KEY`: Your Google Gemini API key (required)\n- `GEMINI_BASE_URL`: Gemini API endpoint (optional; e.g., proxy)\n- `GEMINI_MODEL`: Model to use (default: `gemini-2.5-flash`)\n\n## References\n- [@google/gemini-cli](https://www.npmjs.com/package/@google/gemini-cli) - Gemini CLI\n"
  },
  {
    "path": "examples/gemini-cli/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_execution_logs(execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    gemini_api_key = _required_env(\"GEMINI_API_KEY\")\n    gemini_base_url = os.getenv(\"GEMINI_BASE_URL\")\n    gemini_model = os.getenv(\"GEMINI_MODEL\", \"gemini-2.5-flash\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject Gemini settings into container environment for CLI access\n    env = {\n        \"GEMINI_API_KEY\": gemini_api_key,\n        \"GEMINI_BASE_URL\": gemini_base_url,\n        \"GEMINI_MODEL\": gemini_model,\n    }\n    # Drop None values to avoid overriding defaults inside CLI\n    env = {k: v for k, v in env.items() if v is not None}\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # Install Gemini CLI (Node.js is already in the code-interpreter image)\n        install_exec = await sandbox.commands.run(\n            \"npm install -g @google/gemini-cli@latest\"\n        )\n        await _print_execution_logs(install_exec)\n\n        # Use Gemini CLI to send a message\n        run_exec = await sandbox.commands.run(\n            'gemini \"Compute 1+1=?.\"'\n        )\n        await _print_execution_logs(run_exec)\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/google-adk/README.md",
    "content": "# Google ADK + OpenSandbox Example\n\nIntegrate Google Agent Development Kit (ADK) with OpenSandbox. The ADK agent\ndrives tool calls that execute inside a sandbox.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Python):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nStart the local OpenSandbox server, logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Run the example\n\n```shell\n# Install OpenSandbox + Google ADK deps\nuv pip install opensandbox google-adk\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / GOOGLE_API_KEY)\nuv run python examples/google-adk/main.py\n```\n\nThe script uses ADK to create an agent with OpenSandbox tools (`write_file`,\n`read_file`, `run_in_sandbox`). It runs a few prompts, prints tool events, and\ncleans up the sandbox.\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `GOOGLE_API_KEY`: Gemini API key (required)\n- `GOOGLE_ADK_MODEL`: Gemini model name (default: `gemini-2.5-flash`)\n\n## References\n- [Google ADK](https://google.github.io/adk-docs/) - Agent Development Kit\n"
  },
  {
    "path": "examples/google-adk/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport os\nfrom datetime import timedelta\n\nfrom google.adk.agents import Agent\nfrom google.adk.apps import App\nfrom google.adk.runners import Runner\nfrom google.adk.sessions.in_memory_session_service import InMemorySessionService\nfrom google.adk.utils._debug_output import print_event\nfrom google.adk.utils.context_utils import Aclosing\nfrom google.genai import types\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def main() -> None:\n    _required_env(\"GOOGLE_API_KEY\")\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n    model_name = os.getenv(\"GOOGLE_ADK_MODEL\", \"gemini-2.5-flash\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=120),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n    )\n\n    async def run_in_sandbox(command: str) -> str:\n        \"\"\"Run a shell command in OpenSandbox and return the output.\"\"\"\n\n        execution = await sandbox.commands.run(command)\n        stdout = \"\\n\".join(msg.text for msg in execution.logs.stdout)\n        stderr = \"\\n\".join(msg.text for msg in execution.logs.stderr)\n        if execution.error:\n            stderr = \"\\n\".join(\n                [\n                    stderr,\n                    f\"[error] {execution.error.name}: {execution.error.value}\",\n                ]\n            ).strip()\n\n        output = stdout.strip()\n        if stderr:\n            output = \"\\n\".join([output, f\"[stderr]\\n{stderr}\"]).strip()\n        return output or \"(no output)\"\n\n    async def write_file(path: str, content: str) -> str:\n        \"\"\"Write a file inside the sandbox.\"\"\"\n\n        await sandbox.files.write_file(path, content)\n        return f\"wrote {len(content)} bytes to {path}\"\n\n    async def read_file(path: str) -> str:\n        \"\"\"Read a file from the sandbox.\"\"\"\n\n        return await sandbox.files.read_file(path)\n\n    agent = Agent(\n        name=\"opensandbox_adk\",\n        model=model_name,\n        instruction=(\n            \"You have access to OpenSandbox tools. Use write_file to create or \"\n            \"update files, read_file to read files, and run_in_sandbox to run \"\n            \"commands.\"\n        ),\n        tools=[run_in_sandbox, write_file, read_file],\n    )\n\n    app = App(name=\"opensandbox_adk\", root_agent=agent)\n    session_service = InMemorySessionService()\n    runner = Runner(app=app, session_service=session_service)\n    session = await session_service.create_session(\n        app_name=app.name,\n        user_id=\"local-user\",\n    )\n\n    prompts = [\n        \"Use write_file to save /tmp/math.py that prints 137 * 42.\",\n        \"Run the script using run_in_sandbox and report the result.\",\n        \"Write /tmp/notes.txt with 'ADK + OpenSandbox', then read it back.\",\n    ]\n\n    try:\n        for prompt in prompts:\n            content = types.Content(\n                role=\"user\",\n                parts=[types.Part(text=prompt)],\n            )\n            async with Aclosing(\n                runner.run_async(\n                    user_id=session.user_id,\n                    session_id=session.id,\n                    new_message=content,\n                )\n            ) as agen:\n                async for event in agen:\n                    print_event(event, verbose=True)\n    finally:\n        await sandbox.kill()\n        await sandbox.close()\n\n\nif __name__ == \"__main__\":\n    import asyncio\n\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/host-volume-mount/README.md",
    "content": "# Host Volume Mount Example\n\nThis example demonstrates how to mount host directories into sandbox containers using the OpenSandbox Volume API. Host volume mounts enable bidirectional file sharing between the host machine and sandbox environments — ideal for sharing datasets, model checkpoints, configuration files, or collecting sandbox outputs.\n\n## Scenarios\n\n| # | Scenario | Description |\n|---|----------|-------------|\n| 1 | **Read-write mount** | Mount a host directory for bidirectional file exchange |\n| 2 | **Read-only mount** | Provide shared data that sandboxes cannot modify |\n| 3 | **SubPath mount** | Mount a specific subdirectory from the host path |\n\n## Prerequisites\n\n### 1. Start OpenSandbox Server\n\n```shell\ngit clone git@github.com:alibaba/OpenSandbox.git\ncd OpenSandbox/server\ncp example.config.toml ~/.sandbox.toml\nuv sync && uv run python -m src.main\n```\n\n### 2. Configure Allowed Host Paths\n\nFor security, the server restricts which host paths can be mounted. Add a `[storage]` section to `~/.sandbox.toml`:\n\n```toml\n[storage]\n# Allowlist of host path prefixes permitted for bind mounts.\n# Only paths under these prefixes can be mounted into sandboxes.\n# If empty, all host paths are allowed (not recommended for production).\nallowed_host_paths = [\"/tmp/opensandbox-data\", \"/data/shared\"]\n```\n\n> **Security note**: In production, always set explicit `allowed_host_paths` to prevent sandboxes from accessing sensitive host directories. An empty list allows all paths, which is convenient for local development but not safe for shared environments.\n\n### 3. Create Host Directories\n\n```shell\n# Create a directory to share with sandboxes\nmkdir -p /tmp/opensandbox-data\necho \"hello-from-host\" > /tmp/opensandbox-data/marker.txt\n\n# Create a subdirectory for the subpath demo\nmkdir -p /tmp/opensandbox-data/datasets/train\necho -e \"id,value\\n1,100\\n2,200\\n3,300\" > /tmp/opensandbox-data/datasets/train/data.csv\n```\n\n### 4. Install SDK from Source\n\nVolume support requires the latest SDK built from source (not yet available in the released package):\n\n```shell\n# From the project root (recommended: use uv)\nuv pip install -e sdks/sandbox/python\n\n# Or use pip inside a virtual environment\n# python3 -m venv .venv && source .venv/bin/activate\n# pip install -e sdks/sandbox/python\n```\n\n### 5. Pull the Sandbox Image\n\n```shell\ndocker pull ubuntu:latest\n```\n\n## Run\n\n```shell\nHOST_VOLUME_PATH=/tmp/opensandbox-data uv run python examples/host-volume-mount/main.py\n```\n\n## Expected Output\n\n```text\nUsing HOST_VOLUME_PATH: /tmp/opensandbox-data\n\nOpenSandbox server : localhost:8080\nSandbox image      : ubuntu\nHost volume path   : /tmp/opensandbox-data\n\n============================================================\nScenario 1: Read-Write Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  Mount path: /mnt/shared\n\n  [1] Listing files visible from inside the sandbox:\n  total 12\n  drwxrwxrwx 3 root root 4096 ... .\n  drwxr-xr-x 1 root root 4096 ... ..\n  -rw-r--r-- 1 root root   16 ... marker.txt\n  drwxr-xr-x 3 root root 4096 ... datasets\n\n  [2] Writing a file from inside the sandbox:\n  -> Written: /mnt/shared/sandbox-greeting.txt\n\n  [3] Reading back the file:\n  Hello from sandbox!\n\n  [4] Verified on host: /tmp/opensandbox-data/sandbox-greeting.txt\n      Content: Hello from sandbox!\n\n  Scenario 1 completed.\n\n============================================================\nScenario 2: Read-Only Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  Mount path: /mnt/readonly\n\n  [1] Reading files from read-only mount:\n  ...\n\n  [2] Reading marker.txt:\n  hello-from-host\n\n  [3] Attempting to write (should fail):\n  Write denied (expected)\n\n  Scenario 2 completed.\n\n============================================================\nScenario 3: SubPath Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  SubPath   : datasets/train\n  Mount path: /mnt/training-data\n\n  [1] Listing mounted subpath content:\n  ...\n  -rw-r--r-- 1 root root   28 ... data.csv\n\n  [2] Reading data.csv:\n  id,value\n  1,100\n  2,200\n  3,300\n\n  Scenario 3 completed.\n\n============================================================\nAll scenarios completed successfully!\n============================================================\n```\n\n## SDK Usage Quick Reference\n\n### Python (async)\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import Host, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            host=Host(path=\"/data/shared\"),\n            mountPath=\"/mnt/data\",\n            readOnly=False,       # optional, default is False\n            subPath=\"subdir\",     # optional, mount a subdirectory\n        ),\n    ],\n)\n```\n\n### Python (sync)\n\n```python\nfrom opensandbox import SandboxSync\nfrom opensandbox.models.sandboxes import Host, Volume\n\nsandbox = SandboxSync.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            host=Host(path=\"/data/shared\"),\n            mountPath=\"/mnt/data\",\n        ),\n    ],\n)\n```\n\n### JavaScript / TypeScript\n\n```typescript\nimport { Sandbox } from \"@alibaba-group/opensandbox\";\n\nconst sandbox = await Sandbox.create({\n  image: \"ubuntu\",\n  volumes: [\n    {\n      name: \"my-data\",\n      host: { path: \"/data/shared\" },\n      mountPath: \"/mnt/data\",\n      readOnly: false,\n    },\n  ],\n});\n```\n\n### Java / Kotlin\n\n```java\nVolume volume = Volume.builder()\n    .name(\"my-data\")\n    .host(Host.of(\"/data/shared\"))\n    .mountPath(\"/mnt/data\")\n    .readOnly(false)\n    .build();\n\nSandbox sandbox = Sandbox.builder()\n    .image(\"ubuntu\")\n    .volume(volume)\n    .build();\n```\n\n## References\n\n- [OSEP-0003: Volume and VolumeBinding Support](../../oseps/0003-volume-and-volumebinding-support.md) — Design proposal\n- [Sandbox Lifecycle API Spec](../../specs/sandbox-lifecycle.yml) — OpenAPI schema for volume definitions\n- [Server Configuration](../../server/example.config.toml) — `[storage]` section for `allowed_host_paths`\n"
  },
  {
    "path": "examples/host-volume-mount/README_zh.md",
    "content": "# 宿主机目录挂载示例\n\n本示例演示如何使用 OpenSandbox Volume API 将宿主机目录挂载到沙箱容器中。宿主机目录挂载支持宿主机与沙箱环境之间的双向文件共享，适用于共享数据集、模型检查点、配置文件或收集沙箱输出等场景。\n\n## 演示场景\n\n| # | 场景 | 说明 |\n|---|------|------|\n| 1 | **读写挂载** | 挂载宿主机目录，支持双向文件读写 |\n| 2 | **只读挂载** | 提供沙箱不可修改的共享数据 |\n| 3 | **SubPath 挂载** | 仅挂载宿主机路径下的指定子目录 |\n\n## 前置条件\n\n### 1. 启动 OpenSandbox 服务\n\n```shell\ngit clone git@github.com:alibaba/OpenSandbox.git\ncd OpenSandbox/server\ncp example.config.zh.toml ~/.sandbox.toml\nuv sync && uv run python -m src.main\n```\n\n### 2. 配置允许的宿主机路径\n\n出于安全考虑，服务端会限制可挂载的宿主机路径。请在 `~/.sandbox.toml` 中添加 `[storage]` 配置段：\n\n```toml\n[storage]\n# 允许进行 bind mount 的宿主机路径前缀白名单。\n# 仅匹配这些前缀的路径才能被挂载到沙箱中。\n# 如果为空，则允许所有路径（不建议在生产环境使用）。\nallowed_host_paths = [\"/tmp/opensandbox-data\", \"/data/shared\"]\n```\n\n> **安全提示**：在生产环境中，请务必设置明确的 `allowed_host_paths`，以防止沙箱访问敏感的宿主机目录。空列表表示允许所有路径，适合本地开发，但不适用于共享环境。\n\n### 3. 创建宿主机目录\n\n```shell\n# 创建与沙箱共享的目录\nmkdir -p /tmp/opensandbox-data\necho \"hello-from-host\" > /tmp/opensandbox-data/marker.txt\n\n# 创建用于 subpath 演示的子目录\nmkdir -p /tmp/opensandbox-data/datasets/train\necho -e \"id,value\\n1,100\\n2,200\\n3,300\" > /tmp/opensandbox-data/datasets/train/data.csv\n```\n\n### 4. 从源码安装 SDK\n\nVolume 功能需要从源码安装最新版 SDK：\n\n```shell\n# 在项目根目录下执行（推荐使用 uv）\nuv pip install -e sdks/sandbox/python\n\n# 或者使用 pip（需要在虚拟环境中执行）\n# python3 -m venv .venv && source .venv/bin/activate\n# pip install -e sdks/sandbox/python\n```\n\n### 5. 拉取沙箱镜像\n\n```shell\ndocker pull registry.cn-hangzhou.aliyuncs.com/acs/ubuntu:latest\n```\n\n## 运行\n\n```shell\nSANDBOX_IMAGE=registry.cn-hangzhou.aliyuncs.com/acs/ubuntu:latest \\\n  HOST_VOLUME_PATH=/tmp/opensandbox-data uv run python examples/host-volume-mount/main.py\n```\n\n## 预期输出\n\n```text\nUsing HOST_VOLUME_PATH: /tmp/opensandbox-data\n\nOpenSandbox server : localhost:8080\nSandbox image      : registry.cn-hangzhou.aliyuncs.com/acs/ubuntu:latest\nHost volume path   : /tmp/opensandbox-data\n\n============================================================\nScenario 1: Read-Write Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  Mount path: /mnt/shared\n\n  [1] Listing files visible from inside the sandbox:\n  total 4\ndrwxr-xr-x 1 root root 128 Feb  6 09:24 .\ndrwxr-xr-x 1 root root  12 Feb  6 11:50 ..\ndrwxr-xr-x 1 root root  96 Feb  6 09:24 datasets\n-rw-r--r-- 1 root root  16 Feb  6 09:24 marker.txt\n\n  [2] Writing a file from inside the sandbox:\n  -> Written: /mnt/shared/sandbox-greeting.txt\n\n  [3] Reading back the file:\n  Hello from sandbox!\n\n  [4] Verified on host: /tmp/opensandbox-data/sandbox-greeting.txt\n      Content: Hello from sandbox!\n\n  Scenario 1 completed.\n\n============================================================\nScenario 2: Read-Only Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  Mount path: /mnt/readonly\n\n  [1] Reading files from read-only mount:\n  total 8\ndrwxr-xr-x 1 root root 160 Feb  6 11:50 .\ndrwxr-xr-x 1 root root  16 Feb  6 11:50 ..\ndrwxr-xr-x 1 root root  96 Feb  6 09:24 datasets\n-rw-r--r-- 1 root root  16 Feb  6 09:24 marker.txt\n-rw-r--r-- 1 root root  20 Feb  6 11:50 sandbox-greeting.txt\n\n  [2] Reading marker.txt:\n  hello-from-host\n\n  [3] Attempting to write (should fail):\n  touch: cannot touch '/mnt/readonly/should-fail.txt': Read-only file system\n  Write denied (expected)\n\n  Scenario 2 completed.\n\n============================================================\nScenario 3: SubPath Host Volume Mount\n============================================================\n  Host path : /tmp/opensandbox-data\n  SubPath   : datasets/train\n  Mount path: /mnt/training-data\n\n  [1] Listing mounted subpath content:\n  total 4\ndrwxr-xr-x 1 root root 96 Feb  6 09:24 .\ndrwxr-xr-x 1 root root 26 Feb  6 11:50 ..\n-rw-r--r-- 1 root root 27 Feb  6 11:50 data.csv\n\n  [2] Reading data.csv:\n  id,value\n1,100\n2,200\n3,300\n\n  Scenario 3 completed.\n\n============================================================\nAll scenarios completed successfully!\n============================================================\n```\n\n## 各 SDK 用法速览\n\n### Python（异步）\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import Host, Volume\n\nsandbox = await Sandbox.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            host=Host(path=\"/data/shared\"),\n            mountPath=\"/mnt/data\",\n            readOnly=False,       # 可选，默认为 False\n            subPath=\"subdir\",     # 可选，挂载子目录\n        ),\n    ],\n)\n```\n\n### Python（同步）\n\n```python\nfrom opensandbox import SandboxSync\nfrom opensandbox.models.sandboxes import Host, Volume\n\nsandbox = SandboxSync.create(\n    image=\"ubuntu\",\n    volumes=[\n        Volume(\n            name=\"my-data\",\n            host=Host(path=\"/data/shared\"),\n            mountPath=\"/mnt/data\",\n        ),\n    ],\n)\n```\n\n### JavaScript / TypeScript\n\n```typescript\nimport { Sandbox } from \"@alibaba-group/opensandbox\";\n\nconst sandbox = await Sandbox.create({\n  image: \"ubuntu\",\n  volumes: [\n    {\n      name: \"my-data\",\n      host: { path: \"/data/shared\" },\n      mountPath: \"/mnt/data\",\n      readOnly: false,\n    },\n  ],\n});\n```\n\n### Java / Kotlin\n\n```java\nVolume volume = Volume.builder()\n    .name(\"my-data\")\n    .host(Host.of(\"/data/shared\"))\n    .mountPath(\"/mnt/data\")\n    .readOnly(false)\n    .build();\n\nSandbox sandbox = Sandbox.builder()\n    .image(\"ubuntu\")\n    .volume(volume)\n    .build();\n```\n\n## 参考资料\n\n- [OSEP-0003: Volume 与 VolumeBinding 支持](../../oseps/0003-volume-and-volumebinding-support.md) — 设计提案\n- [Sandbox Lifecycle API 规范](../../specs/sandbox-lifecycle.yml) — Volume 定义的 OpenAPI 规范\n- [服务端配置示例](../../server/example.config.zh.toml) — `[storage]` 段中的 `allowed_host_paths` 配置\n"
  },
  {
    "path": "examples/host-volume-mount/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nHost Volume Mount Example\n=========================\n\nDemonstrates how to mount a host directory into a sandbox container using\nthe OpenSandbox Volume API. This enables sharing files, datasets, or model\ncheckpoints between the host machine and sandbox environments.\n\nThree scenarios are demonstrated:\n\n1. **Read-write mount** - Share a working directory for bidirectional file exchange.\n2. **Read-only mount**  - Provide shared datasets or configs that sandboxes should\n   not modify.\n3. **SubPath mount**    - Mount a specific subdirectory from the host path.\n\nPrerequisites:\n- OpenSandbox server running with Docker runtime\n- Server config includes `[storage]` section with appropriate `allowed_host_paths`\n- Host directories created before running this script (see README.md)\n\"\"\"\n\nimport asyncio\nimport os\nimport tempfile\nfrom datetime import timedelta\nfrom pathlib import Path\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\ntry:\n    from opensandbox.models.sandboxes import Host, Volume\nexcept ImportError:\n    print(\n        \"ERROR: Your installed opensandbox SDK does not include Volume/Host models.\\n\"\n        \"       Volume support requires the latest SDK from source.\\n\"\n        \"       Please install from the local repository:\\n\"\n        \"\\n\"\n        \"           pip install -e sdks/sandbox/python\\n\"\n        \"\\n\"\n        \"       See README.md for details.\"\n    )\n    raise SystemExit(1)\n\n\nasync def print_exec(sandbox: Sandbox, command: str) -> str | None:\n    \"\"\"Run a command in the sandbox and print/return stdout.\"\"\"\n    result = await sandbox.commands.run(command)\n    if result.error:\n        print(f\"  [error] {result.error.name}: {result.error.value}\")\n        return None\n    text = \"\\n\".join(msg.text for msg in result.logs.stdout)\n    if text:\n        print(f\"  {text}\")\n    return text\n\n\nasync def demo_readwrite_mount(config: ConnectionConfig, image: str, host_dir: str) -> None:\n    \"\"\"\n    Scenario 1: Read-write mount.\n\n    Mount a host directory into the sandbox at /mnt/shared. Write a file from\n    inside the sandbox, then verify it appears on the host.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 1: Read-Write Host Volume Mount\")\n    print(\"=\" * 60)\n    print(f\"  Host path : {host_dir}\")\n    print(f\"  Mount path: /mnt/shared\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"shared-data\",\n                host=Host(path=host_dir),\n                mountPath=\"/mnt/shared\",\n                readOnly=False,\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # Read existing files from host\n            print(\"\\n  [1] Listing files visible from inside the sandbox:\")\n            await print_exec(sandbox, \"ls -la /mnt/shared/\")\n\n            # Write a file from inside the sandbox\n            print(\"\\n  [2] Writing a file from inside the sandbox:\")\n            await print_exec(\n                sandbox,\n                \"echo 'Hello from sandbox!' > /mnt/shared/sandbox-greeting.txt\",\n            )\n            print(\"  -> Written: /mnt/shared/sandbox-greeting.txt\")\n\n            # Verify the file content\n            print(\"\\n  [3] Reading back the file:\")\n            await print_exec(sandbox, \"cat /mnt/shared/sandbox-greeting.txt\")\n\n            # Check host-side: the file should now exist on the host\n            host_file = Path(host_dir) / \"sandbox-greeting.txt\"\n            if host_file.exists():\n                print(f\"\\n  [4] Verified on host: {host_file}\")\n                print(f\"      Content: {host_file.read_text().strip()}\")\n            else:\n                print(f\"\\n  [4] Note: {host_file} not directly visible (expected on remote Docker)\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 1 completed.\")\n\n\nasync def demo_readonly_mount(config: ConnectionConfig, image: str, host_dir: str) -> None:\n    \"\"\"\n    Scenario 2: Read-only mount.\n\n    Mount the same host directory as read-only. Verify reads work but writes\n    are rejected by the container runtime.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 2: Read-Only Host Volume Mount\")\n    print(\"=\" * 60)\n    print(f\"  Host path : {host_dir}\")\n    print(f\"  Mount path: /mnt/readonly\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"readonly-data\",\n                host=Host(path=host_dir),\n                mountPath=\"/mnt/readonly\",\n                readOnly=True,\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # Read existing files\n            print(\"\\n  [1] Reading files from read-only mount:\")\n            await print_exec(sandbox, \"ls -la /mnt/readonly/\")\n\n            # Read the marker file\n            print(\"\\n  [2] Reading marker.txt:\")\n            await print_exec(sandbox, \"cat /mnt/readonly/marker.txt\")\n\n            # Attempt to write (should fail)\n            print(\"\\n  [3] Attempting to write (should fail):\")\n            result = await sandbox.commands.run(\n                \"touch /mnt/readonly/should-fail.txt 2>&1 || echo 'Write denied (expected)'\"\n            )\n            for msg in result.logs.stdout:\n                print(f\"  {msg.text}\")\n            for msg in result.logs.stderr:\n                print(f\"  {msg.text}\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 2 completed.\")\n\n\nasync def demo_subpath_mount(config: ConnectionConfig, image: str, host_dir: str) -> None:\n    \"\"\"\n    Scenario 3: SubPath mount.\n\n    Mount only a specific subdirectory from the host path. This is useful when\n    the host path contains multiple datasets or project directories, and you\n    want to expose only one of them.\n    \"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Scenario 3: SubPath Host Volume Mount\")\n    print(\"=\" * 60)\n\n    # Ensure subdirectory exists on host\n    sub_dir = Path(host_dir) / \"datasets\" / \"train\"\n    sub_dir.mkdir(parents=True, exist_ok=True)\n    (sub_dir / \"data.csv\").write_text(\"id,value\\n1,100\\n2,200\\n3,300\\n\")\n\n    print(f\"  Host path : {host_dir}\")\n    print(f\"  SubPath   : datasets/train\")\n    print(f\"  Mount path: /mnt/training-data\")\n\n    sandbox = await Sandbox.create(\n        image=image,\n        connection_config=config,\n        timeout=timedelta(minutes=2),\n        volumes=[\n            Volume(\n                name=\"training-data\",\n                host=Host(path=host_dir),\n                mountPath=\"/mnt/training-data\",\n                subPath=\"datasets/train\",\n                readOnly=True,\n            ),\n        ],\n    )\n\n    async with sandbox:\n        try:\n            # List the mounted subdirectory\n            print(\"\\n  [1] Listing mounted subpath content:\")\n            await print_exec(sandbox, \"ls -la /mnt/training-data/\")\n\n            # Read the CSV data\n            print(\"\\n  [2] Reading data.csv:\")\n            await print_exec(sandbox, \"cat /mnt/training-data/data.csv\")\n\n        finally:\n            await sandbox.kill()\n\n    print(\"\\n  Scenario 3 completed.\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\"SANDBOX_IMAGE\", \"ubuntu\")\n    host_dir = os.getenv(\"HOST_VOLUME_PATH\", \"\")\n\n    # If no host path specified, create a temporary directory with sample data\n    if not host_dir:\n        host_dir = tempfile.mkdtemp(prefix=\"opensandbox-vol-\")\n        print(f\"No HOST_VOLUME_PATH set, using temporary directory: {host_dir}\")\n        marker = Path(host_dir) / \"marker.txt\"\n        marker.write_text(\"hello-from-host\\n\")\n        print(f\"Created marker file: {marker}\")\n    else:\n        print(f\"Using HOST_VOLUME_PATH: {host_dir}\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(minutes=3),\n    )\n\n    print(f\"\\nOpenSandbox server : {config.domain}\")\n    print(f\"Sandbox image      : {image}\")\n    print(f\"Host volume path   : {host_dir}\")\n\n    await demo_readwrite_mount(config, image, host_dir)\n    await demo_readonly_mount(config, image, host_dir)\n    await demo_subpath_mount(config, image, host_dir)\n\n    print(\"\\n\" + \"=\" * 60)\n    print(\"All scenarios completed successfully!\")\n    print(\"=\" * 60)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/kimi-cli/README.md",
    "content": "# Kimi CLI Example\n\nRun [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli) (Moonshot AI) inside an OpenSandbox container.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Python 3.12+):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nThen start the local OpenSandbox server, stdout logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Kimi Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / KIMI_API_KEY)\nuv run python examples/kimi-cli/main.py\n```\n\nThe script installs Kimi Code CLI (`pip install kimi-cli`) at runtime (Python 3.12+ is already in the code-interpreter image), then sends a simple request `kimi -p \"Compute 1+1=?.\"`. Auth is passed via `KIMI_API_KEY`, and you can override endpoint/model with `KIMI_BASE_URL` / `KIMI_MODEL_NAME`.\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `KIMI_API_KEY`: Your Moonshot AI / Kimi API key (required)\n- `KIMI_BASE_URL`: Kimi API endpoint (optional; defaults to Kimi's official endpoint)\n- `KIMI_MODEL_NAME`: Model to use (default: `kimi-k2.5`)\n\n## References\n- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli) - Official Kimi Code CLI repository\n- [Moonshot AI Platform](https://platform.moonshot.ai/) - API key management and documentation\n- [Kimi CLI Documentation](https://moonshotai.github.io/kimi-cli/en/) - Full CLI documentation\n"
  },
  {
    "path": "examples/kimi-cli/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_execution_logs(execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    kimi_api_key = _required_env(\"KIMI_API_KEY\")\n    kimi_base_url = os.getenv(\"KIMI_BASE_URL\")\n    kimi_model_name = os.getenv(\"KIMI_MODEL_NAME\", \"kimi-k2.5\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject Kimi settings into container environment for CLI access\n    env = {\n        \"KIMI_API_KEY\": kimi_api_key,\n        \"KIMI_BASE_URL\": kimi_base_url,\n        \"KIMI_MODEL_NAME\": kimi_model_name,\n    }\n    # Drop None values to avoid overriding defaults inside CLI\n    env = {k: v for k, v in env.items() if v is not None}\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # Install Kimi CLI (Python 3.12+ is already in the code-interpreter image)\n        install_exec = await sandbox.commands.run(\n            \"pip install kimi-cli\"\n        )\n        await _print_execution_logs(install_exec)\n\n        # Use Kimi CLI to send a message in non-interactive mode\n        run_exec = await sandbox.commands.run(\n            'kimi -p \"Compute 1+1=?.\"'\n        )\n        await _print_execution_logs(run_exec)\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/kubernetes-pvc-volume-mount/README.md",
    "content": "# Kubernetes PVC Volume Mount Example\n\nThis example demonstrates how to mount Kubernetes PersistentVolumeClaims (PVC) into OpenSandbox containers for persistent storage. Data written to a PVC persists across sandbox lifecycles -- when a sandbox is killed and a new one is created with the same PVC, previously written data is still available.\n\n## Prerequisites\n\n### 1. CSI Driver\n\nKubernetes PVC requires a [Container Storage Interface (CSI)](https://kubernetes-csi.github.io/docs/drivers.html) driver to provision and manage storage. Install the CSI driver that matches your storage backend. Refer to your storage vendor's documentation for installation instructions.\n\nFor example, [Alibaba Cloud CSI Driver](https://github.com/kubernetes-sigs/alibaba-cloud-csi-driver) supports the following storage types:\n\n- **Cloud Disk (EBS)** -- block storage, suitable for high-performance single-node read-write scenarios\n- **NAS** -- shared file storage, supports multi-node read-write (ReadWriteMany)\n- **OSS** -- object storage, suitable for large-scale data read and shared access scenarios\n- **CPFS** -- high-performance parallel file system\n- **LVM** -- local volume management\n\n### 2. Create a PersistentVolumeClaim\n\nCreate a PVC in the namespace where OpenSandbox runs:\n\n```yaml\n# pvc.yaml\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: my-pvc\n  namespace: opensandbox\nspec:\n  accessModes:\n    - ReadWriteOnce\n  storageClassName: <your-storage-class>\n  resources:\n    requests:\n      storage: 10Gi\n```\n\n```shell\nkubectl apply -f pvc.yaml\n```\n\nVerify the PVC is bound:\n\n```shell\nkubectl get pvc my-pvc -n opensandbox\n```\n\n### 3. OpenSandbox Server\n\nEnsure the OpenSandbox server is running with Kubernetes runtime and BatchSandbox workload provider.\n\n### 4. Python SDK\n\n```shell\nuv pip install opensandbox\n```\n\n## Run the Example\n\n```shell\nexport OPEN_SANDBOX_API_KEY=your-api-key\nexport OPEN_SANDBOX_BASE_URL=http://localhost:8080\nexport SANDBOX_PVC_NAME=my-pvc\n\npython examples/kubernetes-pvc-volume-mount/main.py\n```\n\n## What the Example Does\n\n1. Creates a sandbox with a PVC mounted at `/mnt/data`\n2. Writes a test file to the PVC\n3. Reads the file back to verify\n4. Kills the sandbox\n5. Creates a new sandbox with the same PVC\n6. Reads the file again to verify data persistence across sandbox lifecycles\n\n## Usage\n\n```python\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import PVC, Volume\n\nsandbox = await Sandbox.create(\n    image=\"python:3.11\",\n    volumes=[\n        Volume(\n            name=\"data-volume\",\n            pvc=PVC(claimName=\"my-pvc\"),\n            mountPath=\"/mnt/data\",\n            readOnly=False,\n        ),\n    ],\n)\n\n# Run commands against the mounted volume\nresult = await sandbox.commands.run(\"ls -la /mnt/data\")\noutput = \"\\n\".join(msg.text for msg in result.logs.stdout)\nprint(output)\n```\n\n## Important Notes\n\n- **Pool mode does not support volumes.** Use template mode instead.\n- PVC must exist before creating the sandbox.\n- PVC is not deleted when the sandbox is killed.\n- Multiple sandboxes can mount the same PVC if the access mode allows (e.g. `ReadWriteMany`).\n\n## References\n\n- [OSEP-0003: Volume and VolumeBinding Support](../../oseps/0003-volume-and-volumebinding-support.md)\n- [Kubernetes CSI Drivers](https://kubernetes-csi.github.io/docs/drivers.html)\n- [Alibaba Cloud CSI Driver](https://github.com/kubernetes-sigs/alibaba-cloud-csi-driver)\n"
  },
  {
    "path": "examples/kubernetes-pvc-volume-mount/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n#!/usr/bin/env python3\n\"\"\"\nKubernetes PVC Volume Mount Example\n\nThis example demonstrates how to use PersistentVolumeClaims (PVC) with OpenSandbox\nin a Kubernetes environment. It verifies that data written to a PVC persists across\nsandbox lifecycles.\n\nPrerequisites:\n1. Kubernetes cluster with CSI driver installed\n2. PVC created in the target namespace\n3. OpenSandbox server running with Kubernetes runtime\n\nUsage:\n    export OPEN_SANDBOX_API_KEY=your-api-key\n    export OPEN_SANDBOX_BASE_URL=http://localhost:8080\n    python main.py\n\"\"\"\n\nimport asyncio\nimport os\nfrom datetime import timedelta\nfrom opensandbox import Sandbox\nfrom opensandbox.models.sandboxes import PVC, Volume\nfrom opensandbox.config.connection import ConnectionConfig\n\n# Configuration\nPVC_NAME = os.getenv(\"SANDBOX_PVC_NAME\", \"my-pvc\")\nIMAGE = os.getenv(\"SANDBOX_IMAGE\", \"python:3.11\")\n\n# Connection config with extended timeout for sandbox creation\nCONNECTION_CONFIG = ConnectionConfig(\n    request_timeout=timedelta(minutes=10),\n)\n\nVOLUMES = [\n    Volume(\n        name=\"data-volume\",\n        pvc=PVC(claimName=PVC_NAME),\n        mountPath=\"/mnt/data\",\n        readOnly=False,\n    ),\n]\n\n\nasync def basic_pvc_mount():\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Step 1: Create sandbox and write data to PVC\")\n    print(\"=\" * 60)\n    print(f\"  PVC name  : {PVC_NAME}\")\n    print(f\"  Mount path: /mnt/data\")\n    print()\n\n    sandbox = await Sandbox.create(\n        image=IMAGE,\n        timeout=timedelta(minutes=10),\n        ready_timeout=timedelta(minutes=10),\n        volumes=VOLUMES,\n        connection_config=CONNECTION_CONFIG,\n    )\n    print(f\"  Created sandbox: {sandbox.id}\")\n\n    # Write a test file to PVC\n    await sandbox.commands.run(\n        \"python -c \\\"with open('/mnt/data/sandbox-test.txt', 'w') as f: f.write('Hello from OpenSandbox!')\\\"\"\n    )\n    print(\"  Written test file to /mnt/data/sandbox-test.txt\")\n\n    # Read it back\n    result = await sandbox.commands.run(\"cat /mnt/data/sandbox-test.txt\")\n    content = \"\\n\".join(msg.text for msg in result.logs.stdout)\n    print(f\"  Read back: {content.strip()}\")\n\n    # Kill the sandbox\n    await sandbox.kill()\n    print(\"  Sandbox killed.\")\n\n    # ---- Verify persistence ----\n    print(\"\\n\" + \"=\" * 60)\n    print(\"Step 2: Create new sandbox and verify data persistence\")\n    print(\"=\" * 60)\n\n    sandbox2 = await Sandbox.create(\n        image=IMAGE,\n        timeout=timedelta(minutes=10),\n        ready_timeout=timedelta(minutes=10),\n        volumes=VOLUMES,\n        connection_config=CONNECTION_CONFIG,\n    )\n    print(f\"  Created new sandbox: {sandbox2.id}\")\n\n    # Read the file written by the previous sandbox\n    result = await sandbox2.commands.run(\"cat /mnt/data/sandbox-test.txt\")\n    content = \"\\n\".join(msg.text for msg in result.logs.stdout).strip()\n    print(f\"  Read back from new sandbox: {content}\")\n\n    if content == \"Hello from OpenSandbox!\":\n        print(\"  Data persistence verified!\")\n    else:\n        print(f\"  ERROR: Expected 'Hello from OpenSandbox!', got '{content}'\")\n\n    await sandbox2.kill()\n    print(\"  Sandbox killed.\")\n\n\nasync def main():\n    \"\"\"Main entry point.\"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"OpenSandbox Kubernetes PVC Volume Mount Example\")\n    print(\"=\" * 60)\n    print(f\"PVC Name   : {PVC_NAME}\")\n    print(f\"Image      : {IMAGE}\")\n    print()\n\n    try:\n        await basic_pvc_mount()\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"All steps completed successfully!\")\n        print(\"=\" * 60)\n\n    except Exception as e:\n        print(f\"\\nError: {e}\")\n        raise\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/langgraph/README.md",
    "content": "# LangGraph Agent + OpenSandbox Example\n\nIntegrate LangGraph with OpenSandbox using a graph-driven control flow. The example uses\nexplicit state machine nodes to create, prepare, run, inspect, and clean up a sandbox, plus\na decision node to retry with a fallback command if the run step fails.\n\n## Start OpenSandbox server [local]\n\nPre-pull the code-interpreter image (includes Python):\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n\n# use docker hub\n# docker pull opensandbox/code-interpreter:v1.0.2\n```\n\nStart the local OpenSandbox server, logs will be visible in the terminal:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Run the example\n\n```shell\n# Install OpenSandbox + LangGraph deps\nuv pip install opensandbox langgraph langchain-anthropic\n\n# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / ANTHROPIC_API_KEY)\nuv run python examples/langgraph/main.py\n```\n\nThe workflow writes files, executes a job, retries with a fallback command on failure (default\n`python` vs `python3`), then summarizes results with Claude and cleans up the sandbox instance.\n\n![LangGraph + OpenSandbox screenshot](./screenshot.jpg)\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local)\n- `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `ANTHROPIC_API_KEY`: Your Anthropic API key (required if `ANTHROPIC_AUTH_TOKEN` is unset)\n- `ANTHROPIC_AUTH_TOKEN`: Alternate Anthropic auth token (uses `Authorization` header)\n- `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` should not be set together\n- `ANTHROPIC_BASE_URL`: Anthropic API endpoint override (optional)\n- `ANTHROPIC_MODEL`: Model to use (default: `claude-3-5-sonnet-latest`)\n\n## References\n- [LangGraph](https://langchain-ai.github.io/langgraph/) - Agent workflow framework\n"
  },
  {
    "path": "examples/langgraph/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport os\nfrom datetime import timedelta\nfrom typing import TypedDict\n\nfrom langchain_anthropic import ChatAnthropic\nfrom langgraph.graph import END, StateGraph\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nclass WorkflowState(TypedDict):\n    sandbox: Sandbox | None\n    run_output: str\n    summary: str\n    last_error: str\n    attempt: int\n    max_attempts: int\n    command: str\n    fallback_command: str\n    cleaned: bool\n\n\ndef _configure_anthropic_env() -> None:\n    api_key = os.getenv(\"ANTHROPIC_API_KEY\")\n    auth_token = os.getenv(\"ANTHROPIC_AUTH_TOKEN\")\n\n    if auth_token:\n        os.environ[\"ANTHROPIC_AUTH_TOKEN\"] = auth_token\n        os.environ.pop(\"ANTHROPIC_API_KEY\", None)\n        return\n\n    if api_key:\n        os.environ[\"ANTHROPIC_API_KEY\"] = api_key\n        os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n        return\n\n    raise RuntimeError(\"ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is required\")\n\n\ndef _build_llm() -> ChatAnthropic:\n    _configure_anthropic_env()\n    anthropic_base_url = os.getenv(\"ANTHROPIC_BASE_URL\")\n    model_name = os.getenv(\"ANTHROPIC_MODEL\", \"claude-3-5-sonnet-latest\")\n\n    return ChatAnthropic(\n        model=model_name,\n        anthropic_api_url=anthropic_base_url,\n    )\n\n\ndef _format_execution(execution) -> str:\n    stdout = \"\\n\".join(msg.text for msg in execution.logs.stdout)\n    stderr = \"\\n\".join(msg.text for msg in execution.logs.stderr)\n\n    if execution.error:\n        stderr = \"\\n\".join(\n            [\n                stderr,\n                f\"[error] {execution.error.name}: {execution.error.value}\",\n            ]\n        ).strip()\n\n    output = stdout.strip()\n    if stderr:\n        output = \"\\n\".join([output, f\"[stderr]\\n{stderr}\"]).strip()\n    return output or \"(no output)\"\n\n\nasync def create_sandbox(state: WorkflowState) -> WorkflowState:\n    print(\"[create] Creating sandbox\")\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    )\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=120),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n    )\n\n    print(f\"[create] Sandbox ready: {sandbox.id}\")\n\n    return {**state, \"sandbox\": sandbox}\n\n\nasync def prepare_workspace(state: WorkflowState) -> WorkflowState:\n    print(\"[prepare] Writing job files\")\n    sandbox = state[\"sandbox\"]\n    if sandbox is None:\n        raise RuntimeError(\"Sandbox not initialized\")\n\n    await sandbox.files.write_file(\n        \"/tmp/math.py\",\n        \"result = 137 * 42\\nprint(result)\\n\",\n    )\n    await sandbox.files.write_file(\n        \"/tmp/notes.txt\",\n        \"LangGraph + OpenSandbox\\n\",\n    )\n\n    print(\"[prepare] Files written\")\n\n    return state\n\n\nasync def run_job(state: WorkflowState) -> WorkflowState:\n    attempt = state[\"attempt\"] + 1\n    max_attempts = state[\"max_attempts\"]\n    command = state.get(\"command\") or \"python3 /tmp/math.py\"\n    print(f\"[run] Executing job (attempt {attempt}/{max_attempts})\")\n    sandbox = state[\"sandbox\"]\n    if sandbox is None:\n        raise RuntimeError(\"Sandbox not initialized\")\n\n    execution = await sandbox.commands.run(command)\n    run_output = _format_execution(execution)\n    last_error = \"\"\n    next_command = command\n\n    if execution.error:\n        last_error = f\"{execution.error.name}: {execution.error.value}\"\n        if attempt < max_attempts:\n            next_command = state.get(\"fallback_command\", \"python /tmp/math.py\")\n            print(f\"[run] Failed, scheduling fallback: {next_command}\")\n\n    print(f\"[run] Output: {run_output}\")\n\n    return {\n        **state,\n        \"run_output\": run_output,\n        \"last_error\": last_error,\n        \"attempt\": attempt,\n        \"command\": next_command,\n    }\n\n\ndef decide_next(state: WorkflowState) -> str:\n    if state.get(\"last_error\") and state[\"attempt\"] < state[\"max_attempts\"]:\n        print(\"[decide] Retry with fallback command\")\n        return \"run\"\n\n    print(\"[decide] Proceeding to inspect\")\n    return \"inspect\"\n\n\nasync def inspect_results(state: WorkflowState) -> WorkflowState:\n    print(\"[inspect] Reading notes and summarizing\")\n    sandbox = state[\"sandbox\"]\n    if sandbox is None:\n        raise RuntimeError(\"Sandbox not initialized\")\n\n    notes = await sandbox.files.read_file(\"/tmp/notes.txt\")\n    llm = _build_llm()\n    prompt = (\n        \"Summarize the sandbox run result and notes in one sentence. \"\n        f\"Run output: {state.get('run_output', '')}. \"\n        f\"Notes: {notes.strip()}.\"\n    )\n    response = await llm.ainvoke(prompt)\n\n    print(f\"[inspect] Summary: {response.content}\")\n\n    return {**state, \"summary\": response.content}\n\n\nasync def cleanup_sandbox(state: WorkflowState) -> WorkflowState:\n    print(\"[cleanup] Cleaning up sandbox\")\n    sandbox = state.get(\"sandbox\")\n    if sandbox is not None:\n        await sandbox.kill()\n        await sandbox.close()\n\n    print(\"[cleanup] Done\")\n\n    return {**state, \"sandbox\": None, \"cleaned\": True}\n\n\nasync def main() -> None:\n    graph = StateGraph(WorkflowState)\n    graph.add_node(\"create\", create_sandbox)\n    graph.add_node(\"prepare\", prepare_workspace)\n    graph.add_node(\"run\", run_job)\n    graph.add_node(\"inspect\", inspect_results)\n    graph.add_node(\"cleanup\", cleanup_sandbox)\n    graph.set_entry_point(\"create\")\n    graph.add_edge(\"create\", \"prepare\")\n    graph.add_edge(\"prepare\", \"run\")\n    graph.add_conditional_edges(\n        \"run\",\n        decide_next,\n        {\n            \"run\": \"run\",\n            \"inspect\": \"inspect\",\n        },\n    )\n    graph.add_edge(\"inspect\", \"cleanup\")\n    graph.add_edge(\"cleanup\", END)\n    app = graph.compile()\n\n    initial_state = {\n        \"sandbox\": None,\n        \"run_output\": \"\",\n        \"summary\": \"\",\n        \"last_error\": \"\",\n        \"attempt\": 0,\n        \"max_attempts\": 2,\n        \"command\": \"python3 /tmp/math.py\",\n        \"fallback_command\": \"python /tmp/math.py\",\n        \"cleaned\": False,\n    }\n\n    state = initial_state\n    try:\n        async for update in app.astream(initial_state, stream_mode=\"values\"):\n            state = update\n    finally:\n        if not state.get(\"cleaned\"):\n            sandbox = state.get(\"sandbox\")\n            if sandbox is not None:\n                await sandbox.kill()\n                await sandbox.close()\n\n    print(f\"Run output: {state['run_output']}\")\n    print(f\"Summary: {state['summary']}\")\n\n\nif __name__ == \"__main__\":\n    import asyncio\n\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/nullclaw/README.md",
    "content": "# Nullclaw Gateway Example\n\nLaunch a [Nullclaw](https://github.com/nullclaw/nullclaw) Gateway inside an OpenSandbox instance and expose its HTTP endpoint. The script polls the gateway health check until it returns HTTP 200, then prints the reachable endpoint.\n\n## Start OpenSandbox server [local]\n\nYou can find the latest Nullclaw container image [here](https://github.com/nullclaw/nullclaw/pkgs/container/nullclaw).\n\n### Notes (Docker runtime requirement)\n\nThe server uses `runtime.type = \"docker\"` by default, so it **must** be able to reach a running Docker daemon.\n\n- **Docker Desktop**: ensure Docker Desktop is running, then verify with `docker version`.\n- **Colima (macOS)**: start it first (`colima start`) and export the socket before starting the server:\n\n```shell\nexport DOCKER_HOST=\"unix://${HOME}/.colima/default/docker.sock\"\n```\n\nPre-pull the Nullclaw image:\n\n```shell\ndocker pull ghcr.io/nullclaw/nullclaw:latest\n```\n\nStart the OpenSandbox server (logs will stay in the terminal):\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\nIf you see errors like `FileNotFoundError: [Errno 2] No such file or directory` from `docker/transport/unixconn.py`, it usually means the Docker unix socket is missing or Docker is not running.\n\n## Create and Access the Nullclaw Sandbox\n\nThis example is hard-coded for a quick start:\n- OpenSandbox server: `http://localhost:8080`\n- Image: `ghcr.io/nullclaw/nullclaw:latest`\n- Gateway port: `3000`\n- Timeout: `3600s`\n\nInstall dependencies from the project root:\n\n```shell\nuv pip install opensandbox requests\n```\n\nRun the example:\n\n```shell\nuv run python examples/nullclaw/main.py\n```\n\nYou should see output similar to:\n\n```text\nCreating nullclaw sandbox with image=ghcr.io/nullclaw/nullclaw:latest on OpenSandbox server http://localhost:8080...\n[check] sandbox ready after 0.3s\nNullclaw gateway started. Please refer to 127.0.0.1:56234\n```\n\nThe endpoint printed at the end (e.g., `127.0.0.1:56234`) is the Nullclaw Gateway address exposed from the sandbox.\n\nBy default, Nullclaw requires pairing before authenticated endpoints (for example, `/webhook`) can be used. The `/health` endpoint remains publicly accessible.\n\n## References\n- [Nullclaw](https://github.com/nullclaw/nullclaw) — Minimal AI assistant runtime (678 KB static Zig binary)\n- [Nullclaw Documentation](https://nullclaw.github.io) — Full documentation\n- [OpenSandbox Python SDK](https://pypi.org/project/opensandbox/)\n"
  },
  {
    "path": "examples/nullclaw/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport time\nfrom datetime import timedelta\n\nimport requests\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\n\n\ndef check_nullclaw(sbx: SandboxSync) -> bool:\n    \"\"\"\n    Health check: poll nullclaw gateway until it returns 200.\n\n    Returns:\n        True  when ready\n        False on timeout or any exception\n    \"\"\"\n    try:\n        endpoint = sbx.get_endpoint(3000)\n        start = time.perf_counter()\n        url = f\"http://{endpoint.endpoint}/health\"\n        for _ in range(150):  # max for ~30s\n            try:\n                resp = requests.get(url, timeout=1)\n                if resp.status_code == 200:\n                    elapsed = time.perf_counter() - start\n                    print(f\"[check] sandbox ready after {elapsed:.1f}s\")\n                    return True\n            except Exception:\n                pass\n            time.sleep(0.2)\n        return False\n    except Exception as exc:\n        print(f\"[check] failed: {exc}\")\n        return False\n\n\ndef main() -> None:\n    server = \"http://localhost:8080\"\n    image = \"ghcr.io/nullclaw/nullclaw:latest\"\n    timeout_seconds = 3600  # 1 hour\n\n    print(f\"Creating nullclaw sandbox with image={image} on OpenSandbox server {server}...\")\n    sandbox = SandboxSync.create(\n        image=image,\n        timeout=timedelta(seconds=timeout_seconds),\n        metadata={\"example\": \"nullclaw\"},\n        connection_config=ConnectionConfigSync(domain=server),\n        health_check=check_nullclaw,\n        # use network policy to limit nullclaw network accesses\n        network_policy=NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"openrouter.ai\")],\n        ),\n    )\n\n    endpoint = sandbox.get_endpoint(3000)\n    print(f\"Nullclaw gateway started. Please refer to {endpoint.endpoint}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "examples/openclaw/README.md",
    "content": "# OpenClaw Gateway Example\n\nLaunch an [OpenClaw](https://github.com/openclaw/openclaw) Gateway inside an OpenSandbox instance and expose its HTTP endpoint. The script polls the gateway until it returns HTTP 200, then prints the reachable endpoint.\n\n## Start OpenSandbox server [local]\n\nYou can find the latest OpenClaw container image [here](https://github.com/openclaw/openclaw/pkgs/container/openclaw).\n\n### Notes (Docker runtime requirement)\n\nThe server uses `runtime.type = \"docker\"` by default, so it **must** be able to reach a running Docker daemon.\n\n- **Docker Desktop**: ensure Docker Desktop is running, then verify with `docker version`.\n- **Colima (macOS)**: start it first (`colima start`) and export the socket before starting the server:\n\n```shell\nexport DOCKER_HOST=\"unix://${HOME}/.colima/default/docker.sock\"\n```\n\nPre-pull the OpenClaw image:\n\n```shell\ndocker pull ghcr.io/openclaw/openclaw:latest\n```\n\nStart the OpenSandbox server (logs will stay in the terminal):\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\nIf you see errors like `FileNotFoundError: [Errno 2] No such file or directory` from `docker/transport/unixconn.py`, it usually means the Docker unix socket is missing or Docker is not running.\n\n## Create and Access the OpenClaw Sandbox\n\nThis example is hard-coded for a quick start:\n- OpenSandbox server: `http://localhost:8080`\n- Image: `ghcr.io/openclaw/openclaw:latest`\n- Gateway port: `18789`\n- Timeout: `3600s`\n- Token: `OPENCLAW_GATEWAY_TOKEN` (default: `dummy-token-for-sandbox`)\n\nInstall dependencies from the project root:\n\n```shell\nuv pip install opensandbox requests\n```\n\nRun the example (set a real token if you need authenticated access):\n\n```shell\nexport OPENCLAW_GATEWAY_TOKEN=\"$(openssl rand -hex 32)\"\nuv run python examples/openclaw/main.py\n```\n\nYou should see output similar to:\n\n```text\nCreating openclaw sandbox with image=ghcr.io/openclaw/openclaw:latest on OpenSandbox server http://localhost:8080...\n[check] sandbox ready after 7.1s\nOpenclaw started finished. Please refer to 127.0.0.1:56123\n```\n\nThe endpoint printed at the end (e.g., `127.0.0.1:56123`) is the OpenClaw Gateway address exposed from the sandbox.\n\n## References\n- [OpenClaw](https://github.com/openclaw/openclaw)\n- [OpenSandbox Python SDK](https://pypi.org/project/opensandbox/)\n"
  },
  {
    "path": "examples/openclaw/README_zh.md",
    "content": "# OpenClaw Gateway 示例\n\n在 OpenSandbox 沙箱实例中启动 [OpenClaw](https://github.com/openclaw/openclaw) Gateway，并暴露 HTTP 访问端点。脚本会轮询 Gateway，直到返回 HTTP 200，然后打印可访问地址。\n\n## 启动 OpenSandbox Server（本地）\n\n最新 OpenClaw 镜像可在这里查看：[OpenClaw Container Registry](https://github.com/openclaw/openclaw/pkgs/container/openclaw)。\n\n### 注意事项（Docker 运行时要求）\n\n默认情况下，OpenSandbox Server 使用 `runtime.type = \"docker\"`，因此 **必须** 能访问可用的 Docker daemon。\n\n- **Docker Desktop**：确保已启动，然后执行 `docker version` 验证。\n- **Colima（macOS）**：先启动 (`colima start`)，再在启动 server 前导出 socket：\n\n```shell\nexport DOCKER_HOST=\"unix://${HOME}/.colima/default/docker.sock\"\n```\n\n预拉取 OpenClaw 镜像：\n\n```shell\ndocker pull ghcr.io/openclaw/openclaw:latest\n```\n\n启动 OpenSandbox Server（日志会持续输出在当前终端）：\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n如果出现 `docker/transport/unixconn.py` 的 `FileNotFoundError: [Errno 2] No such file or directory`，通常表示 Docker unix socket 不存在或 Docker 未启动。\n\n## 创建并访问 OpenClaw Sandbox\n\n该示例为快速体验预置了以下参数：\n\n- OpenSandbox Server：`http://localhost:8080`\n- 镜像：`ghcr.io/openclaw/openclaw:latest`\n- Gateway 端口：`18789`\n- 超时时间：`3600s`\n- Token：`OPENCLAW_GATEWAY_TOKEN`（默认：`dummy-token-for-sandbox`）\n\n在项目根目录安装依赖：\n\n```shell\nuv pip install opensandbox requests\n```\n\n运行示例（如需鉴权访问请设置真实 token）：\n\n```shell\nexport OPENCLAW_GATEWAY_TOKEN=\"$(openssl rand -hex 32)\"\nuv run python examples/openclaw/main.py\n```\n\n预期输出类似：\n\n```text\nCreating openclaw sandbox with image=ghcr.io/openclaw/openclaw:latest on OpenSandbox server http://localhost:8080...\n[check] sandbox ready after 7.1s\nOpenclaw started finished. Please refer to 127.0.0.1:56123\n```\n\n最后打印的地址（如 `127.0.0.1:56123`）就是沙箱中 OpenClaw Gateway 的可访问端点。\n\n## 参考\n\n- [OpenClaw](https://github.com/openclaw/openclaw)\n- [OpenSandbox Python SDK](https://pypi.org/project/opensandbox/)\n"
  },
  {
    "path": "examples/openclaw/main.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport os\nimport time\nfrom datetime import timedelta\n\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\nimport requests\n\n\ndef check_openclaw(sbx: SandboxSync) -> bool:\n    \"\"\"\n    Health check: poll openclaw until it returns 200.\n\n    Returns:\n        True  when ready\n        False on timeout or any exception\n    \"\"\"\n    try:\n        endpoint = sbx.get_endpoint(18789)\n        start = time.perf_counter()\n        url = f\"http://{endpoint.endpoint}\"\n        for _ in range(150):  # max for ~30s\n            try:\n                resp = requests.get(url, timeout=1)\n                if resp.status_code == 200:\n                    elapsed = time.perf_counter() - start\n                    print(f\"[check] sandbox ready after {elapsed:.1f}s\")\n                    return True\n            except Exception as exc:\n                pass\n            time.sleep(0.2)\n        return False\n    except Exception as exc:\n        print(f\"[check] failed: {exc}\")\n        return False\n\n\ndef main() -> None:\n    server = \"http://localhost:8080\"\n    image = \"ghcr.io/openclaw/openclaw:latest\"\n    timeout_seconds = 3600  # 1 hour\n    token = os.getenv(\"OPENCLAW_GATEWAY_TOKEN\", \"dummy-token-for-sandbox\")\n\n    print(f\"Creating openclaw sandbox with image={image} on OpenSandbox server {server}...\")\n    sandbox = SandboxSync.create(\n        image=image,\n        timeout=timedelta(seconds=timeout_seconds),\n        metadata={\"example\": \"openclaw\"},\n        entrypoint=[\"node dist/index.js gateway --bind=lan --port 18789 --allow-unconfigured --verbose\"],\n        connection_config=ConnectionConfigSync(domain=server),\n        health_check=check_openclaw,\n        # env for openclaw\n        env={\n            \"OPENCLAW_GATEWAY_TOKEN\": token\n        },\n        # use network policy to limit openclaw network accesses\n        network_policy=NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        ),\n    )\n\n    endpoint = sandbox.get_endpoint(18789)\n    print(f\"Openclaw started finished. Please refer to {endpoint.endpoint}\")\n\nif __name__ == \"__main__\":\n    main()"
  },
  {
    "path": "examples/playwright/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM debian:12-slim\n\n#----------------------\n# Install all prerequisite packages in one layer\nRUN apt-get update && apt-get install -y \\\n    python3 \\\n    python3-pip \\\n    python3-venv \\\n    wget \\\n    ca-certificates \\\n    curl \\\n    git \\\n    nodejs \\\n    npm \\\n    --no-install-recommends \\\n    && rm -rf /var/lib/apt/lists/*\n\n#----------------------\n# Create a non-root user and browser cache dir early (needed before chown)\nRUN groupadd -r playwright && useradd -r -g playwright playwright \\\n    && mkdir -p /home/playwright /ms-playwright \\\n    && chown -R playwright:playwright /home/playwright /ms-playwright\n\n#----------------------\n# Install Playwright and browser binaries\n# Use an isolated venv to avoid PEP 668 issues\nENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright\n\nRUN python3 -m venv /venv \\\n    && /venv/bin/pip install --no-cache-dir --upgrade pip \\\n    && /venv/bin/pip install --no-cache-dir playwright \\\n    && npm install -g @playwright/mcp \\\n    && /venv/bin/playwright install --with-deps chromium\n\nENV PATH=\"/venv/bin:${PATH}\"\n\n#----------------------\n# Configure user, etc\n\nWORKDIR /home/playwright\n\nUSER playwright\n\n# Default to bash\nCMD [\"bash\"]\n"
  },
  {
    "path": "examples/playwright/README.md",
    "content": "# Playwright Example\n\nAccess web pages in headless mode using Playwright + Chromium in OpenSandbox to scrape title/body snippets.\n\n## Build the Playwright Sandbox Image\n\nThe Dockerfile in this directory builds a sandbox image with Playwright and Chromium pre-installed:\n\n```shell\ncd examples/playwright\ndocker build -t opensandbox/playwright:latest .\n```\n\nThis image includes:\n- Playwright Python package\n- Chromium browser binaries\n- Node.js and npm (for Playwright MCP)\n- Non-root user (playwright) for security\n\n## Start OpenSandbox server [local]\n\nPre-pull the Playwright image:\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/playwright:latest\n```\n\nStart the local OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the Playwright Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\nuv run python examples/playwright/main.py\n```\n\nThe script launches Chromium in headless mode to access the target URL, prints title/body snippets, and saves a full-page screenshot to `/home/playwright/screenshot.png` inside the sandbox. It also downloads the screenshot to the local working directory as `./screenshot.png`. Uses the prebuilt Playwright image by default.\n\n![Playwright screenshot](./screenshot.png)\n\n## References\n- [Playwright](https://playwright.dev/)\n"
  },
  {
    "path": "examples/playwright/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm playwright-builder || true\n\ndocker buildx create --use --name playwright-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\ndocker buildx build \\\n  -t opensandbox/playwright:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/playwright:${TAG} \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "examples/playwright/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\nfrom pathlib import Path\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_logs(label: str, execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[{label} stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[{label} stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[{label} error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"opensandbox/playwright:latest\",\n    )\n    python_version = os.getenv(\"PYTHON_VERSION\", \"3.11\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject Python version into container environment\n    env = {\"PYTHON_VERSION\": python_version}\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # Playwright and Chromium are pre-installed in the image\n        # Run browser script\n        browse_exec = await sandbox.commands.run(\n            \"python - <<'PY'\\n\"\n            \"import asyncio\\n\"\n            \"import os\\n\"\n            \"from pathlib import Path\\n\"\n            \"from playwright.async_api import async_playwright\\n\"\n            \"\\n\"\n            \"URL = os.environ.get('TARGET_URL', 'https://example.com')\\n\"\n            \"SCREENSHOT_PATH = Path('/home/playwright/screenshot.png')\\n\"\n            \"SCREENSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)\\n\"\n            \"\\n\"\n            \"async def run():\\n\"\n            \"    async with async_playwright() as p:\\n\"\n            \"        browser = await p.chromium.launch(headless=True)\\n\"\n            \"        page = await browser.new_page()\\n\"\n            \"        await page.goto(URL, wait_until='networkidle')\\n\"\n            \"        title = await page.title()\\n\"\n            \"        content = await page.text_content('body')\\n\"\n            \"        await page.screenshot(path=str(SCREENSHOT_PATH), full_page=True)\\n\"\n            \"        print('title:', title)\\n\"\n            \"        print('screenshot saved at:', SCREENSHOT_PATH)\\n\"\n            \"        if content:\\n\"\n            \"            snippet = content.strip().replace('\\\\n', ' ')\\n\"\n            \"            print('content snippet:', snippet[:300])\\n\"\n            \"        await browser.close()\\n\"\n            \"\\n\"\n            \"asyncio.run(run())\\n\"\n            \"PY\"\n        )\n        await _print_logs(\"browse\", browse_exec)\n\n        # Download screenshot from sandbox to local disk\n        screenshot_remote = \"/home/playwright/screenshot.png\"\n        screenshot_local = Path(\"screenshot.png\")\n        try:\n            data = await sandbox.files.read_bytes(screenshot_remote)\n            screenshot_local.write_bytes(data)\n            print(f\"\\nDownloaded screenshot to: {screenshot_local.resolve()}\")\n        except Exception as e:\n            print(f\"\\nFailed to download screenshot from {screenshot_remote}: {e}\")\n\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/rl-training/README.md",
    "content": "# Reinforcement Learning Sandbox Example\n\nDemonstrates running a basic RL training loop (CartPole + DQN) inside an isolated OpenSandbox container. The example installs RL dependencies in the sandbox, trains a policy, saves a checkpoint, and returns a training summary.\n\n## Start OpenSandbox server [local]\n\nStart the local OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Run the Example\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\n# Run the example\nuv run python examples/rl-training/main.py\n```\n\nThe script provisions a sandbox, installs RL dependencies, trains a DQN agent on CartPole, saves a checkpoint, and prints the JSON training summary.\n\n![RL training screenshot](./screenshot.jpg)\n\n## Environment Variables\n\n- `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`)\n- `SANDBOX_API_KEY`: API key if your server requires authentication\n- `SANDBOX_IMAGE`: Docker image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`)\n- `RL_TIMESTEPS`: Training timesteps to run (default: `5000`)\n\n## TensorBoard\n\nThe training script logs to `runs/`. To visualize metrics, open a shell in the sandbox and run:\n\n```shell\ntensorboard --logdir runs --host 0.0.0.0 --port 6006\n```\n"
  },
  {
    "path": "examples/rl-training/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nimport textwrap\nfrom datetime import timedelta\nfrom pathlib import Path\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\ndef _load_requirements() -> str:\n    requirements_path = Path(__file__).with_name(\"requirements.txt\")\n    return requirements_path.read_text(encoding=\"utf-8\")\n\n\ndef _training_script() -> str:\n    return textwrap.dedent(\n        \"\"\"\n        import json\n        import os\n\n        import gymnasium as gym\n        from stable_baselines3 import DQN\n        from stable_baselines3.common.evaluation import evaluate_policy\n\n        timesteps = int(os.getenv(\"RL_TIMESTEPS\", \"5000\"))\n        tensorboard_log = os.getenv(\"RL_TENSORBOARD_LOG\", \"runs\")\n\n        env = gym.make(\"CartPole-v1\")\n        model = DQN(\n            \"MlpPolicy\",\n            env,\n            verbose=1,\n            tensorboard_log=tensorboard_log,\n            learning_rate=1e-3,\n            buffer_size=10000,\n            learning_starts=1000,\n            batch_size=32,\n            train_freq=4,\n            gradient_steps=1,\n        )\n\n        model.learn(total_timesteps=timesteps)\n\n        os.makedirs(\"checkpoints\", exist_ok=True)\n        checkpoint_path = \"checkpoints/cartpole_dqn\"\n        model.save(checkpoint_path)\n\n        mean_reward, std_reward = evaluate_policy(model, env, n_eval_episodes=5)\n        summary = {\n            \"timesteps\": timesteps,\n            \"mean_reward\": float(mean_reward),\n            \"std_reward\": float(std_reward),\n            \"checkpoint_path\": f\"{checkpoint_path}.zip\",\n        }\n        with open(\"training_summary.json\", \"w\", encoding=\"utf-8\") as handle:\n            json.dump(summary, handle, indent=2)\n\n        print(\"Training summary:\", summary)\n        env.close()\n        \"\"\"\n    ).lstrip()\n\n\nasync def _print_execution_logs(execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[error] {execution.error.name}: {execution.error.value}\")\n\n\ndef _execution_failed(execution) -> bool:\n    return execution.error is not None\n\n\nasync def _run_command(sandbox: Sandbox, command: str) -> bool:\n    execution = await sandbox.commands.run(command)\n    await _print_execution_logs(execution)\n    return not _execution_failed(execution)\n\n\ndef _with_python_env(command: str) -> str:\n    return (\n        \"bash -lc '\"\n        \"source /opt/opensandbox/code-interpreter-env.sh \"\n        \"python ${PYTHON_VERSION:-3.14} >/dev/null \"\n        \"&& \"\n        f\"{command}\"\n        \"'\"\n    )\n\n\nasync def _ensure_pip(sandbox: Sandbox) -> bool:\n    bootstrap_commands = [\n        _with_python_env(\"python3 -m pip --version\"),\n        _with_python_env(\"python3 -m ensurepip --upgrade\"),\n        \"apt-get update && apt-get install -y python3-pip\",\n        \"apk add --no-cache py3-pip\",\n    ]\n    for command in bootstrap_commands:\n        if await _run_command(sandbox, command):\n            return True\n    return False\n\n\nasync def _install_requirements(sandbox: Sandbox) -> bool:\n    install_commands = [\n        _with_python_env(\n            \"python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt\"\n        ),\n        \"pip3 install --no-cache-dir -r requirements.txt\",\n        \"pip install --no-cache-dir -r requirements.txt\",\n    ]\n    for command in install_commands:\n        if await _run_command(sandbox, command):\n            return True\n    return False\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\"SANDBOX_IMAGE\", \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\")\n    timesteps = os.getenv(\"RL_TIMESTEPS\", \"5000\")\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(minutes=10),\n    )\n\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env={\"RL_TIMESTEPS\": timesteps},\n    )\n\n    async with sandbox:\n        try:\n            await sandbox.files.write_file(\"requirements.txt\", _load_requirements())\n            if not await _ensure_pip(sandbox):\n                print(\"Failed to bootstrap pip inside the sandbox.\")\n                return\n\n            if not await _install_requirements(sandbox):\n                print(\"Failed to install RL dependencies inside the sandbox.\")\n                return\n\n            await sandbox.files.write_file(\"train.py\", _training_script())\n            train_exec = await sandbox.commands.run(_with_python_env(\"python3 train.py\"))\n            await _print_execution_logs(train_exec)\n            if _execution_failed(train_exec):\n                print(\"Training failed inside the sandbox.\")\n                return\n\n            try:\n                summary = await sandbox.files.read_file(\"training_summary.json\")\n            except Exception as exc:\n                print(f\"\\nFailed to read training summary: {exc}\")\n            else:\n                print(\"\\n=== Training summary ===\")\n                print(summary)\n        finally:\n            await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "examples/rl-training/requirements.txt",
    "content": "gymnasium==0.29.1\nstable-baselines3==2.3.2\ntensorboard==2.16.2\ntorch==2.9.1\n"
  },
  {
    "path": "examples/vscode/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM debian:12-slim\n\n#----------------------\n# Install all prerequisite packages in one layer\nRUN apt-get update && apt-get install -y \\\n    python3 \\\n    python3-pip \\\n    curl \\\n    ca-certificates \\\n    --no-install-recommends \\\n    && rm -rf /var/lib/apt/lists/*\n\n#----------------------\n# Install code-server\nRUN curl -fsSL https://code-server.dev/install.sh | sh\n\n#----------------------\n# Create a non-root user\nRUN groupadd -r vscode && useradd -r -g vscode vscode \\\n    && mkdir -p /home/vscode /workspace && chown -R vscode:vscode /home/vscode /workspace\n\n#----------------------\n# Configure user, etc\n\nWORKDIR /workspace\n\nUSER vscode\n\n# Default to bash\nCMD [\"bash\"]\n"
  },
  {
    "path": "examples/vscode/README.md",
    "content": "# VS Code Example\n\n## Build the VS Code Sandbox Image\n\nThe Dockerfile in this directory builds a sandbox image with code-server pre-installed:\n\n```shell\ncd examples/vscode\ndocker build -t opensandbox/vscode:latest .\n```\n\nThis image includes:\n- code-server (VS Code Web) pre-installed\n- Non-root user (vscode) for security\n- Workspace directory at `/workspace`\n\nLaunch code-server (VS Code Web) in OpenSandbox to provide browser access.\n\n## Start OpenSandbox server [local]\n\nPre-pull the VS Code image:\n\n```shell\ndocker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/vscode:latest\n```\n\nStart the local OpenSandbox server:\n\n```shell\nuv pip install opensandbox-server\nopensandbox-server init-config ~/.sandbox.toml --example docker\nopensandbox-server\n```\n\n## Create and Access the VS Code Sandbox\n\n```shell\n# Install OpenSandbox package\nuv pip install opensandbox\n\nuv run python examples/vscode/main.py\n```\n\nThe script starts code-server (with authentication disabled), binds it to the specified port and outputs the accessible address. Uses the prebuilt VS Code image by default.\n\n![VS Code screenshot shell](./screenshot_shell.jpg)\n![VS Code screenshot vscode](./screenshot_vscode.jpg)\n\n## References\n- [code-server (VS Code Web)](https://github.com/coder/code-server)\n"
  },
  {
    "path": "examples/vscode/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm vscode-builder || true\n\ndocker buildx create --use --name vscode-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\ndocker buildx build \\\n  -t opensandbox/vscode:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/vscode:${TAG} \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "examples/vscode/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport os\nfrom datetime import timedelta\n\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.execd import RunCommandOpts\n\n\ndef _required_env(name: str) -> str:\n    value = os.getenv(name)\n    if not value:\n        raise RuntimeError(f\"{name} is required\")\n    return value\n\n\nasync def _print_logs(label: str, execution) -> None:\n    for msg in execution.logs.stdout:\n        print(f\"[{label} stdout] {msg.text}\")\n    for msg in execution.logs.stderr:\n        print(f\"[{label} stderr] {msg.text}\")\n    if execution.error:\n        print(f\"[{label} error] {execution.error.name}: {execution.error.value}\")\n\n\nasync def main() -> None:\n    domain = os.getenv(\"SANDBOX_DOMAIN\", \"localhost:8080\")\n    api_key = os.getenv(\"SANDBOX_API_KEY\")\n    image = os.getenv(\n        \"SANDBOX_IMAGE\",\n        \"opensandbox/vscode:latest\",\n    )\n    python_version = os.getenv(\"PYTHON_VERSION\", \"3.11\")\n    code_port = int(os.getenv(\"CODE_PORT\", \"8443\"))\n\n    config = ConnectionConfig(\n        domain=domain,\n        api_key=api_key,\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # Inject Python version into container environment\n    env = {\"PYTHON_VERSION\": python_version}\n    sandbox = await Sandbox.create(\n        image,\n        connection_config=config,\n        env=env,\n    )\n\n    async with sandbox:\n        # code-server is pre-installed in the image\n        # Start code-server with authentication disabled\n        start_exec = await sandbox.commands.run(\n            f\"code-server --bind-addr 0.0.0.0:{code_port} --auth none /workspace\",\n            opts=RunCommandOpts(background=True),\n        )\n        await _print_logs(\"code-server\", start_exec)\n\n        endpoint = await sandbox.get_endpoint(code_port)\n        print(\"\\nVS Code Web endpoint:\")\n        print(f\"  http://{endpoint.endpoint}/\")\n\n        print(\"\\nKeeping sandbox alive for 10 minutes. Press Ctrl+C to exit sooner.\")\n        try:\n            await asyncio.sleep(600)\n        except KeyboardInterrupt:\n            print(\"Stopping...\")\n        finally:\n            await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "kubernetes/.golangci.yml",
    "content": "version: \"2\"\nrun:\n  concurrency: 4\n  issues-exit-code: 1\n  tests: true\n\n# output configuration options\noutput:\n  formats:\n    text:\n      path: stdout\n      colors: true\nlinters:\n  default: none\n  enable:\n    - depguard\n    - govet\n    - ineffassign\n    - misspell\n    - unconvert\n    - unused\n  settings:\n    misspell:\n      # Correct spellings using locale preferences for US or UK.\n      # Default is to use a neutral variety of English.\n      # Setting locale to US will correct the British spelling of 'colour' to 'color'.\n      locale: US\n    depguard:\n      rules:\n        forbid-pkg-errors:\n          deny:\n          - pkg: \"github.com/pkg/errors\"\n            desc: Should be replaced with standard lib errors or fmt.Errorf\n  exclusions:\n    generated: lax\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    rules:\n      - path: (.+)\\.go$\n        text: 'SA1019: package github.com/golang/protobuf/proto is deprecated: Use the \"google.golang.org/protobuf/proto\" package instead'\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n      - apis\n      - pkg/client\n      - vendor\n      - test\nformatters:\n  enable:\n  - gofmt\n  - goimports\n  settings:\n    gofmt:\n      simplify: true\n    goimports:\n      # put imports beginning with prefix after 3rd-party packages;\n      local-prefixes: \n      - github.com/alibaba/OpenSandbox/sandbox-k8s\n  exclusions:\n    generated: lax\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n      - apis\n      - pkg/client\n      - vendor\n      - test"
  },
  {
    "path": "kubernetes/Dockerfile",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Build the manager binary\nFROM golang:1.24 AS builder\nARG TARGETOS\nARG TARGETARCH\n\nWORKDIR /workspace\n# Copy the Go Modules manifests\nCOPY go.mod go.mod\nCOPY go.sum go.sum\n# cache deps before building and copying source so that we don't need to re-download as much\n# and so that source changes don't invalidate our downloaded layer\nRUN GOPROXY=https://goproxy.cn,direct go mod download\n\n# Copy the go source\nCOPY cmd/ cmd/\nCOPY apis/ apis/\nCOPY pkg/ pkg/\nCOPY internal/ internal/\n\n# Build\n# the GOARCH has not a default value to allow the binary be built according to the host where the command\n# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO\n# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore,\n# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform.\nRUN echo \"Building for $TARGETOS/$TARGETARCH\"\nARG PACKAGE=cmd/controller/main.go\nRUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -o server ${PACKAGE}\n\n# Use golang image as base to ensure nsenter (util-linux) is available\n# distroless does not contain shell or nsenter\nFROM golang:1.24\nARG USERID=65532\nWORKDIR /workspace\nCOPY --from=builder /workspace/server .\nUSER $USERID\nENTRYPOINT [\"/workspace/server\"]"
  },
  {
    "path": "kubernetes/Dockerfile.debug",
    "content": "FROM golang:1.25\n\n# Install Delve (debugger) and Reflex (file watcher)\nRUN go install github.com/go-delve/delve/cmd/dlv@latest && \\\n    go install github.com/cespare/reflex@latest\n\nWORKDIR /workspace\n\n# Set cache env vars to ensuring they are targeted by our volume mounts\nENV GOCACHE=/go/.cache/go-build\nENV GOMODCACHE=/go/pkg/mod\n# Expose ports\nEXPOSE 5758 2345\n\n# The default command will be overridden by the script, but we can set a safe default\nCMD [\"bash\"]\n"
  },
  {
    "path": "kubernetes/Makefile",
    "content": "# VERSION defines the project version for the bundle.\n# Update this value when you upgrade the version of your project.\n# To re-generate a bundle for another specific version without changing the standard setup, you can:\n# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2)\n# - use environment variables to overwrite this value (e.g export VERSION=0.0.2)\nVERSION ?= 0.1.0\n\n# CHANNELS define the bundle channels used in the bundle.\n# Add a new line here if you would like to change its default config. (E.g CHANNELS = \"candidate,fast,stable\")\n# To re-generate a bundle for other specific channels without changing the standard setup, you can:\n# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable)\n# - use environment variables to overwrite this value (e.g export CHANNELS=\"candidate,fast,stable\")\nifneq ($(origin CHANNELS), undefined)\nBUNDLE_CHANNELS := --channels=$(CHANNELS)\nendif\n\n# DEFAULT_CHANNEL defines the default channel used in the bundle.\n# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = \"stable\")\n# To re-generate a bundle for any other default channel without changing the default setup, you can:\n# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable)\n# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL=\"stable\")\nifneq ($(origin DEFAULT_CHANNEL), undefined)\nBUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL)\nendif\nBUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL)\n\n# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images.\n# This variable is used to construct full image tags for bundle and catalog images.\n#\n# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both\n# opensandbox.io/sandbox-k8s-bundle:$VERSION and opensandbox.io/sandbox-k8s-catalog:$VERSION.\nIMAGE_TAG_BASE ?= sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/controller\n\n# BUNDLE_IMG defines the image:tag used for the bundle.\n# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=<some-registry>/<project-name-bundle>:<tag>)\nBUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION)\n\n# BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command\nBUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS)\n\n# USE_IMAGE_DIGESTS defines if images are resolved via tags or digests\n# You can enable this value if you would like to use SHA Based Digests\n# To enable set flag to true\nUSE_IMAGE_DIGESTS ?= false\nifeq ($(USE_IMAGE_DIGESTS), true)\n\tBUNDLE_GEN_FLAGS += --use-image-digests\nendif\n\n# Set the Operator SDK version to use. By default, what is installed on the system is used.\n# This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit.\nOPERATOR_SDK_VERSION ?= v1.42.0\n# Image URL to use all building/pushing image targets\n# CONTROLLER_IMG defines the image for the controller manager.\nCONTROLLER_IMG ?= controller:dev\n# TASK_EXECUTOR_IMG defines the image for the task-executor service.\nTASK_EXECUTOR_IMG ?= task-executor:dev\n\n# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)\nifeq (,$(shell go env GOBIN))\nGOBIN=$(shell go env GOPATH)/bin\nelse\nGOBIN=$(shell go env GOBIN)\nendif\n\n# CONTAINER_TOOL defines the container tool to be used for building images.\n# Be aware that the target commands are only tested with Docker which is\n# scaffolded by default. However, you might want to replace it to use other\n# tools. (i.e. podman)\nCONTAINER_TOOL ?= docker\n\n# DOCKER_BUILD_ARGS defines additional arguments to pass to docker build.\n# For example, in some environments you may need: DOCKER_BUILD_ARGS=--network=host\nDOCKER_BUILD_ARGS ?=\n\n# Setting SHELL to bash allows bash commands to be executed by recipes.\n# Options are set to exit when a recipe line exits non-zero or a piped command fails.\nSHELL = /usr/bin/env bash -o pipefail\n.SHELLFLAGS = -ec\n\n.PHONY: all\nall: build\n\n##@ General\n\n# The help target prints out all targets with their descriptions organized\n# beneath their categories. The categories are represented by '##@' and the\n# target descriptions by '##'. The awk command is responsible for reading the\n# entire set of makefiles included in this invocation, looking for lines of the\n# file as xyz: ## something, and then pretty-format the target and help. Then,\n# if there's a line with ##@ something, that gets pretty-printed as a category.\n# More info on the usage of ANSI control characters for terminal formatting:\n# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters\n# More info on the awk command:\n# http://linuxcommand.org/lc3_adv_awk.php\n\n.PHONY: help\nhelp: ## Display this help.\n\t@awk 'BEGIN {FS = \":.*##\"; printf \"\\nUsage:\\n  make \\033[36m<target>\\033[0m\\n\"} /^[a-zA-Z_0-9-]+:.*?##/ { printf \"  \\033[36m%-15s\\033[0m %s\\n\", $$1, $$2 } /^##@/ { printf \"\\n\\033[1m%s\\033[0m\\n\", substr($$0, 5) } ' $(MAKEFILE_LIST)\n\n##@ Development\n\n.PHONY: manifests\nmanifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.\n\t$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths=\"./...\" output:crd:artifacts:config=config/crd/bases\n\n.PHONY: generate\ngenerate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.\n\t$(CONTROLLER_GEN) object:headerFile=\"hack/boilerplate.go.txt\" paths=\"./...\"\n\n.PHONY: fmt\nfmt: ## Run go fmt against code.\n\tgo fmt ./...\n\n.PHONY: vet\nvet: ## Run go vet against code.\n\tgo vet ./...\n\n.PHONY: test\ntest: manifests generate fmt vet setup-envtest ## Run tests.\n\tKUBEBUILDER_ASSETS=\"$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)\" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out\n\n# To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.\n# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.\nKIND_CLUSTER ?= sandbox-k8s-test-e2e\nKIND_K8S_VERSION ?= v1.22.4\nGINKGO_ARGS ?=\n\n.PHONY: install-kind\ninstall-kind: ## Install Kind using go install if not already installed\n\t@if command -v kind >/dev/null 2>&1; then \\\n\t\techo \"Kind is already installed: $$(kind version)\"; \\\n\telse \\\n\t\techo \"Installing Kind...\"; \\\n\t\tgo install sigs.k8s.io/kind@v0.20.0 && \\\n\t\techo \"Kind installed successfully: $$(kind version)\"; \\\n\tfi\n\n.PHONY: setup-test-e2e\nsetup-test-e2e: install-kind ## Set up a Kind cluster for e2e tests if it does not exist\n\t@case \"$$($(KIND) get clusters 2>/dev/null || echo '')\" in \\\n\t\t*\"$(KIND_CLUSTER)\"*) \\\n\t\t\techo \"Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation.\" ;; \\\n\t\t*) \\\n\t\t\techo \"Creating Kind cluster '$(KIND_CLUSTER)' with Kubernetes version $(KIND_K8S_VERSION)...\"; \\\n\t\t\t$(KIND) create cluster --name $(KIND_CLUSTER) --image kindest/node:$(KIND_K8S_VERSION) ;; \\\n\tesac\n\n.PHONY: test-e2e\ntest-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. Use GINKGO_ARGS to pass additional arguments.\n\tCONTROLLER_IMG=$(CONTROLLER_IMG) TASK_EXECUTOR_IMG=$(TASK_EXECUTOR_IMG) \\\n\t\tKIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e/ -v -ginkgo.v $(GINKGO_ARGS)\n\tCONTROLLER_IMG=$(CONTROLLER_IMG) TASK_EXECUTOR_IMG=$(TASK_EXECUTOR_IMG) \\\n\t\tKIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e_task/ -v -ginkgo.v $(GINKGO_ARGS)\n\t$(MAKE) cleanup-test-e2e\n\t$(MAKE) test-gvisor CONTROLLER_IMG=$(CONTROLLER_IMG) TASK_EXECUTOR_IMG=$(TASK_EXECUTOR_IMG)\n\t$(MAKE) cleanup-gvisor\n\n.PHONY: cleanup-test-e2e\ncleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests\n\t@$(KIND) delete cluster --name $(KIND_CLUSTER)\n\n# Common E2E setup targets - install CRDs and controller for any Kind cluster\n.PHONY: install-e2e-deps\ninstall-e2e-deps:\n\t@echo \"Installing OpenSandbox CRDs...\"\n\t@$(MAKE) install\n\t@echo \"Installing OpenSandbox controller...\"\n\t@cd config/manager && $(KUSTOMIZE) edit set image controller=$(CONTROLLER_IMG)\n\t@$(KUSTOMIZE) build config/default | kubectl apply -f -\n\t@echo \"Waiting for controller to be ready...\"\n\t@kubectl wait --for=condition=available --timeout=120s deployment -n opensandbox-system opensandbox-controller-manager || \\\n\t\t{ kubectl describe deployment -n opensandbox-system opensandbox-controller-manager; exit 1; }\n\n# RuntimeClass E2E testing - gVisor\nGVISOR_KIND_CLUSTER ?= gvisor-test\nGVISOR_KIND_IMAGE ?= kindest/node:v1.27.3\n\n# gVisor versions\nGVISOR_VERSION ?= 20260112\nGVISOR_RUNSC_BIN ?= $(shell pwd)/test/kind/gvisor/runsc\nGVISOR_SHIM_BIN ?= $(shell pwd)/test/kind/gvisor/containerd-shim-runsc-v1\n\n.PHONY: download-gvisor\ndownload-gvisor: ## Download gVisor runsc and containerd-shim-runsc-v1 binaries\n\t@echo \"Downloading gVisor runsc (release-$(GVISOR_VERSION))...\"\n\t@mkdir -p $(dir $(GVISOR_RUNSC_BIN))\n\t@wget -q \"https://storage.googleapis.com/gvisor/releases/release/$(GVISOR_VERSION)/$$(uname -m)/runsc\" -O $(GVISOR_RUNSC_BIN)\n\t@chmod +x $(GVISOR_RUNSC_BIN)\n\t@echo \"Downloading containerd-shim-runsc-v1...\"\n\t@wget -q \"https://storage.googleapis.com/gvisor/releases/release/$(GVISOR_VERSION)/$$(uname -m)/containerd-shim-runsc-v1\" -O $(GVISOR_SHIM_BIN)\n\t@chmod +x $(GVISOR_SHIM_BIN)\n\t@echo \"gVisor binaries downloaded successfully.\"\n\n.PHONY: setup-gvisor\nsetup-gvisor: download-gvisor ## Set up Kind cluster with gVisor (runsc) for e2e tests\n\t@echo \"Creating gVisor Kind cluster with runsc binaries from kubernetes/test/kind/gvisor/...\"\n\t@export GVISOR_KIND_CLUSTER=$(GVISOR_KIND_CLUSTER) && \\\n\t\texport GVISOR_KIND_IMAGE=$(GVISOR_KIND_IMAGE) && \\\n\t\texport PWD=$$(pwd) && \\\n\t\tenvsubst < test/e2e_runtime/gvisor/testdata/gvisor.yaml.tmpl | \\\n\t\t$(KIND) create cluster --config -\n\t@echo \"Creating runsc.toml on Kind nodes...\"\n\t@for node in $$(docker ps --filter \"name=$(GVISOR_KIND_CLUSTER)-\" --format \"{{.Names}}\"); do \\\n\t\tdocker exec $$node sh -c 'mkdir -p /etc/containerd && echo \"[runsc]\" > /etc/containerd/runsc.toml && echo \"  platform = \\\"ptrace\\\"\" >> /etc/containerd/runsc.toml'; \\\n\tdone\n\n.PHONY: cleanup-gvisor\ncleanup-gvisor: ## Tear down gVisor Kind cluster\n\t@$(KIND) delete cluster --name $(GVISOR_KIND_CLUSTER) 2>/dev/null || true\n\n# install-gvisor-deps installs CRDs and controller for gVisor tests\n.PHONY: install-gvisor-deps\ninstall-gvisor-deps:\n\t@echo \"Building and loading controller image into gVisor Kind cluster...\"\n\t@$(MAKE) docker-build-controller CONTROLLER_IMG=$(CONTROLLER_IMG)\n\t@$(KIND) load docker-image --name $(GVISOR_KIND_CLUSTER) $(CONTROLLER_IMG)\n\t@$(MAKE) install-e2e-deps CONTROLLER_IMG=$(CONTROLLER_IMG)\n\n.PHONY: test-gvisor\ntest-gvisor: setup-gvisor install-gvisor-deps ## Run gVisor RuntimeClass e2e tests\n\t@echo \"Installing gVisor RuntimeClass resources...\"\n\t@kubectl apply -f test/e2e_runtime/gvisor/testdata/runtimeclass.yaml\n\t@echo \"Running gVisor E2E tests...\"\n\tCONTROLLER_IMG=$(CONTROLLER_IMG) TASK_EXECUTOR_IMG=$(TASK_EXECUTOR_IMG) \\\n\t\tKIND_CLUSTER=$(GVISOR_KIND_CLUSTER) go test ./test/e2e_runtime/gvisor -v -ginkgo.v $(GINKGO_ARGS)\n\t$(MAKE) cleanup-gvisor\n\n.PHONY: lint\nlint: golangci-lint ## Run golangci-lint linter\n\t$(GOLANGCI_LINT) run\n\n.PHONY: lint-fix\nlint-fix: golangci-lint ## Run golangci-lint linter and perform fixes\n\t$(GOLANGCI_LINT) run --fix\n\n.PHONY: lint-config\nlint-config: golangci-lint ## Verify golangci-lint linter configuration\n\t$(GOLANGCI_LINT) config verify\n\n##@ Build\n\n.PHONY: build\nbuild: manifests generate fmt vet ## Build manager binary.\n\tgo build -o bin/manager cmd/controller/main.go\n\n.PHONY: run\nrun: manifests generate fmt vet ## Run a controller from your host.\n\tgo run ./cmd/controller/main.go\n\n.PHONY: task-executor-build\ntask-executor-build: ## Build task-executor binary.\n\tgo build -o bin/task-executor ./cmd/task-executor\n\n.PHONY: task-executor-run\ntask-executor-run: ## Run task-executor from your host.\n\tgo run ./cmd/task-executor\n\n# If you wish to build the manager image targeting other platforms you can use the --platform flag.\n# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.\n# More info: https://docs.docker.com/develop/develop-images/build_enhancements/\n.PHONY: docker-build\n# docker-build: ## Build docker image with the manager.\n#\t$(CONTAINER_TOOL) build -t ${CONTROLLER_IMG} .\n\ndocker-build: docker-build-controller\n\n.PHONY: docker-build-controller\ndocker-build-controller: ## Build docker image with the manager.\n\t$(CONTAINER_TOOL) build $(DOCKER_BUILD_ARGS) --build-arg PACKAGE=cmd/controller/main.go -t ${CONTROLLER_IMG} .\n\n.PHONY: docker-build-task-executor\ndocker-build-task-executor: ## Build docker image with task-executor.\n\t$(CONTAINER_TOOL) build $(DOCKER_BUILD_ARGS) --build-arg PACKAGE=cmd/task-executor/main.go --build-arg USERID=0 -t ${TASK_EXECUTOR_IMG} .\n\n.PHONY: docker-push\n# docker-push: ## Push docker image with the manager.\n#\t$(CONTAINER_TOOL) push ${CONTROLLER_IMG}\n\ndocker-push: docker-push-controller\n\n.PHONY: docker-push-controller\ndocker-push-controller: ## Push docker image with the manager.\n\t$(CONTAINER_TOOL) push ${CONTROLLER_IMG}\n\n.PHONY: docker-push-task-executor\ndocker-push-task-executor: ## Push docker image with task-executor.\n\t$(CONTAINER_TOOL) push ${TASK_EXECUTOR_IMG}\n\n.PHONY: docker-run-task-executor\ndocker-run-task-executor: docker-build-task-executor ## Run task-executor docker image.\n\t@echo \"Running task-executor image: $(TASK_EXECUTOR_IMG) on port 8080\"\n\t@$(CONTAINER_TOOL) run --rm -d -p 8080:8080 --name task-executor-local $(TASK_EXECUTOR_IMG)\n\n.PHONY: docker-stop-task-executor\ndocker-stop-task-executor: ## Stop task-executor docker container.\n\t@echo \"Stopping task-executor container: task-executor-local\"\n\t-@$(CONTAINER_TOOL) stop task-executor-local || true\n\t-@$(CONTAINER_TOOL) rm task-executor-local || true\n\n# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple\n# architectures. (i.e. make docker-buildx CONTROLLER_IMG=myregistry/mypoperator:0.0.1). To use this option you need to:\n# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/\n# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/\n# be able to push the image to your registry (i.e. if you do not set a valid value via CONTROLLER_IMG=<myregistry/image:<tag>> then the export will fail)\n# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option.\nPLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le\n.PHONY: docker-buildx\ndocker-buildx: ## Build and push docker image for the manager for cross-platform support\n\t# copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile\n\tsed -e '1 s/\\(^FROM\\)/FROM --platform=\\$$\\{BUILDPLATFORM\\}/; t' -e ' 1,// s//FROM --platform=\\$$\\{BUILDPLATFORM\\}/' Dockerfile > Dockerfile.cross\n\t- $(CONTAINER_TOOL) buildx create --name sandbox-k8s-builder\n\t$(CONTAINER_TOOL) buildx use sandbox-k8s-builder\n\t- $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${CONTROLLER_IMG} -f Dockerfile.cross .\n\t- $(CONTAINER_TOOL) buildx rm sandbox-k8s-builder\n\trm Dockerfile.cross\n\n.PHONY: build-installer\nbuild-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment.\n\tmkdir -p dist\n\tcd config/manager && $(KUSTOMIZE) edit set image controller=${CONTROLLER_IMG}\n\t$(KUSTOMIZE) build config/default > dist/install.yaml\n\n##@ Deployment\n\nifndef ignore-not-found\n  ignore-not-found = false\nendif\n\n.PHONY: install\ninstall: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.\n\t$(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f -\n\n.PHONY: uninstall\nuninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.\n\t$(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -\n\n.PHONY: deploy\ndeploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.\n\tcd config/manager && $(KUSTOMIZE) edit set image controller=${CONTROLLER_IMG}\n\t$(KUSTOMIZE) build config/default | $(KUBECTL) apply -f -\n\n.PHONY: undeploy\nundeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.\n\t$(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -\n\n##@ Dependencies\n\n## Location to install dependencies to\nLOCALBIN ?= $(shell pwd)/bin\n$(LOCALBIN):\n\tmkdir -p $(LOCALBIN)\n\n## Tool Binaries\nKUBECTL ?= kubectl\nKIND ?= kind\nKUSTOMIZE ?= $(LOCALBIN)/kustomize\nCONTROLLER_GEN ?= $(LOCALBIN)/controller-gen\nENVTEST ?= $(LOCALBIN)/setup-envtest\nGOLANGCI_LINT = $(LOCALBIN)/golangci-lint\n\n## Tool Versions\nKUSTOMIZE_VERSION ?= v5.6.0\nCONTROLLER_TOOLS_VERSION ?= v0.18.0\n#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20)\nENVTEST_VERSION ?= $(shell go list -m -f \"{{ .Version }}\" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf \"release-%d.%d\", $$2, $$3}')\n#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)\nENVTEST_K8S_VERSION ?= $(shell go list -m -f \"{{ .Version }}\" k8s.io/api | awk -F'[v.]' '{printf \"1.%d\", $$3}')\nGOLANGCI_LINT_VERSION ?= v2.7.2\n\n.PHONY: kustomize\nkustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.\n$(KUSTOMIZE): $(LOCALBIN)\n\t$(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION))\n\n.PHONY: controller-gen\ncontroller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.\n$(CONTROLLER_GEN): $(LOCALBIN)\n\t$(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))\n\n.PHONY: setup-envtest\nsetup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory.\n\t@echo \"Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)...\"\n\t@$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \\\n\t\techo \"Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION).\"; \\\n\t\texit 1; \\\n\t}\n\n.PHONY: envtest\nenvtest: $(ENVTEST) ## Download setup-envtest locally if necessary.\n$(ENVTEST): $(LOCALBIN)\n\t$(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION))\n\n.PHONY: golangci-lint\ngolangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.\n$(GOLANGCI_LINT): $(LOCALBIN)\n\t$(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))\n\n# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist\n# $1 - target path with name of binary\n# $2 - package url which can be installed\n# $3 - specific version of package\ndefine go-install-tool\n@[ -f \"$(1)-$(3)\" ] || { \\\nset -e; \\\npackage=$(2)@$(3) ;\\\necho \"Downloading $${package}\" ;\\\nrm -f $(1) || true ;\\\nGOBIN=$(LOCALBIN) go install $${package} ;\\\nmv $(1) $(1)-$(3) ;\\\n} ;\\\nln -sf $(1)-$(3) $(1)\nendef\n\n.PHONY: operator-sdk\nOPERATOR_SDK ?= $(LOCALBIN)/operator-sdk\noperator-sdk: ## Download operator-sdk locally if necessary.\nifeq (,$(wildcard $(OPERATOR_SDK)))\nifeq (, $(shell which operator-sdk 2>/dev/null))\n\t@{ \\\n\tset -e ;\\\n\tmkdir -p $(dir $(OPERATOR_SDK)) ;\\\n\tOS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \\\n\tcurl -sSLo $(OPERATOR_SDK) https://github.com/operator-framework/operator-sdk/releases/download/$(OPERATOR_SDK_VERSION)/operator-sdk_$${OS}_$${ARCH} ;\\\n\tchmod +x $(OPERATOR_SDK) ;\\\n\t}\nelse\nOPERATOR_SDK = $(shell which operator-sdk)\nendif\nendif\n\n.PHONY: bundle\nbundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files.\n\t$(OPERATOR_SDK) generate kustomize manifests -q\n\tcd config/manager && $(KUSTOMIZE) edit set image controller=$(CONTROLLER_IMG)\n\t$(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS)\n\t$(OPERATOR_SDK) bundle validate ./bundle\n\n.PHONY: bundle-build\nbundle-build: ## Build the bundle image.\n\t$(CONTAINER_TOOL) build $(DOCKER_BUILD_ARGS) -f bundle.Dockerfile -t $(BUNDLE_IMG) .\n\n.PHONY: bundle-push\nbundle-push: ## Push the bundle image.\n\t$(MAKE) docker-push CONTROLLER_IMG=$(BUNDLE_IMG)\n\n.PHONY: opm\nOPM = $(LOCALBIN)/opm\nopm: ## Download opm locally if necessary.\nifeq (,$(wildcard $(OPM)))\nifeq (,$(shell which opm 2>/dev/null))\n\t@{ \\\n\tset -e ;\\\n\tmkdir -p $(dir $(OPM)) ;\\\n\tOS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \\\n\tcurl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.55.0/$${OS}-$${ARCH}-opm ;\\\n\tchmod +x $(OPM) ;\\\n\t}\nelse\nOPM = $(shell which opm)\nendif\nendif\n\n# A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0).\n# These images MUST exist in a registry and be pull-able.\nBUNDLE_IMGS ?= $(BUNDLE_IMG)\n\n# The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0).\nCATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION)\n\n# Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image.\nifneq ($(origin CATALOG_BASE_IMG), undefined)\nFROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG)\nendif\n\n# Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'.\n# This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see:\n# https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator\n.PHONY: catalog-build\ncatalog-build: opm ## Build a catalog image.\n\t$(OPM) index add --container-tool $(CONTAINER_TOOL) --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT)\n\n# Push the catalog image.\n.PHONY: catalog-push\ncatalog-push: ## Push a catalog image.\n\t$(MAKE) docker-push CONTROLLER_IMG=$(CATALOG_IMG)\n\n##@ Helm\n\n# Helm chart configuration\nHELM_CHART_PATH ?= charts/opensandbox-controller\nHELM_CHART_VERSION ?= $(VERSION)\n\n.PHONY: helm-lint\nhelm-lint: ## Lint the Helm chart\n\t@echo \"Linting Helm chart...\"\n\thelm lint $(HELM_CHART_PATH)\n\n.PHONY: helm-template\nhelm-template: ## Generate Kubernetes manifests from Helm chart\n\t@echo \"Generating manifests from Helm chart...\"\n\thelm template opensandbox-controller $(HELM_CHART_PATH) \\\n\t\t--set controller.image.repository=$(IMAGE_TAG_BASE) \\\n\t\t--set controller.image.tag=v$(VERSION)\n\n.PHONY: helm-template-debug\nhelm-template-debug: ## Generate Kubernetes manifests with debug output\n\t@echo \"Generating manifests from Helm chart with debug...\"\n\thelm template opensandbox-controller $(HELM_CHART_PATH) \\\n\t\t--set controller.image.repository=$(IMAGE_TAG_BASE) \\\n\t\t--set controller.image.tag=v$(VERSION) \\\n\t\t--debug\n\n.PHONY: helm-package\nhelm-package: ## Package the Helm chart\n\t@echo \"Packaging Helm chart...\"\n\t@mkdir -p dist\n\thelm package $(HELM_CHART_PATH) -d dist/ --version $(HELM_CHART_VERSION) --app-version $(VERSION)\n\n.PHONY: helm-install\nhelm-install: ## Install the Helm chart\n\t@echo \"Installing Helm chart...\"\n\thelm install opensandbox-controller $(HELM_CHART_PATH) \\\n\t\t--set controller.image.repository=$(IMAGE_TAG_BASE) \\\n\t\t--set controller.image.tag=v$(VERSION) \\\n\t\t--namespace opensandbox-system \\\n\t\t--create-namespace\n\n.PHONY: helm-upgrade\nhelm-upgrade: ## Upgrade the Helm chart\n\t@echo \"Upgrading Helm chart...\"\n\thelm upgrade opensandbox-controller $(HELM_CHART_PATH) \\\n\t\t--set controller.image.repository=$(IMAGE_TAG_BASE) \\\n\t\t--set controller.image.tag=v$(VERSION) \\\n\t\t--namespace opensandbox-system\n\n.PHONY: helm-uninstall\nhelm-uninstall: ## Uninstall the Helm chart\n\t@echo \"Uninstalling Helm chart...\"\n\thelm uninstall opensandbox-controller --namespace opensandbox-system\n\n.PHONY: helm-test\nhelm-test: ## Run Helm chart tests\n\t@echo \"Running Helm chart tests...\"\n\thelm test opensandbox-controller --namespace opensandbox-system\n\n.PHONY: helm-docs\nhelm-docs: ## Generate Helm chart documentation (requires helm-docs)\n\t@if command -v helm-docs >/dev/null 2>&1; then \\\n\t\techo \"Generating Helm chart documentation...\"; \\\n\t\thelm-docs $(HELM_CHART_PATH); \\\n\telse \\\n\t\techo \"helm-docs is not installed. Install it with: go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest\"; \\\n\t\texit 1; \\\n\tfi\n\n.PHONY: helm-dry-run\nhelm-dry-run: ## Perform a dry-run install of the Helm chart\n\t@echo \"Performing dry-run installation...\"\n\thelm install opensandbox-controller $(HELM_CHART_PATH) \\\n\t\t--set controller.image.repository=$(IMAGE_TAG_BASE) \\\n\t\t--set controller.image.tag=v$(VERSION) \\\n\t\t--namespace opensandbox-system \\\n\t\t--create-namespace \\\n\t\t--dry-run --debug\n\n.PHONY: helm-all\nhelm-all: helm-lint helm-package ## Run all Helm-related tasks (lint and package)"
  },
  {
    "path": "kubernetes/PROJECT",
    "content": "# Code generated by tool. DO NOT EDIT.\n# This file is used to track the info used to scaffold your project\n# and allow the plugins properly work.\n# More info: https://book.kubebuilder.io/reference/project-config.html\ndomain: opensandbox.io\nlayout:\n- go.kubebuilder.io/v4\nplugins:\n  manifests.sdk.operatorframework.io/v2: {}\n  scorecard.sdk.operatorframework.io/v2: {}\nprojectName: sandbox-k8s\nrepo: github.com/alibaba/OpenSandbox/sandbox-k8s\nresources:\n- api:\n    crdVersion: v1\n    namespaced: true\n  controller: true\n  domain: opensandbox.io\n  group: sandbox\n  kind: Sandbox\n  path: github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1\n  version: v1alpha1\n- api:\n    crdVersion: v1\n    namespaced: true\n  controller: true\n  domain: opensandbox.io\n  group: sandbox\n  kind: BatchSandbox\n  path: github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1\n  version: v1alpha1\n- api:\n    crdVersion: v1\n    namespaced: true\n  controller: true\n  domain: opensandbox.io\n  group: sandbox\n  kind: Pool\n  path: github.com/alibaba/OpenSandbox/sandbox-k8s/api/v1alpha1\n  version: v1alpha1\nversion: \"3\"\n"
  },
  {
    "path": "kubernetes/README-ZH.md",
    "content": "# OpenSandbox Kubernetes 控制器\n\n[English](README.md) | [中文](README-ZH.md)\n\nOpenSandbox Kubernetes 控制器，通过自定义资源管理沙箱环境。它在 Kubernetes 集群中提供**自动化沙箱生命周期管理**、**资源池化以实现快速供应**、**批处理沙箱创建**和**可选的任务编排**功能。\n\n## 关键特性\n\n- **灵活的沙箱创建**：在池化和非池化沙箱创建模式之间选择\n- **批处理和单个交付**：支持单个沙箱（用于真实用户交互）和批处理沙箱交付（用于高吞吐量智能体强化学习场景）\n- **可选任务调度**：集成任务编排，支持可选的分片任务模板以实现异构任务分发和定制化沙箱交付（例如，进程注入）\n- **资源池化**：维护预热的资源池以实现快速沙箱供应\n- **全面监控**：实时跟踪沙箱和任务状态\n\n## 功能特性\n\n### 批处理沙箱管理\nBatchSandbox 自定义资源允许您创建和管理多个相同的沙箱环境。主要功能包括：\n- **灵活的创建模式**：支持池化（使用资源池）和非池化沙箱创建\n- **单个和批处理交付**：根据需要创建单个沙箱（replicas=1）或批处理沙箱（replicas=N）\n- **可扩展的副本管理**：通过副本配置轻松控制沙箱实例数量\n- **自动过期**：设置 TTL（生存时间）以自动清理过期沙箱\n- **可选任务调度**：内置任务执行引擎，支持可选任务模板\n- **详细状态报告**：关于副本、分配和任务状态的综合指标\n\n### 资源池化\nPool 自定义资源维护一个预热的计算资源池，以实现快速沙箱供应：\n- 可配置的缓冲区大小（最小和最大）以平衡资源可用性和成本\n- 池容量限制以控制总体资源消耗\n- 基于需求的自动资源分配和释放\n- 实时状态监控，显示总数、已分配和可用资源\n\n### 任务编排\n集成的任务管理系统，在沙箱内执行自定义工作负载：\n- **可选执行**：任务调度完全可选 - 可以在不带任务的情况下创建沙箱\n- **基于进程的任务**：支持在沙箱环境中执行基于进程的任务\n- **异构任务分发**：使用 shardTaskPatches 为批处理中的每个沙箱定制单独的任务\n\n### 高级调度\n智能资源管理功能：\n- 最小和最大缓冲区设置，以确保资源可用性同时控制成本\n- 池范围的容量限制，防止资源耗尽\n- 基于需求的自动扩展\n\n## 运行时 API 支持说明\n\n- Kubernetes 运行时当前**不支持** `pause` / `resume` 生命周期 API。\n- 对 Kubernetes 运行时调用这两个 API 会返回 `501 Not Implemented`。\n- OpenSandbox 的 pause/resume 语义是保留容器进程内存态后再恢复；当前 Kubernetes provider 主要覆盖 create/get/list/delete/renew 流程。\n\n\n## 与 [kubernates-sigs/agent-sandbox](kubernates-sigs/agent-sandbox) 的关系\n\nBatchSandbox 并非重复实现 Agent-Sandbox 的基础功能，而是作为其补充，提供了额外的增强能力：\n\n1. **批量 Sandbox 语义**：在强化学习（RL）训练等场景下，显著提升 Sandbox 的交付吞吐量\n2. **Task 调度能力**：通过 Task 调度实现差异化 Sandbox 交付，例如在交付 Sandbox 之前向容器内注入自定义进程\n\n因此，您可以根据具体应用场景选择合适的项目作为 Sandbox 底层运行时。\n\n### 性能测试\n\nBatchSandbox 与 Sig Agent-Sandbox 在吞吐量方面的性能对比测试。\n\n**测试环境**\n\n**Controller 组件配置**\n- 资源规格：request: 12C32G, limit: 16C64G\n- 并发配置：\n  - **Sig Agent-Sandbox**：共 3 个 controller（sandbox、sandboxclaim、sandboxwarmppool），代码中未提供并发度配置，默认值为 1\n  - **BatchSandbox**：共 2 个 controller，batchsandbox controller 并发度为 32，pool controller 并发度为 1\n\n**Pool 配置**\n- 镜像：busybox:latest\n- 资源规格：0.1C256MB\n\n> **补充说明**：虽然 BatchSandbox 的 batchsandbox-controller 并发度为 32，但测试用例中仅创建了一个 BatchSandbox 对象，实际等价于并发度为 1。因此在并发度方面，BatchSandbox 与 SIG Agent-Sandbox 保持一致。\n\n**性能对比结果**\n\n在都使用资源池的情况下，交付 100 个 Sandbox 的总耗时对比：\n\n| 测试场景 | 总耗时 (秒) |\n|---------|------------|\n| SIG Agent-Sandbox (创建并发=1) | 76.35 |\n| SIG Agent-Sandbox (创建并发=10) | 23.17 |\n| SIG Agent-Sandbox (创建并发=50) | 33.85 |\n| BatchSandbox | 0.92 |\n\n**原因分析**\n\n核心差异：Sig Agent-Sandbox 和 BatchSandbox 批量交付 N 个 Sandbox 的时间复杂度分别为 O(N) 和 O(1)。\n\n**Sig Agent-Sandbox 原理**\n- 每个 Sandbox 的交付流程需要执行以下写操作（写操作总数与 Sandbox 规模成正比）：\n  1. 创建一个 SandboxClaim\n  2. 创建一个 Sandbox\n  3. 更新 Pod 一次（从资源池中接管 Pod）\n  4. 更新 Sandbox Status 一次\n  5. 更新 SandboxClaim Status 一次\n\n**BatchSandbox 原理**\n- 每批 Sandbox 的交付流程需要执行以下写操作（写操作总数与 Sandbox 规模无关）：\n  1. 创建一个 BatchSandbox\n  2. 更新 BatchSandbox annotation 一次（写入批分配结果）\n  3. 更新 BatchSandbox status 一次\n\n## 入门指南\n\n![](images/deploy-example.gif)\n\n### 先决条件\n- go 版本 v1.24.0+\n- docker 版本 17.03+\n- kubectl 版本 v1.11.3+\n- 访问 Kubernetes v1.21.1+ 集群\n\n如果您没有 Kubernetes 集群的访问权限，可以使用 [kind](https://kind.sigs.k8s.io/) 创建一个本地 Kubernetes 集群进行测试。Kind 在 Docker 容器中运行 Kubernetes 节点，使得设置本地开发环境变得容易。\n\n安装 kind：\n- 从[发布页面](https://github.com/kubernetes-sigs/kind/releases)下载适用于您操作系统的发布二进制文件并将其移动到 `$PATH` 中\n- 或使用包管理器：\n  - macOS (Homebrew)：`brew install kind`\n  - Windows (winget)：`winget install Kubernetes.kind`\n\n安装 kind 后，使用以下命令创建集群：\n```sh\nkind create cluster\n```\n\n此命令默认创建单节点集群。要与其交互，请使用生成的 kubeconfig 运行 `kubectl`。\n\n**Kind 用户的重要说明**：如果您使用的是 kind 集群，在使用 `make docker-build` 构建镜像后，需要将控制器和任务执行器镜像加载到 kind 节点中。这是因为 kind 在 Docker 容器中运行 Kubernetes 节点，无法直接访问本地 Docker 守护进程中的镜像。\n\n使用以下命令将镜像加载到 kind 集群中：\n```sh\nkind load docker-image <controller-image-name>:<tag>\nkind load docker-image <task-executor-image-name>:<tag>\n```\n\n例如，如果您使用 `make docker-build CONTROLLER_IMG=my-controller:latest` 构建镜像，则使用以下命令加载：\n```sh\nkind load docker-image my-controller:latest\n```\n\n完成后使用以下命令删除集群：\n```sh\nkind delete cluster\n```\n\n有关使用 kind 的更多详细说明，请参阅[官方 kind 文档](https://kind.sigs.k8s.io/docs/user/quick-start/)。\n\n### 部署\n\n此项目需要两个独立的镜像 - 一个用于控制器，另一个用于任务执行器组件。\n\n#### 方式 1：使用 Helm 部署（推荐）\n\n**从 GitHub Release 安装：**\n\n您可以直接从 GitHub Releases 安装 OpenSandbox Controller。查看 [Releases 页面](https://github.com/alibaba/OpenSandbox/releases?q=helm%2Fopensandbox-controller&expanded=true) 了解所有可用版本。\n\n```sh\n# 将 <version> 替换为所需版本（例如：0.1.0）\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<version>/opensandbox-controller-<version>.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n具体版本示例：\n```sh\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n您也可以先下载 chart 然后再安装：\n```sh\n# 下载 chart\nwget https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<version>/opensandbox-controller-<version>.tgz\n\n# 从本地文件安装\nhelm install opensandbox-controller ./opensandbox-controller-<version>.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n**自定义安装：**\n\n使用 `--set` 参数自定义配置：\n\n```sh\n# 示例：自定义资源限制\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  --set controller.replicaCount=2 \\\n  --set controller.resources.limits.cpu=1000m \\\n  --set controller.resources.limits.memory=512Mi\n\n# 示例：自定义日志级别\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  --set controller.logLevel=debug\n```\n\n或使用 values 文件进行复杂配置：\n\n```sh\n# 创建自定义 values 文件\ncat > custom-values.yaml <<EOF\ncontroller:\n  replicaCount: 2\n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n    requests:\n      cpu: 100m\n      memory: 128Mi\n  logLevel: debug\nEOF\n\n# 使用自定义 values 安装\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  -f custom-values.yaml\n```\n\n**从源码安装（用于开发）：**\n\n如果您正在进行开发或需要自定义 chart：\n\n1. **构建和推送您的镜像：**\n   ```sh\n   # 构建和推送控制器镜像\n   make docker-build docker-push CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag\n   \n   # 构建和推送任务执行器镜像\n   make docker-build-task-executor docker-push-task-executor TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n2. **使用 Helm 安装：**\n   ```sh\n   helm install opensandbox-controller ./charts/opensandbox-controller \\\n     --set controller.image.repository=<some-registry>/opensandbox-controller \\\n     --set controller.image.tag=<tag> \\\n     --namespace opensandbox-system \\\n     --create-namespace\n   ```\n\n**验证安装：**\n\n检查控制器是否运行：\n```sh\nkubectl get pods -n opensandbox-system\nkubectl get deployment -n opensandbox-system\n\n# 查看日志\nkubectl logs -n opensandbox-system -l control-plane=controller-manager -f\n```\n\n**升级：**\n\n```sh\n# 升级到新版本\nhelm upgrade opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<new-version>/opensandbox-controller-<new-version>.tgz \\\n  --namespace opensandbox-system\n```\n\n**卸载：**\n\n```sh\nhelm uninstall opensandbox-controller -n opensandbox-system\n```\n\n有关更多配置选项和高级用法，请参阅 [Helm Chart README](charts/opensandbox-controller/README.md)。\n\n#### 方式 2：使用 Kustomize 部署\n\n1. **构建和推送您的镜像：**\n   ```sh\n   # 构建和推送控制器镜像\n   make docker-build docker-push CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag\n   \n   # 构建和推送任务执行器镜像\n   make docker-build-task-executor docker-push-task-executor TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n   **注意：** 这些镜像应该发布在您指定的个人注册表中。需要能够从工作环境中拉取镜像。如果上述命令不起作用，请确保您对注册表具有适当的权限。\n\n2. **将 CRD 安装到集群中：**\n   ```sh\n   make install\n   ```\n\n3. **将管理器部署到集群：**\n   ```sh\n   make deploy CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n   **注意**：您可能需要授予自己集群管理员权限或以管理员身份登录以确保您在运行命令之前具有集群管理员权限。\n\n**Kind 用户的重要说明**：如果您使用的是 kind 集群，需要在构建镜像后将两个镜像都加载到 kind 节点中：\n```sh\nkind load docker-image <controller-image-name>:<tag>\nkind load docker-image <task-executor-image-name>:<tag>\n```\n\n### 创建 BatchSandbox 和 Pool 资源\n\n#### 基础示例\n创建一个简单的非池化沙箱，不带任务调度：\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: basic-batch-sandbox\nspec:\n  replicas: 2\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: nginx:latest\n        ports:\n        - containerPort: 80\n```\n\n应用批处理沙箱配置：\n```sh\nkubectl apply -f basic-batch-sandbox.yaml\n```\n\n检查批处理沙箱状态：\n```sh\nkubectl get batchsandbox basic-batch-sandbox -o wide\n```\n\n示例输出：\n```sh\nNAME                   DESIRED   TOTAL   ALLOCATED   READY   EXPIRE   AGE\nbasic-batch-sandbox    2         2       2           2       <none>   5m\n```\n\n状态字段说明：\n- **DESIRED**：请求的沙箱数量\n- **TOTAL**：创建的沙箱总数\n- **ALLOCATED**：成功分配的沙箱数量\n- **READY**：准备使用的沙箱数量\n- **EXPIRE**：过期时间（未设置时为空）\n- **AGE**：资源创建以来的时间\n\n沙箱准备好后，您可以在注解中找到端点信息：\n```sh\nkubectl get batchsandbox basic-batch-sandbox -o jsonpath='{.metadata.annotations.sandbox\\.opensandbox\\.io/endpoints}'\n```\n\n这将显示交付沙箱的 IP 地址。\n\n#### 高级示例\n\n##### 不带任务的池化沙箱\n首先，创建一个资源池：\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: example-pool\nspec:\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: nginx:latest\n        ports:\n        - containerPort: 80\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\n应用资源池配置：\n```sh\nkubectl apply -f pool-example.yaml\n```\n\n使用资源池创建一批沙箱：\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: pooled-batch-sandbox\nspec:\n  replicas: 3\n  poolRef: example-pool\n```\n\n应用批处理沙箱配置：\n```sh\nkubectl apply -f pooled-batch-sandbox.yaml\n```\n\n##### 带异构任务的池化沙箱\n创建一批带有基于进程的异构任务的沙箱。为了使任务执行正常工作，任务执行器必须作为 sidecar 容器部署在资源池模板中，并与沙箱容器共享进程命名空间：\n\n首先，创建一个带有任务执行器 sidecar 的资源池：\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: task-example-pool\nspec:\n  template:\n    spec:\n      shareProcessNamespace: true\n      containers:\n      - name: sandbox-container\n        image: ubuntu:latest\n        command: [\"sleep\", \"3600\"]\n      - name: task-executor\n        image: <task-executor-image>:<tag>\n        securityContext:\n          capabilities:\n            add: [\"SYS_PTRACE\"]\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\n使用我们刚刚创建的资源池创建一批带有基于进程的异构任务的沙箱：\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: task-batch-sandbox\nspec:\n  replicas: 2\n  poolRef: task-example-pool\n  taskTemplate:\n    spec:\n      process:\n        command: [\"echo\", \"Default task\"]\n  shardTaskPatches:\n  - spec:\n      process:\n        command: [\"echo\", \"Custom task for sandbox 1\"]\n  - spec:\n      process:\n        command: [\"echo\", \"Custom task for sandbox 2\"]\n        args: [\"with\", \"additional\", \"arguments\"]\n```\n\n应用批处理沙箱配置：\n```sh\nkubectl apply -f task-batch-sandbox.yaml\n```\n\n检查带任务的批处理沙箱状态：\n```sh\nkubectl get batchsandbox task-batch-sandbox -o wide\n```\n\n示例输出：\n```sh\nNAME                   DESIRED   TOTAL   ALLOCATED   READY   TASK_RUNNING   TASK_SUCCEED   TASK_FAILED   TASK_UNKNOWN   EXPIRE   AGE\ntask-batch-sandbox     2         2       2           2       0              2              0             0              <none>   5m\n```\n\n任务状态字段说明：\n- **TASK_RUNNING**：当前正在执行的任务数\n- **TASK_SUCCEED**：成功完成的任务数\n- **TASK_FAILED**：失败的任务数\n- **TASK_UNKNOWN**：状态未知的任务数\n\n当您删除带有运行任务的 BatchSandbox 时，控制器将首先停止所有任务，然后删除 BatchSandbox 资源。一旦所有任务都成功终止，BatchSandbox 将被完全删除，沙箱将返回到资源池中以供重用。\n\n删除 BatchSandbox：\n```sh\nkubectl delete batchsandbox task-batch-sandbox\n```\n\n您可以通过观察 BatchSandbox 状态来监控删除过程：\n```sh\nkubectl get batchsandbox task-batch-sandbox -w\n```\n\n### 监控资源\n检查资源池和批处理沙箱的状态：\n```sh\n# 查看资源池状态\nkubectl get pools\n\n# 查看批处理沙箱状态\nkubectl get batchsandboxes\n\n# 获取特定资源的详细信息\nkubectl describe pool example-pool\nkubectl describe batchsandbox example-batch-sandbox\n```\n\n## 项目结构\n\n```\n├── api/\n│   └── v1alpha1/          # 自定义资源定义（BatchSandbox, Pool）\n├── cmd/\n│   ├── controller/         # 主控制器管理器入口点\n│   └── task-executor/     # 任务执行器二进制文件\n├── config/\n│   ├── crd/               # 自定义资源定义清单\n│   ├── default/           # 控制器部署的默认配置\n│   ├── manager/           # 控制器管理器配置\n│   ├── rbac/              # 基于角色的访问控制清单\n│   └── samples/           # 资源的示例 YAML 清单\n├── hack/                  # 开发脚本和工具\n├── images/                # 文档图片\n├── internal/\n│   ├── controller/        # 核心控制器实现\n│   ├── scheduler/         # 资源分配和调度逻辑\n│   ├── task-executor/     # 任务执行引擎内部实现\n│   └── utils/             # 实用函数和助手\n├── pkg/\n│   └── task-executor/     # 共享的任务执行器包\n└── test/                  # 测试套件\n```\n\n## 贡献\n欢迎为 OpenSandbox Kubernetes 控制器项目做出贡献。请随时提交问题、功能请求和拉取请求。\n\n**注意：** 运行 `make help` 以获取所有潜在 `make` 目标的更多信息\n\n更多信息请参见 [Kubebuilder 文档](https://book.kubebuilder.io/introduction.html)\n\n## 许可证\n此项目在 Apache 2.0 许可证下开源。\n\n您可以将 OpenSandbox 用于个人或商业项目，只要遵守许可证条款即可。\n"
  },
  {
    "path": "kubernetes/README.md",
    "content": "# OpenSandbox Kubernetes Controller\n\n[English](README.md) | [中文](README-ZH.md)\n\nOpenSandbox Kubernetes Controller is a Kubernetes operator that manages sandbox environments through custom resources. It provides **automated sandbox lifecycle management**, **resource pooling for fast provisioning**, **batch sandbox creation**, and **optional task orchestration** capabilities in Kubernetes clusters.\n\n## Key Features\n\n- **Flexible Sandbox Creation**: Choose between pooled and non-pooled sandbox creation modes\n- **Batch and Individual Delivery**: Support both single sandbox (for real-user interactions) and batch sandbox delivery (for high-throughput agentic-RL scenarios)\n- **Optional Task Scheduling**: Integrated task orchestration with optional shard task templates for heterogeneous task distribution and customized sandbox delivery (e.g., process injection)\n- **Resource Pooling**: Maintain pre-warmed resource pools for rapid sandbox provisioning\n- **Comprehensive Monitoring**: Real-time status tracking of sandboxes and tasks\n\n## Features\n\n### Batch Sandbox Management\nThe BatchSandbox custom resource allows you to create and manage multiple identical sandbox environments. Key capabilities include:\n- **Flexible Creation Modes**: Support both pooled (using resource pools) and non-pooled sandbox creation\n- **Single and Batch Delivery**: Create single sandboxes (replicas=1) or batches of sandboxes (replicas=N) as needed\n- **Scalable Replica Management**: Easily control the number of sandbox instances through replica configuration\n- **Automatic Expiration**: Set TTL (time-to-live) for automatic cleanup of expired sandboxes\n- **Optional Task Scheduling**: Built-in task execution engine with support for optional task templates\n- **Detailed Status Reporting**: Comprehensive metrics on replicas, allocations, and task states\n\n### Resource Pooling\nThe Pool custom resource maintains a pool of pre-warmed compute resources to enable rapid sandbox provisioning:\n- Configurable buffer sizes (minimum and maximum) to balance resource availability and cost\n- Pool capacity limits to control overall resource consumption\n- Automatic resource allocation and deallocation based on demand\n- Real-time status monitoring showing total, allocated, and available resources\n\n### Task Orchestration\nIntegrated task management system that executes custom workloads within sandboxes:\n- **Optional Execution**: Task scheduling is completely optional - sandboxes can be created without tasks\n- **Process-Based Tasks**: Support for process-based tasks that execute within the sandbox environment\n- **Heterogeneous Task Distribution**: Customize individual tasks for each sandbox in a batch using shardTaskPatches\n\n### Advanced Scheduling\nIntelligent resource management features:\n- Minimum and maximum buffer settings to ensure resource availability while controlling costs\n- Pool-wide capacity limits to prevent resource exhaustion\n- Automatic scaling based on demand\n\n## Runtime API Support Notes\n\n- `pause` / `resume` lifecycle APIs are currently **not supported** by the Kubernetes runtime.\n- Calling these APIs against Kubernetes runtime returns `501 Not Implemented`.\n- Pause/resume semantics in OpenSandbox mean preserving in-memory process state (container-level suspend/resume). Kubernetes provider currently focuses on create/get/list/delete/renew workflows.\n\n\n## Relationship with [kubernates-sigs/agent-sandbox](kubernates-sigs/agent-sandbox)\n\nBatchSandbox does not duplicate the basic functionality of Agent-Sandbox, but rather complements it with additional enhanced capabilities:\n\n1. **Batch Sandbox Semantics**: Significantly improves Sandbox delivery throughput in scenarios such as Reinforcement Learning (RL) training\n2. **Task Scheduling Capability**: Enables differentiated Sandbox delivery through Task scheduling, such as injecting custom processes into containers before Sandbox delivery\n\nTherefore, you can choose the appropriate project as your Sandbox underlying runtime based on your specific application scenarios.\n\n### Performance Testing\n\nPerformance comparison test of BatchSandbox and Sig Agent-Sandbox in terms of throughput.\n\n**Test Environment**\n\n**Controller Component Configuration**\n- Resource Specifications: request: 12C32G, limit: 16C64G\n- Concurrency Configuration:\n  - **Sig Agent-Sandbox**: 3 controllers (sandbox, sandboxclaim, sandboxwarmppool), no concurrency configuration provided in the code, default value is 1\n  - **BatchSandbox**: 2 controllers, batchsandbox controller concurrency is 32, pool controller concurrency is 1\n\n**Pool Configuration**\n- Image: busybox:latest\n- Resource Specifications: 0.1C256MB\n\n> **Additional Note**: Although the batchsandbox-controller of BatchSandbox has a concurrency of 32, only one BatchSandbox object was created in the test cases, which is actually equivalent to a concurrency of 1. Therefore, in terms of concurrency, BatchSandbox is consistent with SIG Agent-Sandbox.\n\n**Performance Comparison Results**\n\nWhen both use resource pools, the total time comparison for delivering 100 Sandboxes:\n\n| Test Scenario | Total Time (seconds) |\n|---------------|---------------------|\n| SIG Agent-Sandbox (concurrency=1) | 76.35 |\n| SIG Agent-Sandbox (concurrency=10) | 23.17 |\n| SIG Agent-Sandbox (concurrency=50) | 33.85 |\n| BatchSandbox | 0.92 |\n\n**Analysis**\n\nCore Difference: The time complexity of Sig Agent-Sandbox and BatchSandbox for batch delivery of N Sandboxes is O(N) and O(1) respectively.\n\n**Sig Agent-Sandbox Architecture**\n- Each Sandbox delivery process requires the following write operations (total write operations are proportional to Sandbox scale):\n  1. Create a SandboxClaim\n  2. Create a Sandbox\n  3. Update Pod once (adopt Pod from resource pool)\n  4. Update Sandbox Status once\n  5. Update SandboxClaim Status once\n\n**BatchSandbox Architecture**\n- Each batch Sandbox delivery process requires the following write operations (total write operations are independent of Sandbox scale):\n  1. Create a BatchSandbox\n  2. Update BatchSandbox annotation once (write batch allocation results)\n  3. Update BatchSandbox status once\n\n## Getting Started\n![](images/deploy-example.gif)\n\n### Prerequisites\n- go version v1.24.0+\n- docker version 17.03+\n- kubectl version v1.11.3+\n- Access to a Kubernetes v1.21.1+ cluster\n\nIf you don't have access to a Kubernetes cluster, you can use [kind](https://kind.sigs.k8s.io/) to create a local Kubernetes cluster for testing purposes. Kind runs Kubernetes nodes in Docker containers, making it easy to set up a local development environment.\n\nTo install kind:\n- Download the release binary for your OS from the [releases page](https://github.com/kubernetes-sigs/kind/releases) and move it into your `$PATH`\n- Or use a package manager:\n  - macOS (Homebrew): `brew install kind`\n  - Windows (winget): `winget install Kubernetes.kind`\n\nAfter installing kind, create a cluster with:\n```sh\nkind create cluster\n```\n\nThis command creates a single-node cluster by default. To interact with it, use `kubectl` with the generated kubeconfig.\n\n**Important Note for Kind Users**: If you're using a kind cluster, you need to load the controller and task-executor images into the kind node after building them with `make docker-build`. This is because kind runs Kubernetes nodes in Docker containers and cannot directly access images from your local Docker daemon.\n\nLoad the images into the kind cluster with:\n```sh\nkind load docker-image <controller-image-name>:<tag>\nkind load docker-image <task-executor-image-name>:<tag>\n```\n\nFor example, if you built your images with `make docker-build CONTROLLER_IMG=my-controller:latest`, you would load them with:\n```sh\nkind load docker-image my-controller:latest\n```\n\nDelete the cluster when you're done with:\n```sh\nkind delete cluster\n```\n\nFor more detailed instructions on using kind, please refer to the [official kind documentation](https://kind.sigs.k8s.io/docs/user/quick-start/).\n\n### Deployment\n\nThis project requires two separate images - one for the controller and another for the task-executor component.\n\n#### Option 1: Deploy with Helm (Recommended)\n\n**Install from GitHub Release:**\n\nYou can install OpenSandbox Controller directly from GitHub Releases. Check the [Releases page](https://github.com/alibaba/OpenSandbox/releases?q=helm%2Fopensandbox-controller&expanded=true) for all available versions.\n\n```sh\n# Replace <version> with the desired version (e.g., 0.1.0)\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<version>/opensandbox-controller-<version>.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\nExample with specific version:\n```sh\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\nYou can also download the chart first and then install:\n```sh\n# Download the chart\nwget https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<version>/opensandbox-controller-<version>.tgz\n\n# Install from local file\nhelm install opensandbox-controller ./opensandbox-controller-<version>.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n**Customize Installation:**\n\nUse `--set` flags to customize the configuration:\n\n```sh\n# Example: Custom resource limits\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  --set controller.replicaCount=2 \\\n  --set controller.resources.limits.cpu=1000m \\\n  --set controller.resources.limits.memory=512Mi\n\n# Example: Custom log level\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  --set controller.logLevel=debug\n```\n\nOr use a values file for complex configurations:\n\n```sh\n# Create a custom values file\ncat > custom-values.yaml <<EOF\ncontroller:\n  replicaCount: 2\n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n    requests:\n      cpu: 100m\n      memory: 128Mi\n  logLevel: debug\nEOF\n\n# Install with custom values\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  -f custom-values.yaml\n```\n\n**Install from source (for development):**\n\nIf you're developing or need to customize the chart:\n\n1. **Build and push your images:**\n   ```sh\n   # Build and push the controller image\n   make docker-build docker-push CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag\n   \n   # Build and push the task-executor image\n   make docker-build-task-executor docker-push-task-executor TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n2. **Install with Helm:**\n   ```sh\n   helm install opensandbox-controller ./charts/opensandbox-controller \\\n     --set controller.image.repository=<some-registry>/opensandbox-controller \\\n     --set controller.image.tag=<tag> \\\n     --namespace opensandbox-system \\\n     --create-namespace\n   ```\n\n**Verify Installation:**\n\nCheck the controller is running:\n```sh\nkubectl get pods -n opensandbox-system\nkubectl get deployment -n opensandbox-system\n\n# Check logs\nkubectl logs -n opensandbox-system -l control-plane=controller-manager -f\n```\n\n**Upgrade:**\n\n```sh\n# Upgrade to a new version\nhelm upgrade opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/<new-version>/opensandbox-controller-<new-version>.tgz \\\n  --namespace opensandbox-system\n```\n\n**Uninstall:**\n\n```sh\nhelm uninstall opensandbox-controller -n opensandbox-system\n```\n\nFor more configuration options and advanced usage, see the [Helm Chart README](charts/opensandbox-controller/README.md).\n\n#### Option 2: Deploy with Kustomize\n\n1. **Build and push your images:**\n   ```sh\n   # Build and push the controller image\n   make docker-build docker-push CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag\n   \n   # Build and push the task-executor image\n   make docker-build-task-executor docker-push-task-executor TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n   **NOTE:** These images ought to be published in the personal registry you specified. And it is required to have access to pull the images from the working environment. Make sure you have the proper permission to the registry if the above commands don't work.\n\n2. **Install the CRDs into the cluster:**\n   ```sh\n   make install\n   ```\n\n3. **Deploy the Manager to the cluster:**\n   ```sh\n   make deploy CONTROLLER_IMG=<some-registry>/opensandbox-controller:tag TASK_EXECUTOR_IMG=<some-registry>/opensandbox-task-executor:tag\n   ```\n\n   **NOTE**: you may need to grant yourself cluster-admin privileges or be logged in as admin to ensure you have cluster-admin privileges before running the commands.\n\n**Important Note for Kind Users**: If you're using a kind cluster, you need to load both images into the kind node after building them:\n```sh\nkind load docker-image <controller-image-name>:<tag>\nkind load docker-image <task-executor-image-name>:<tag>\n```\n\n### Creating BatchSandbox and Pool Resources\n\n#### Basic Example\nCreate a simple non-pooled sandbox without task scheduling:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: basic-batch-sandbox\nspec:\n  replicas: 2\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: nginx:latest\n        ports:\n        - containerPort: 80\n```\n\nApply the batch sandbox configuration:\n```sh\nkubectl apply -f basic-batch-sandbox.yaml\n```\n\nCheck the status of your batch sandbox:\n```sh\nkubectl get batchsandbox basic-batch-sandbox -o wide\n```\n\nExample output:\n```sh\nNAME                   DESIRED   TOTAL   ALLOCATED   READY   EXPIRE   AGE\nbasic-batch-sandbox    2         2       2           2       <none>   5m\n```\n\nStatus field explanations:\n- **DESIRED**: The number of sandboxes requested\n- **TOTAL**: The total number of sandboxes created\n- **ALLOCATED**: The number of sandboxes successfully allocated\n- **READY**: The number of sandboxes ready for use\n- **EXPIRE**: Expiration time (empty if not set)\n- **AGE**: Time since the resource was created\n\nAfter the sandboxes are ready, you can find the endpoint information in the annotations:\n```sh\nkubectl get batchsandbox basic-batch-sandbox -o jsonpath='{.metadata.annotations.sandbox\\.opensandbox\\.io/endpoints}'\n```\n\nThis will show the IP addresses of the delivered sandboxes.\n\n#### Advanced Examples\n\n##### Pooled Sandbox Without Task\nFirst, create a resource pool:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: example-pool\nspec:\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: nginx:latest\n        ports:\n        - containerPort: 80\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\nApply the pool configuration:\n```sh\nkubectl apply -f pool-example.yaml\n```\n\nCreate a batch of sandboxes using the pool:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: pooled-batch-sandbox\nspec:\n  replicas: 3\n  poolRef: example-pool\n```\n\nApply the batch sandbox configuration:\n```sh\nkubectl apply -f pooled-batch-sandbox.yaml\n```\n\n##### Pooled Sandbox With Heterogeneous Tasks\nCreate a batch of sandboxes with process-based heterogeneous tasks. For task execution to work properly, the task-executor must be deployed as a sidecar container in the pool template and share the process namespace with the sandbox container:\n\nFirst, create a resource pool with the task-executor sidecar:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: task-example-pool\nspec:\n  template:\n    spec:\n      shareProcessNamespace: true\n      containers:\n      - name: sandbox-container\n        image: ubuntu:latest\n        command: [\"sleep\", \"3600\"]\n      - name: task-executor\n        image: <task-executor-image>:<tag>\n        securityContext:\n          capabilities:\n            add: [\"SYS_PTRACE\"]\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\nCreate a batch of sandboxes with process-based heterogeneous tasks using the pool we just created:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: task-batch-sandbox\nspec:\n  replicas: 2\n  poolRef: task-example-pool\n  taskTemplate:\n    spec:\n      process:\n        command: [\"echo\", \"Default task\"]\n  shardTaskPatches:\n  - spec:\n      process:\n        command: [\"echo\", \"Custom task for sandbox 1\"]\n  - spec:\n      process:\n        command: [\"echo\", \"Custom task for sandbox 2\"]\n        args: [\"with\", \"additional\", \"arguments\"]\n```\n\nApply the batch sandbox configuration:\n```sh\nkubectl apply -f task-batch-sandbox.yaml\n```\n\nCheck the status of your batch sandbox with tasks:\n```sh\nkubectl get batchsandbox task-batch-sandbox -o wide\n```\n\nExample output:\n```sh\nNAME                   DESIRED   TOTAL   ALLOCATED   READY   TASK_RUNNING   TASK_SUCCEED   TASK_FAILED   TASK_UNKNOWN   EXPIRE   AGE\ntask-batch-sandbox     2         2       2           2       0              2              0             0              <none>   5m\n```\n\nTask status field explanations:\n- **TASK_RUNNING**: The number of tasks currently executing\n- **TASK_SUCCEED**: The number of tasks that have completed successfully\n- **TASK_FAILED**: The number of tasks that have failed\n- **TASK_UNKNOWN**: The number of tasks with unknown status\n\nWhen you delete a BatchSandbox with running tasks, the controller will first stop all tasks before deleting the BatchSandbox resource. Once all tasks are successfully terminated, the BatchSandbox will be completely removed, and the sandboxes will be returned to the pool for reuse.\n\nTo delete the BatchSandbox:\n```sh\nkubectl delete batchsandbox task-batch-sandbox\n```\n\nYou can monitor the deletion process by watching the BatchSandbox status:\n```sh\nkubectl get batchsandbox task-batch-sandbox -w\n```\n\n### Monitoring Resources\nCheck the status of your pools and batch sandboxes:\n\n```sh\n# View pool status\nkubectl get pools\n\n# View batch sandbox status\nkubectl get batchsandboxes\n\n# Get detailed information about a specific resource\nkubectl describe pool example-pool\nkubectl describe batchsandbox example-batch-sandbox\n```\n\n## Project Structure\n\n```\n├── api/\n│   └── v1alpha1/          # Custom resource definitions (BatchSandbox, Pool)\n├── cmd/\n│   ├── controller/         # Main controller manager entry point\n│   └── task-executor/     # Task executor binary\n├── config/\n│   ├── crd/               # Custom resource definitions manifests\n│   ├── default/           # Default configuration for controller deployment\n│   ├── manager/           # Controller manager configuration\n│   ├── rbac/              # Role-based access control manifests\n│   └── samples/           # Sample YAML manifests for resources\n├── hack/                  # Development scripts and tools\n├── images/                # Documentation images\n├── internal/\n│   ├── controller/        # Core controller implementations\n│   ├── scheduler/         # Resource allocation and scheduling logic\n│   ├── task-executor/     # Task execution engine internals\n│   └── utils/             # Utility functions and helpers\n├── pkg/\n│   └── task-executor/     # Shared task executor packages\n└── test/                  # Test suites and utilities\n```\n\n## Contributing\nWe welcome contributions to the OpenSandbox Kubernetes Controller project. Please feel free to submit issues, feature requests, and pull requests.\n\n**NOTE:** Run `make help` for more information on all potential `make` targets\n\nMore information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)\n\n## License\nThis project is open source under the Apache 2.0 License.\n\nYou can use OpenSandbox for personal or commercial projects in compliance with the license terms.\n"
  },
  {
    "path": "kubernetes/apis/sandbox/v1alpha1/batchsandbox_types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1alpha1\n\nimport (\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n)\n\n// BatchSandboxSpec defines the desired state of BatchSandbox.\ntype BatchSandboxSpec struct {\n\t// Replicas is the number of desired replicas.\n\t// +kubebuilder:validation:Required\n\t// +kubebuilder:validation:Minimum=0\n\t// +kubebuilder:default=1\n\tReplicas *int32 `json:\"replicas,omitempty\"`\n\t// PoolRef references the Pool resource name for pooled sandbox creation.\n\t// Mutually exclusive with Template - use PoolRef for pool-based allocation or Template for direct sandbox creation.\n\t// +optional\n\t// +kubebuilder:validation:Optional\n\tPoolRef string `json:\"poolRef,omitempty\"`\n\t// +optional\n\t// Template describes the pods that will be created.\n\t// +kubebuilder:pruning:PreserveUnknownFields\n\t// +kubebuilder:validation:Schemaless\n\t// +kubebuilder:validation:Optional\n\tTemplate *corev1.PodTemplateSpec `json:\"template\"`\n\t// ShardPatches indicates patching to the Template for BatchSandbox.\n\t// +kubebuilder:pruning:PreserveUnknownFields\n\t// +kubebuilder:validation:Schemaless\n\t// +optional\n\t// +kubebuilder:validation:Optional\n\tShardPatches []runtime.RawExtension `json:\"shardPatches,omitempty\"`\n\t// ExpireTime - Absolute time when the batch-sandbox is deleted.\n\t// If a time in the past is provided, the batch-sandbox will be deleted immediately.\n\t// +optional\n\t// +kubebuilder:validation:Format=\"date-time\"\n\t// +kubebuilder:validation:Optional\n\tExpireTime *metav1.Time `json:\"expireTime,omitempty\"`\n\t// Task is a custom task spec that is automatically dispatched after the sandbox is successfully created.\n\t// The Sandbox is responsible for managing the lifecycle of the task.\n\t// +optional\n\t// +kubebuilder:pruning:PreserveUnknownFields\n\t// +kubebuilder:validation:Schemaless\n\t// +kubebuilder:validation:Optional\n\tTaskTemplate *TaskTemplateSpec `json:\"taskTemplate,omitempty\"`\n\t// ShardTaskPatches indicates patching to the TaskTemplate for individual Task.\n\t// +kubebuilder:pruning:PreserveUnknownFields\n\t// +kubebuilder:validation:Schemaless\n\t// +optional\n\t// +kubebuilder:validation:Optional\n\tShardTaskPatches []runtime.RawExtension `json:\"shardTaskPatches,omitempty\"`\n\t// TaskResourcePolicyWhenCompleted specifies how resources should be handled once a task reaches a completed state (SUCCEEDED or FAILED).\n\t// - Retain: Keep the resources until the BatchSandbox is deleted.\n\t// - Release: Free the resources immediately when the task completes.\n\t// +optional\n\t// +kubebuilder:default=Retain\n\t// +kubebuilder:validation:Optional\n\tTaskResourcePolicyWhenCompleted *TaskResourcePolicy `json:\"taskResourcePolicyWhenCompleted,omitempty\"`\n}\n\ntype TaskResourcePolicy string\n\nconst (\n\tTaskResourcePolicyRetain  TaskResourcePolicy = \"Retain\"\n\tTaskResourcePolicyRelease TaskResourcePolicy = \"Release\"\n)\n\n// BatchSandboxStatus defines the observed state of BatchSandbox.\ntype BatchSandboxStatus struct {\n\t// ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n\t// BatchSandbox's generation, which is updated on mutation by the API Server.\n\tObservedGeneration int64 `json:\"observedGeneration,omitempty\"`\n\t// Replicas is the number of actual Pods\n\tReplicas int32 `json:\"replicas\"`\n\t//\tAllocated is the number of actual scheduled Pod\n\tAllocated int32 `json:\"allocated\"`\n\t//\tReady is the number of actual Ready Pod\n\tReady int32 `json:\"ready\"`\n\t// TaskRunning is the number of Running task\n\tTaskRunning int32 `json:\"taskRunning\"`\n\t// TaskSucceed is the number of Succeed task\n\tTaskSucceed int32 `json:\"taskSucceed\"`\n\t// TaskFailed is the number of Failed task\n\tTaskFailed int32 `json:\"taskFailed\"`\n\t// TaskPending is the number of Pending task which is unassigned\n\tTaskPending int32 `json:\"taskPending\"`\n\t// TaskUnknown is the number of Unknown task\n\tTaskUnknown int32 `json:\"taskUnknown\"`\n}\n\n// +genclient\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\n// +kubebuilder:object:root=true\n// +kubebuilder:subresource:status\n// +kubebuilder:resource:shortName=bsbx\n// +kubebuilder:printcolumn:name=\"DESIRED\",type=\"integer\",JSONPath=\".spec.replicas\",description=\"The desired number of pods.\"\n// +kubebuilder:printcolumn:name=\"TOTAL\",type=\"integer\",JSONPath=\".status.replicas\",description=\"The number of currently all pods.\"\n// +kubebuilder:printcolumn:name=\"ALLOCATED\",type=\"integer\",JSONPath=\".status.allocated\",description=\"The number of currently all allocated pods.\"\n// +kubebuilder:printcolumn:name=\"Ready\",type=\"integer\",JSONPath=\".status.ready\",description=\"The number of currently all ready pods.\"\n// +kubebuilder:printcolumn:name=\"TASK_RUNNING\",type=\"integer\",priority=1,JSONPath=\".status.taskRunning\",description=\"The number of currently all running tasks.\"\n// +kubebuilder:printcolumn:name=\"TASK_SUCCEED\",type=\"integer\",priority=1,JSONPath=\".status.taskSucceed\",description=\"The number of currently all succeed tasks.\"\n// +kubebuilder:printcolumn:name=\"TASK_FAILED\",type=\"integer\",priority=1,JSONPath=\".status.taskFailed\",description=\"The number of currently all failed tasks.\"\n// +kubebuilder:printcolumn:name=\"TASK_UNKNOWN\",type=\"integer\",priority=1,JSONPath=\".status.taskUnknown\",description=\"The number of currently all unknown tasks.\"\n// +kubebuilder:printcolumn:name=\"EXPIRE\",type=\"string\",JSONPath=\".spec.expireTime\",description=\"sandbox expire time\"\n// +kubebuilder:printcolumn:name=\"AGE\",type=\"date\",JSONPath=\".metadata.creationTimestamp\",description=\"CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\"\n// BatchSandbox is the Schema for the batchsandboxes API.\ntype BatchSandbox struct {\n\tmetav1.TypeMeta   `json:\",inline\"`\n\tmetav1.ObjectMeta `json:\"metadata,omitempty\"`\n\n\tSpec   BatchSandboxSpec   `json:\"spec,omitempty\"`\n\tStatus BatchSandboxStatus `json:\"status,omitempty\"`\n}\n\n// +kubebuilder:object:root=true\n\n// BatchSandboxList contains a list of BatchSandbox.\ntype BatchSandboxList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\tmetav1.ListMeta `json:\"metadata,omitempty\"`\n\tItems           []BatchSandbox `json:\"items\"`\n}\n\nfunc init() {\n\tSchemeBuilder.Register(&BatchSandbox{}, &BatchSandboxList{})\n}\n\n// TaskTemplateSpec task spec\ntype TaskTemplateSpec struct {\n\t// +optional\n\tSpec TaskSpec `json:\"spec,omitempty\"`\n}\n\ntype TaskSpec struct {\n\t// +optional\n\tProcess *ProcessTask `json:\"process,omitempty\"`\n\t// TimeoutSeconds specifies the maximum duration in seconds for task execution.\n\t// If exceeded, the task executor should terminate the task.\n\t// +optional\n\tTimeoutSeconds *int64 `json:\"timeoutSeconds,omitempty\"`\n}\n\ntype ProcessTask struct {\n\t// Command command\n\t// +kubebuilder:validation:Required\n\tCommand []string `json:\"command\"`\n\t// Arguments to the entrypoint.\n\t// +optional\n\tArgs []string `json:\"args,omitempty\"`\n\t// List of environment variables to set in the task.\n\t// +optional\n\t// +patchMergeKey=name\n\t// +patchStrategy=merge\n\tEnv []corev1.EnvVar `json:\"env,omitempty\"`\n\t// WorkingDir task working directory.\n\t// +optional\n\tWorkingDir string `json:\"workingDir,omitempty\"`\n}\n\n// TaskStatus task status\ntype TaskStatus struct {\n\t// Details about the task's current condition.\n\t// +optional\n\tState TaskState `json:\"state,omitempty\"`\n\t// Details about the task's last termination condition.\n\t// +optional\n\tLastTerminationState TaskState `json:\"lastState,omitempty\"`\n}\n\n// TaskState holds a possible state of task.\n// Only one of its members may be specified.\n// If none of them is specified, the default one is TaskStateWaiting.\ntype TaskState struct {\n\t// Details about a waiting task\n\t// +optional\n\tWaiting *TaskStateWaiting `json:\"waiting,omitempty\"`\n\t// Details about a running task\n\t// +optional\n\tRunning *TaskStateRunning `json:\"running,omitempty\"`\n\t// Details about a terminated task\n\t// +optional\n\tTerminated *TaskStateTerminated `json:\"terminated,omitempty\"`\n}\n\n// TaskStateWaiting is a waiting state of a task.\ntype TaskStateWaiting struct {\n\t// (brief) reason the task is not yet running.\n\t// +optional\n\tReason string `json:\"reason,omitempty\"`\n\t// Message regarding why the task is not yet running.\n\t// +optional\n\tMessage string `json:\"message,omitempty\"`\n}\n\n// TaskStateRunning is a running state of a task.\ntype TaskStateRunning struct {\n\t// Time at which the task was last (re-)started\n\t// +optional\n\tStartedAt metav1.Time `json:\"startedAt,omitempty\"`\n}\n\n// TaskStateTerminated is a terminated state of a task.\ntype TaskStateTerminated struct {\n\t// Exit status from the last termination of the task\n\tExitCode int32 `json:\"exitCode\"`\n\t// Signal from the last termination of the task\n\t// +optional\n\tSignal int32 `json:\"signal,omitempty\"`\n\t// (brief) reason from the last termination of the task\n\t// +optional\n\tReason string `json:\"reason,omitempty\"`\n\t// Message regarding the last termination of the task\n\t// +optional\n\tMessage string `json:\"message,omitempty\"`\n\t// Time at which previous execution of the task started\n\t// +optional\n\tStartedAt metav1.Time `json:\"startedAt,omitempty\"`\n\t// Time at which the task last terminated\n\t// +optional\n\tFinishedAt metav1.Time `json:\"finishedAt,omitempty\"`\n}\n"
  },
  {
    "path": "kubernetes/apis/sandbox/v1alpha1/doc.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// +k8s:openapi-gen=true\n// +groupName=sandbox.opensandbox.io\npackage v1alpha1\n"
  },
  {
    "path": "kubernetes/apis/sandbox/v1alpha1/groupversion_info.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package v1alpha1 contains API Schema definitions for the sandbox v1alpha1 API group.\n// +kubebuilder:object:generate=true\n// +groupName=sandbox.opensandbox.io\npackage v1alpha1\n\nimport (\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"sigs.k8s.io/controller-runtime/pkg/scheme\"\n)\n\nvar (\n\t// GroupVersion is group version used to register these objects.\n\tGroupVersion = schema.GroupVersion{Group: \"sandbox.opensandbox.io\", Version: \"v1alpha1\"}\n\n\t// SchemeGroupVersion is an alias for GroupVersion to match code-generator expectations\n\tSchemeGroupVersion = GroupVersion\n\n\t// SchemeBuilder is used to add go types to the GroupVersionKind scheme.\n\tSchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}\n\n\t// AddToScheme adds the types in this group-version to the given scheme.\n\tAddToScheme = SchemeBuilder.AddToScheme\n)\n\n// Resource takes an unqualified resource and returns a Group qualified GroupResource\nfunc Resource(resource string) schema.GroupResource {\n\treturn SchemeGroupVersion.WithResource(resource).GroupResource()\n}\n"
  },
  {
    "path": "kubernetes/apis/sandbox/v1alpha1/pool_types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage v1alpha1\n\nimport (\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!\n// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.\n\n// PoolSpec defines the desired state of Pool.\ntype PoolSpec struct {\n\t// Pod Template used to create pre-warmed nodes in the pool.\n\t// +kubebuilder:pruning:PreserveUnknownFields\n\t// +kubebuilder:validation:Schemaless\n\t// +kubebuilder:validation:Optional\n\tTemplate *corev1.PodTemplateSpec `json:\"template\"`\n\t// CapacitySpec controls the size of the resource pool.\n\t// +kubebuilder:validation:Required\n\tCapacitySpec CapacitySpec `json:\"capacitySpec\"`\n}\n\ntype CapacitySpec struct {\n\t// BufferMax is the maximum number of nodes kept in the warm buffer.\n\t// +kubebuilder:validation:Minimum=0\n\t// +kubebuilder:validation:Required\n\tBufferMax int32 `json:\"bufferMax\"`\n\t// BufferMin is the minimum number of nodes that must remain in the buffer.\n\t// +kubebuilder:validation:Minimum=0\n\t// +kubebuilder:validation:Required\n\tBufferMin int32 `json:\"bufferMin\"`\n\t// PoolMax is the maximum total number of nodes allowed in the entire pool.\n\t// +kubebuilder:validation:Minimum=0\n\t// +kubebuilder:validation:Required\n\tPoolMax int32 `json:\"poolMax\"`\n\t// PoolMin is the minimum total size of the pool.\n\t// +kubebuilder:validation:Minimum=0\n\t// +kubebuilder:validation:Required\n\tPoolMin int32 `json:\"poolMin\"`\n}\n\n// PoolStatus defines the observed state of Pool.\ntype PoolStatus struct {\n\t// ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n\t// BatchSandbox's generation, which is updated on mutation by the API Server.\n\tObservedGeneration int64 `json:\"observedGeneration,omitempty\"`\n\t// Revision is the latest version of pool\n\tRevision string `json:\"revision\"`\n\t// Total is the total number of nodes in the pool.\n\tTotal int32 `json:\"total\"`\n\t// Allocated is the number of nodes currently allocated to sandboxes.\n\tAllocated int32 `json:\"allocated\"`\n\t// Available is the number of nodes currently available in the pool.\n\tAvailable int32 `json:\"available\"`\n}\n\n// +genclient\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\n// +kubebuilder:object:root=true\n// +kubebuilder:subresource:status\n// +kubebuilder:printcolumn:name=\"TOTAL\",type=\"integer\",JSONPath=\".status.total\",description=\"The number of all nodes in pool.\"\n// +kubebuilder:printcolumn:name=\"ALLOCATED\",type=\"integer\",JSONPath=\".status.allocated\",description=\"The number of allocated nodes in pool.\"\n// +kubebuilder:printcolumn:name=\"AVAILABLE\",type=\"integer\",JSONPath=\".status.available\",description=\"The number of available nodes in pool.\"\n// Pool is the Schema for the pools API.\ntype Pool struct {\n\tmetav1.TypeMeta   `json:\",inline\"`\n\tmetav1.ObjectMeta `json:\"metadata,omitempty\"`\n\n\tSpec   PoolSpec   `json:\"spec,omitempty\"`\n\tStatus PoolStatus `json:\"status,omitempty\"`\n}\n\n// +kubebuilder:object:root=true\n\n// PoolList contains a list of Pool.\ntype PoolList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\tmetav1.ListMeta `json:\"metadata,omitempty\"`\n\tItems           []Pool `json:\"items\"`\n}\n\nfunc init() {\n\tSchemeBuilder.Register(&Pool{}, &PoolList{})\n}\n"
  },
  {
    "path": "kubernetes/apis/sandbox/v1alpha1/zz_generated.deepcopy.go",
    "content": "//go:build !ignore_autogenerated\n\n// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Code generated by controller-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\t\"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n)\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *BatchSandbox) DeepCopyInto(out *BatchSandbox) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n\tout.Status = in.Status\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchSandbox.\nfunc (in *BatchSandbox) DeepCopy() *BatchSandbox {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(BatchSandbox)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *BatchSandbox) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *BatchSandboxList) DeepCopyInto(out *BatchSandboxList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]BatchSandbox, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchSandboxList.\nfunc (in *BatchSandboxList) DeepCopy() *BatchSandboxList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(BatchSandboxList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *BatchSandboxList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *BatchSandboxSpec) DeepCopyInto(out *BatchSandboxSpec) {\n\t*out = *in\n\tif in.Replicas != nil {\n\t\tin, out := &in.Replicas, &out.Replicas\n\t\t*out = new(int32)\n\t\t**out = **in\n\t}\n\tif in.Template != nil {\n\t\tin, out := &in.Template, &out.Template\n\t\t*out = new(v1.PodTemplateSpec)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n\tif in.ShardPatches != nil {\n\t\tin, out := &in.ShardPatches, &out.ShardPatches\n\t\t*out = make([]runtime.RawExtension, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n\tif in.ExpireTime != nil {\n\t\tin, out := &in.ExpireTime, &out.ExpireTime\n\t\t*out = (*in).DeepCopy()\n\t}\n\tif in.TaskTemplate != nil {\n\t\tin, out := &in.TaskTemplate, &out.TaskTemplate\n\t\t*out = new(TaskTemplateSpec)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n\tif in.ShardTaskPatches != nil {\n\t\tin, out := &in.ShardTaskPatches, &out.ShardTaskPatches\n\t\t*out = make([]runtime.RawExtension, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n\tif in.TaskResourcePolicyWhenCompleted != nil {\n\t\tin, out := &in.TaskResourcePolicyWhenCompleted, &out.TaskResourcePolicyWhenCompleted\n\t\t*out = new(TaskResourcePolicy)\n\t\t**out = **in\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchSandboxSpec.\nfunc (in *BatchSandboxSpec) DeepCopy() *BatchSandboxSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(BatchSandboxSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *BatchSandboxStatus) DeepCopyInto(out *BatchSandboxStatus) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BatchSandboxStatus.\nfunc (in *BatchSandboxStatus) DeepCopy() *BatchSandboxStatus {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(BatchSandboxStatus)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *CapacitySpec) DeepCopyInto(out *CapacitySpec) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapacitySpec.\nfunc (in *CapacitySpec) DeepCopy() *CapacitySpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(CapacitySpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *Pool) DeepCopyInto(out *Pool) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n\tout.Status = in.Status\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Pool.\nfunc (in *Pool) DeepCopy() *Pool {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(Pool)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *Pool) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *PoolList) DeepCopyInto(out *PoolList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]Pool, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolList.\nfunc (in *PoolList) DeepCopy() *PoolList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(PoolList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *PoolList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *PoolSpec) DeepCopyInto(out *PoolSpec) {\n\t*out = *in\n\tif in.Template != nil {\n\t\tin, out := &in.Template, &out.Template\n\t\t*out = new(v1.PodTemplateSpec)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n\tout.CapacitySpec = in.CapacitySpec\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolSpec.\nfunc (in *PoolSpec) DeepCopy() *PoolSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(PoolSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *PoolStatus) DeepCopyInto(out *PoolStatus) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolStatus.\nfunc (in *PoolStatus) DeepCopy() *PoolStatus {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(PoolStatus)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *ProcessTask) DeepCopyInto(out *ProcessTask) {\n\t*out = *in\n\tif in.Command != nil {\n\t\tin, out := &in.Command, &out.Command\n\t\t*out = make([]string, len(*in))\n\t\tcopy(*out, *in)\n\t}\n\tif in.Args != nil {\n\t\tin, out := &in.Args, &out.Args\n\t\t*out = make([]string, len(*in))\n\t\tcopy(*out, *in)\n\t}\n\tif in.Env != nil {\n\t\tin, out := &in.Env, &out.Env\n\t\t*out = make([]v1.EnvVar, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProcessTask.\nfunc (in *ProcessTask) DeepCopy() *ProcessTask {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(ProcessTask)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskSpec) DeepCopyInto(out *TaskSpec) {\n\t*out = *in\n\tif in.Process != nil {\n\t\tin, out := &in.Process, &out.Process\n\t\t*out = new(ProcessTask)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n\tif in.TimeoutSeconds != nil {\n\t\tin, out := &in.TimeoutSeconds, &out.TimeoutSeconds\n\t\t*out = new(int64)\n\t\t**out = **in\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskSpec.\nfunc (in *TaskSpec) DeepCopy() *TaskSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskState) DeepCopyInto(out *TaskState) {\n\t*out = *in\n\tif in.Waiting != nil {\n\t\tin, out := &in.Waiting, &out.Waiting\n\t\t*out = new(TaskStateWaiting)\n\t\t**out = **in\n\t}\n\tif in.Running != nil {\n\t\tin, out := &in.Running, &out.Running\n\t\t*out = new(TaskStateRunning)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n\tif in.Terminated != nil {\n\t\tin, out := &in.Terminated, &out.Terminated\n\t\t*out = new(TaskStateTerminated)\n\t\t(*in).DeepCopyInto(*out)\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskState.\nfunc (in *TaskState) DeepCopy() *TaskState {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskState)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskStateRunning) DeepCopyInto(out *TaskStateRunning) {\n\t*out = *in\n\tin.StartedAt.DeepCopyInto(&out.StartedAt)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskStateRunning.\nfunc (in *TaskStateRunning) DeepCopy() *TaskStateRunning {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskStateRunning)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskStateTerminated) DeepCopyInto(out *TaskStateTerminated) {\n\t*out = *in\n\tin.StartedAt.DeepCopyInto(&out.StartedAt)\n\tin.FinishedAt.DeepCopyInto(&out.FinishedAt)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskStateTerminated.\nfunc (in *TaskStateTerminated) DeepCopy() *TaskStateTerminated {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskStateTerminated)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskStateWaiting) DeepCopyInto(out *TaskStateWaiting) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskStateWaiting.\nfunc (in *TaskStateWaiting) DeepCopy() *TaskStateWaiting {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskStateWaiting)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskStatus) DeepCopyInto(out *TaskStatus) {\n\t*out = *in\n\tin.State.DeepCopyInto(&out.State)\n\tin.LastTerminationState.DeepCopyInto(&out.LastTerminationState)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskStatus.\nfunc (in *TaskStatus) DeepCopy() *TaskStatus {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskStatus)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *TaskTemplateSpec) DeepCopyInto(out *TaskTemplateSpec) {\n\t*out = *in\n\tin.Spec.DeepCopyInto(&out.Spec)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskTemplateSpec.\nfunc (in *TaskTemplateSpec) DeepCopy() *TaskTemplateSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TaskTemplateSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n"
  },
  {
    "path": "kubernetes/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -e\n\n# Default values\nTAG=${TAG:-latest}\nCOMPONENT=${COMPONENT:-controller}\nPUSH=${PUSH:-true}\n\n# Image repository\nACR_REPO=\"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox\"\n\n# Component specific settings\nif [ \"$COMPONENT\" == \"controller\" ]; then\n    IMAGE_NAME=\"controller\"\n    BUILD_ARG=\"--build-arg PACKAGE=cmd/controller/main.go\"\nelif [ \"$COMPONENT\" == \"task-executor\" ]; then\n    IMAGE_NAME=\"task-executor\"\n    BUILD_ARG=\"--build-arg PACKAGE=cmd/task-executor/main.go --build-arg USERID=0\"\nelse\n    echo \"Error: Unknown component: $COMPONENT\"\n    echo \"Available components: controller, task-executor\"\n    exit 1\nfi\n\necho \"=========================================\"\necho \"Building $COMPONENT\"\necho \"Image: $IMAGE_NAME\"\necho \"Tag: $TAG\"\necho \"Push: $PUSH\"\necho \"=========================================\"\n\n# Build for multiple platforms\nPLATFORMS=\"linux/amd64,linux/arm64\"\n\nif [ \"$PUSH\" == \"true\" ]; then\n    # Build and push to ACR registry\n    docker buildx build \\\n        --platform $PLATFORMS \\\n        $BUILD_ARG \\\n        -t ${ACR_REPO}/${IMAGE_NAME}:${TAG} \\\n        --push \\\n        -f Dockerfile \\\n        .\n    \n    echo \"=========================================\"\n    echo \"Successfully built and pushed:\"\n    echo \"  ${ACR_REPO}/${IMAGE_NAME}:${TAG}\"\n    echo \"=========================================\"\nelse\n    # Build only (for local testing)\n    docker buildx build \\\n        --platform linux/amd64 \\\n        $BUILD_ARG \\\n        -t ${IMAGE_NAME}:${TAG} \\\n        -f Dockerfile \\\n        --load \\\n        .\n    \n    echo \"=========================================\"\n    echo \"Successfully built (local only):\"\n    echo \"  ${IMAGE_NAME}:${TAG}\"\n    echo \"=========================================\"\nfi\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n# OWNERS file\nOWNERS\n# Make files\nMakefile\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/Chart.yaml",
    "content": "apiVersion: v2\nname: opensandbox-controller\ndescription: A Kubernetes operator for managing sandbox environments with resource pooling and batch delivery\ntype: application\nversion: 0.1.0\nappVersion: \"0.1.0\"\n\nkeywords:\n  - sandbox\n  - kubernetes\n  - operator\n  - resource-pool\n  - batch-sandbox\n  - task-orchestration\n\nhome: https://github.com/alibaba/OpenSandbox\nsources:\n  - https://github.com/alibaba/OpenSandbox/tree/main/kubernetes\n\nmaintainers:\n  - name: OpenSandbox Team\n    email: opensandbox@example.com\n\nicon: https://raw.githubusercontent.com/alibaba/OpenSandbox/main/kubernetes/images/logo.png\n\n# Kubernetes version constraints\nkubeVersion: \">=1.21.1-0\"\n\nannotations:\n  # Category for Artifact Hub\n  artifacthub.io/category: integration-delivery\n  artifacthub.io/license: Apache-2.0\n  artifacthub.io/signKey: |\n    fingerprint: [your-gpg-fingerprint]\n  artifacthub.io/prerelease: \"false\"\n  artifacthub.io/operator: \"true\"\n  artifacthub.io/operatorCapabilities: Full Lifecycle\n  artifacthub.io/recommendations: |\n    - url: https://github.com/kubernetes-sigs/kind\n  artifacthub.io/links: |\n    - name: Documentation\n      url: https://github.com/alibaba/OpenSandbox/blob/main/kubernetes/README.md\n    - name: Support\n      url: https://github.com/alibaba/OpenSandbox/issues\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/README.md",
    "content": "# OpenSandbox Controller Helm Chart\n\nA Helm chart for deploying the OpenSandbox Kubernetes Controller, which manages sandbox environments with resource pooling and batch delivery capabilities.\n\n## Introduction\n\nThis chart bootstraps an OpenSandbox Controller deployment on a Kubernetes cluster using the Helm package manager. The controller provides:\n\n- **Batch Sandbox Management**: Create and manage multiple identical sandbox environments\n- **Resource Pooling**: Maintain pre-warmed resource pools for rapid sandbox provisioning\n- **Task Orchestration**: Optional task execution within sandboxes\n- **High Performance**: O(1) time complexity for batch sandbox delivery\n\n## Prerequisites\n\n- Kubernetes 1.21.1+\n- Helm 3.0+\n- Container runtime (Docker, containerd, etc.)\n\n## Installing the Chart\n\nTo install the chart with the release name `opensandbox-controller`:\n\n```bash\nhelm install opensandbox-controller ./opensandbox-controller \\\n  --set controller.image.repository=<your-registry>/opensandbox-controller \\\n  --set controller.image.tag=v0.1.0 \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\nThe command deploys OpenSandbox Controller on the Kubernetes cluster with default configuration. The [Parameters](#parameters) section lists the parameters that can be configured during installation.\n\n## Uninstalling the Chart\n\nTo uninstall/delete the `opensandbox-controller` deployment:\n\n```bash\nhelm delete opensandbox-controller -n opensandbox-system\n```\n\nThe command removes all the Kubernetes components associated with the chart. Note that CRDs are kept by default (can be changed via `crds.keep`).\n\nTo also remove the CRDs:\n\n```bash\nkubectl delete crd batchsandboxes.sandbox.opensandbox.io\nkubectl delete crd pools.sandbox.opensandbox.io\n```\n\n## Parameters\n\n### Global Parameters\n\n| Name | Description | Value |\n|------|-------------|-------|\n| `nameOverride` | Override the name of the chart | `\"\"` |\n| `fullnameOverride` | Override the full name of the chart | `\"\"` |\n| `namespaceOverride` | Override the namespace where resources will be created | `\"\"` |\n\n### Controller Parameters\n\n| Name | Description | Value |\n|------|-------------|-------|\n| `controller.image.repository` | Controller image repository | `opensandbox.io/opensandbox-controller` |\n| `controller.image.pullPolicy` | Image pull policy | `IfNotPresent` |\n| `controller.image.tag` | Overrides the image tag (default is chart appVersion) | `\"\"` |\n| `controller.replicaCount` | Number of controller replicas | `1` |\n| `controller.resources.limits.cpu` | CPU resource limits | `500m` |\n| `controller.resources.limits.memory` | Memory resource limits | `128Mi` |\n| `controller.resources.requests.cpu` | CPU resource requests | `10m` |\n| `controller.resources.requests.memory` | Memory resource requests | `64Mi` |\n| `controller.logLevel` | Can be one of 'debug', 'info', 'error' | `info` |\n| `controller.kubeClient.qps` | QPS for Kubernetes client rate limiter | `100` |\n| `controller.kubeClient.burst` | Burst for Kubernetes client rate limiter | `200` |\n| `controller.leaderElection.enabled` | Enable leader election | `true` |\n| `controller.nodeSelector` | Node labels for pod assignment | `{}` |\n| `controller.tolerations` | Tolerations for pod assignment | `[]` |\n| `controller.affinity` | Affinity for pod assignment | `{}` |\n| `controller.podLabels` | Additional labels for controller pods | `{}` |\n| `controller.podAnnotations` | Additional annotations for controller pods | `{}` |\n| `controller.priorityClassName` | Priority class name for controller pods | `\"\"` |\n\n### RBAC Parameters\n\n| Name | Description | Value |\n|------|-------------|-------|\n| `rbac.create` | Specifies whether RBAC resources should be created | `true` |\n| `serviceAccount.create` | Specifies whether a service account should be created | `true` |\n| `serviceAccount.annotations` | Annotations to add to the service account | `{}` |\n| `serviceAccount.name` | The name of the service account to use | `\"\"` |\n\n### CRD Parameters\n\n| Name | Description | Value |\n|------|-------------|-------|\n| `crds.install` | Specifies whether CRDs should be installed | `true` |\n| `crds.keep` | Keep CRDs on chart uninstall | `true` |\n| `crds.annotations` | Annotations to add to CRDs | `{\"helm.sh/resource-policy\": \"keep\"}` |\n\n### Additional Parameters\n\n| Name | Description | Value |\n|------|-------------|-------|\n| `imagePullSecrets` | Image pull secrets for private registries | `[]` |\n| `extraEnv` | Additional environment variables | `[]` |\n| `extraVolumes` | Additional volumes | `[]` |\n| `extraVolumeMounts` | Additional volume mounts | `[]` |\n| `extraInitContainers` | Additional init containers | `[]` |\n| `extraContainers` | Additional sidecar containers | `[]` |\n\n## Configuration Examples\n\n### Custom Resource Limits\n\n```yaml\ncontroller:\n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n    requests:\n      cpu: 100m\n      memory: 128Mi\n```\n\n### Custom Kubernetes Client Rate Limiter\n\nConfigure the QPS and Burst for the Kubernetes client to handle high-throughput scenarios:\n\n```yaml\ncontroller:\n  kubeClient:\n    qps: 100\n    burst: 250\n```\n\n> Note: Default values are QPS=100, Burst=200.\n\n### Use Private Registry\n\n```yaml\ncontroller:\n  image:\n    repository: myregistry.example.com/opensandbox-controller\n    tag: v0.1.0\n\nimagePullSecrets:\n  - name: myregistrykey\n```\n\n### Node Affinity\n\n```yaml\ncontroller:\n  affinity:\n    nodeAffinity:\n      requiredDuringSchedulingIgnoredDuringExecution:\n        nodeSelectorTerms:\n        - matchExpressions:\n          - key: node-role.kubernetes.io/control-plane\n            operator: Exists\n```\n\n## Usage Examples\n\nAfter installation, you can create resources:\n\n### Create a Resource Pool\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: example-pool\nspec:\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: nginx:latest\n        ports:\n        - containerPort: 80\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\n### Create a Batch Sandbox\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: example-batch-sandbox\nspec:\n  replicas: 3\n  poolRef: example-pool\n```\n\n## Upgrading\n\nTo upgrade the chart:\n\n```bash\nhelm upgrade opensandbox-controller ./opensandbox-controller \\\n  --namespace opensandbox-system \\\n  -f custom-values.yaml\n```\n\n## Troubleshooting\n\n### Check controller logs\n\n```bash\nkubectl logs -n opensandbox-system -l control-plane=controller-manager -f\n```\n\n### Check CRD installation\n\n```bash\nkubectl get crd | grep opensandbox\n```\n\n### Verify RBAC permissions\n\n```bash\nkubectl auth can-i --as=system:serviceaccount:opensandbox-system:opensandbox-controller-controller-manager create pods\n```\n\n## Additional Resources\n\n- [OpenSandbox GitHub](https://github.com/alibaba/OpenSandbox)\n- [Documentation](https://github.com/alibaba/OpenSandbox/blob/main/kubernetes/README.md)\n- [Examples](https://github.com/alibaba/OpenSandbox/tree/main/kubernetes/config/samples)\n\n## License\n\nApache 2.0 License\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/NOTES.txt",
    "content": "Thank you for installing {{ .Chart.Name }}!\n\nYour release is named {{ .Release.Name }}.\n\nTo learn more about the release, try:\n\n  $ helm status {{ .Release.Name }} -n {{ include \"opensandbox.namespace\" . }}\n  $ helm get all {{ .Release.Name }} -n {{ include \"opensandbox.namespace\" . }}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n🎉 OpenSandbox Controller has been successfully installed!\n\n📋 Verify the installation:\n\n  kubectl --namespace {{ include \"opensandbox.namespace\" . }} get pods -l \"app.kubernetes.io/name={{ include \"opensandbox.name\" . }}\"\n\n📚 Check the installed CRDs:\n\n  kubectl get crd batchsandboxes.sandbox.opensandbox.io\n  kubectl get crd pools.sandbox.opensandbox.io\n\n🚀 Create your first resources:\n\n  # Create a resource pool\n  cat <<EOF | kubectl apply -f -\n  apiVersion: sandbox.opensandbox.io/v1alpha1\n  kind: Pool\n  metadata:\n    name: example-pool\n    namespace: {{ include \"opensandbox.namespace\" . }}\n  spec:\n    template:\n      spec:\n        containers:\n        - name: sandbox-container\n          image: nginx:latest\n          ports:\n          - containerPort: 80\n    capacitySpec:\n      bufferMax: 10\n      bufferMin: 2\n      poolMax: 20\n      poolMin: 5\n  EOF\n\n  # Create a batch sandbox\n  cat <<EOF | kubectl apply -f -\n  apiVersion: sandbox.opensandbox.io/v1alpha1\n  kind: BatchSandbox\n  metadata:\n    name: example-batch-sandbox\n    namespace: {{ include \"opensandbox.namespace\" . }}\n  spec:\n    replicas: 3\n    poolRef: example-pool\n  EOF\n\n📊 Monitor resources:\n\n  # View pool status\n  kubectl get pools -n {{ include \"opensandbox.namespace\" . }}\n\n  # View batch sandbox status\n  kubectl get batchsandboxes -n {{ include \"opensandbox.namespace\" . }}\n\n  # Get detailed information\n  kubectl describe pool example-pool -n {{ include \"opensandbox.namespace\" . }}\n  kubectl describe batchsandbox example-batch-sandbox -n {{ include \"opensandbox.namespace\" . }}\n\n📖 Documentation:\n\n  GitHub: https://github.com/alibaba/OpenSandbox\n  Docs:   https://github.com/alibaba/OpenSandbox/blob/main/kubernetes/README.md\n\n💡 Examples:\n\n  Check out example configurations in the repository:\n  https://github.com/alibaba/OpenSandbox/tree/main/kubernetes/config/samples\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n⚠️  Note: This is an operator that manages sandbox resources. The controller\n    itself doesn't run sandboxes - it manages Pool and BatchSandbox resources.\n\n{{- if not .Values.rbac.create }}\n\n⚠️  WARNING: RBAC is disabled. Make sure the ServiceAccount has proper permissions.\n\n{{- end }}\n\n{{- if not .Values.crds.install }}\n\n⚠️  WARNING: CRD installation is disabled. Make sure CRDs are installed manually.\n\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"opensandbox.name\" -}}\n{{- default \"opensandbox\" .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"opensandbox.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"opensandbox.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"opensandbox.labels\" -}}\nhelm.sh/chart: {{ include \"opensandbox.chart\" . }}\n{{ include \"opensandbox.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"opensandbox.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"opensandbox.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\ncontrol-plane: controller-manager\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"opensandbox.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default \"opensandbox-controller-manager\" .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n\n{{/*\nGet the namespace to use\n*/}}\n{{- define \"opensandbox.namespace\" -}}\n{{- if .Values.namespaceOverride }}\n{{- .Values.namespaceOverride }}\n{{- else }}\n{{- print \"opensandbox-system\" }}\n{{- end }}\n{{- end }}\n\n{{/*\nController image with automatic version prefix handling.\nPrepends 'v' to semantic version tags (e.g., 0.0.1 -> v0.0.1) but preserves\nspecial tags like 'latest', 'dev', 'main', etc. as-is.\n*/}}\n{{- define \"opensandbox.controllerImage\" -}}\n{{- $tag := .Values.controller.image.tag | default .Chart.AppVersion }}\n{{- $finalTag := $tag }}\n{{- if and (not (hasPrefix \"v\" $tag)) (regexMatch \"^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\" $tag) }}\n{{- $finalTag = printf \"v%s\" $tag }}\n{{- end }}\n{{- printf \"%s:%s\" .Values.controller.image.repository $finalTag }}\n{{- end }}\n\n{{/*\nCreate the name for the leader election role\n*/}}\n{{- define \"opensandbox.leaderElectionRoleName\" -}}\n{{- print \"opensandbox-leader-election-role\" }}\n{{- end }}\n\n{{/*\nCreate the name for the manager role\n*/}}\n{{- define \"opensandbox.managerRoleName\" -}}\n{{- print \"opensandbox-manager-role\" }}\n{{- end }}\n\n{{/*\nReturn the appropriate apiVersion for RBAC APIs\n*/}}\n{{- define \"opensandbox.rbac.apiVersion\" -}}\n{{- if .Capabilities.APIVersions.Has \"rbac.authorization.k8s.io/v1\" }}\n{{- print \"rbac.authorization.k8s.io/v1\" }}\n{{- else }}\n{{- print \"rbac.authorization.k8s.io/v1beta1\" }}\n{{- end }}\n{{- end }}\n\n{{/*\nReturn image pull policy\n*/}}\n{{- define \"opensandbox.imagePullPolicy\" -}}\n{{- .Values.controller.image.pullPolicy | default \"IfNotPresent\" }}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/clusterrole.yaml",
    "content": "{{- if .Values.rbac.create -}}\n---\n# Leader election role\napiVersion: {{ include \"opensandbox.rbac.apiVersion\" . }}\nkind: Role\nmetadata:\n  name: {{ include \"opensandbox.leaderElectionRoleName\" . }}\n  namespace: {{ include \"opensandbox.namespace\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: rbac\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - configmaps\n  verbs:\n  - get\n  - list\n  - watch\n  - create\n  - update\n  - patch\n  - delete\n- apiGroups:\n  - coordination.k8s.io\n  resources:\n  - leases\n  verbs:\n  - get\n  - list\n  - watch\n  - create\n  - update\n  - patch\n  - delete\n- apiGroups:\n  - \"\"\n  resources:\n  - events\n  verbs:\n  - create\n  - patch\n\n---\n# Manager ClusterRole\napiVersion: {{ include \"opensandbox.rbac.apiVersion\" . }}\nkind: ClusterRole\nmetadata:\n  name: {{ include \"opensandbox.managerRoleName\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: rbac\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - events\n  - pods\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - \"\"\n  resources:\n  - pods/status\n  verbs:\n  - get\n  - patch\n  - update\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes\n  - pools\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/finalizers\n  - pools/finalizers\n  verbs:\n  - update\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/status\n  - pools/status\n  verbs:\n  - get\n  - patch\n  - update\n\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/clusterrolebinding.yaml",
    "content": "{{- if .Values.rbac.create -}}\n---\n# Leader election role binding\napiVersion: {{ include \"opensandbox.rbac.apiVersion\" . }}\nkind: RoleBinding\nmetadata:\n  name: {{ include \"opensandbox.leaderElectionRoleName\" . }}\n  namespace: {{ include \"opensandbox.namespace\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: rbac\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: {{ include \"opensandbox.leaderElectionRoleName\" . }}\nsubjects:\n- kind: ServiceAccount\n  name: {{ include \"opensandbox.serviceAccountName\" . }}\n  namespace: {{ include \"opensandbox.namespace\" . }}\n\n---\n# Manager role binding\napiVersion: {{ include \"opensandbox.rbac.apiVersion\" . }}\nkind: ClusterRoleBinding\nmetadata:\n  name: {{ include \"opensandbox.managerRoleName\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: rbac\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: {{ include \"opensandbox.managerRoleName\" . }}\nsubjects:\n- kind: ServiceAccount\n  name: {{ include \"opensandbox.serviceAccountName\" . }}\n  namespace: {{ include \"opensandbox.namespace\" . }}\n\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/crds/batchsandboxes.yaml",
    "content": "{{- if .Values.crds.install -}}\n---\napiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  annotations:\n    controller-gen.kubebuilder.io/version: v0.18.0\n    {{- if .Values.crds.keep }}\n    helm.sh/resource-policy: keep\n    {{- end }}\n    {{- with .Values.crds.annotations }}\n    {{- toYaml . | nindent 4 }}\n    {{- end }}\n  name: batchsandboxes.sandbox.opensandbox.io\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\nspec:\n  group: sandbox.opensandbox.io\n  names:\n    kind: BatchSandbox\n    listKind: BatchSandboxList\n    plural: batchsandboxes\n    shortNames:\n    - bsbx\n    singular: batchsandbox\n  scope: Namespaced\n  versions:\n  - additionalPrinterColumns:\n    - description: The desired number of pods.\n      jsonPath: .spec.replicas\n      name: DESIRED\n      type: integer\n    - description: The number of currently all pods.\n      jsonPath: .status.replicas\n      name: TOTAL\n      type: integer\n    - description: The number of currently all allocated pods.\n      jsonPath: .status.allocated\n      name: ALLOCATED\n      type: integer\n    - description: The number of currently all ready pods.\n      jsonPath: .status.ready\n      name: Ready\n      type: integer\n    - description: The number of currently all running tasks.\n      jsonPath: .status.taskRunning\n      name: TASK_RUNNING\n      priority: 1\n      type: integer\n    - description: The number of currently all succeed tasks.\n      jsonPath: .status.taskSucceed\n      name: TASK_SUCCEED\n      priority: 1\n      type: integer\n    - description: The number of currently all failed tasks.\n      jsonPath: .status.taskFailed\n      name: TASK_FAILED\n      priority: 1\n      type: integer\n    - description: The number of currently all unknown tasks.\n      jsonPath: .status.taskUnknown\n      name: TASK_UNKNOWN\n      priority: 1\n      type: integer\n    - description: sandbox expire time\n      jsonPath: .spec.expireTime\n      name: EXPIRE\n      type: string\n    - description: CreationTimestamp is a timestamp representing the server time when\n        this object was created. It is not guaranteed to be set in happens-before\n        order across separate operations. Clients may not set this value. It is represented\n        in RFC3339 form and is in UTC.\n      jsonPath: .metadata.creationTimestamp\n      name: AGE\n      type: date\n    name: v1alpha1\n    schema:\n      openAPIV3Schema:\n        description: BatchSandbox is the Schema for the batchsandboxes API.\n        properties:\n          apiVersion:\n            description: |-\n              APIVersion defines the versioned schema of this representation of an object.\n              Servers should convert recognized schemas to the latest internal value, and\n              may reject unrecognized values.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n            type: string\n          kind:\n            description: |-\n              Kind is a string value representing the REST resource this object represents.\n              Servers may infer this from the endpoint the client submits requests to.\n              Cannot be updated.\n              In CamelCase.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n            type: string\n          metadata:\n            type: object\n          spec:\n            description: BatchSandboxSpec defines the desired state of BatchSandbox.\n            properties:\n              expireTime:\n                description: |-\n                  ExpireTime - Absolute time when the batch-sandbox is deleted.\n                  If a time in the past is provided, the batch-sandbox will be deleted immediately.\n                format: date-time\n                type: string\n              poolRef:\n                description: |-\n                  PoolRef references the Pool resource name for pooled sandbox creation.\n                  Mutually exclusive with Template - use PoolRef for pool-based allocation or Template for direct sandbox creation.\n                type: string\n              replicas:\n                default: 1\n                description: Replicas is the number of desired replicas.\n                format: int32\n                minimum: 0\n                type: integer\n              shardPatches:\n                description: ShardPatches indicates patching to the Template for BatchSandbox.\n                x-kubernetes-preserve-unknown-fields: true\n              shardTaskPatches:\n                description: ShardTaskPatches indicates patching to the TaskTemplate\n                  for individual Task.\n                x-kubernetes-preserve-unknown-fields: true\n              taskResourcePolicyWhenCompleted:\n                default: Retain\n                description: |-\n                  TaskResourcePolicyWhenCompleted specifies how resources should be handled once a task reaches a completed state (SUCCEEDED or FAILED).\n                  - Retain: Keep the resources until the BatchSandbox is deleted.\n                  - Release: Free the resources immediately when the task completes.\n                type: string\n              taskTemplate:\n                description: |-\n                  Task is a custom task spec that is automatically dispatched after the sandbox is successfully created.\n                  The Sandbox is responsible for managing the lifecycle of the task.\n                x-kubernetes-preserve-unknown-fields: true\n              template:\n                description: Template describes the pods that will be created.\n                x-kubernetes-preserve-unknown-fields: true\n            required:\n            - replicas\n            type: object\n          status:\n            description: BatchSandboxStatus defines the observed state of BatchSandbox.\n            properties:\n              allocated:\n                description: \"\\tAllocated is the number of actual scheduled Pod\"\n                format: int32\n                type: integer\n              observedGeneration:\n                description: |-\n                  ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n                  BatchSandbox's generation, which is updated on mutation by the API Server.\n                format: int64\n                type: integer\n              ready:\n                description: \"\\tReady is the number of actual Ready Pod\"\n                format: int32\n                type: integer\n              replicas:\n                description: Replicas is the number of actual Pods\n                format: int32\n                type: integer\n              taskFailed:\n                description: TaskFailed is the number of Failed task\n                format: int32\n                type: integer\n              taskPending:\n                description: TaskPending is the number of Pending task which is unassigned\n                format: int32\n                type: integer\n              taskRunning:\n                description: TaskRunning is the number of Running task\n                format: int32\n                type: integer\n              taskSucceed:\n                description: TaskSucceed is the number of Succeed task\n                format: int32\n                type: integer\n              taskUnknown:\n                description: TaskUnknown is the number of Unknown task\n                format: int32\n                type: integer\n            required:\n            - allocated\n            - ready\n            - replicas\n            - taskFailed\n            - taskPending\n            - taskRunning\n            - taskSucceed\n            - taskUnknown\n            type: object\n        type: object\n    served: true\n    storage: true\n    subresources:\n      status: {}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/crds/pools.yaml",
    "content": "{{- if .Values.crds.install -}}\n---\napiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  annotations:\n    controller-gen.kubebuilder.io/version: v0.18.0\n    {{- if .Values.crds.keep }}\n    helm.sh/resource-policy: keep\n    {{- end }}\n    {{- with .Values.crds.annotations }}\n    {{- toYaml . | nindent 4 }}\n    {{- end }}\n  name: pools.sandbox.opensandbox.io\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\nspec:\n  group: sandbox.opensandbox.io\n  names:\n    kind: Pool\n    listKind: PoolList\n    plural: pools\n    singular: pool\n  scope: Namespaced\n  versions:\n  - additionalPrinterColumns:\n    - description: The number of all nodes in pool.\n      jsonPath: .status.total\n      name: TOTAL\n      type: integer\n    - description: The number of allocated nodes in pool.\n      jsonPath: .status.allocated\n      name: ALLOCATED\n      type: integer\n    - description: The number of available nodes in pool.\n      jsonPath: .status.available\n      name: AVAILABLE\n      type: integer\n    name: v1alpha1\n    schema:\n      openAPIV3Schema:\n        description: Pool is the Schema for the pools API.\n        properties:\n          apiVersion:\n            description: |-\n              APIVersion defines the versioned schema of this representation of an object.\n              Servers should convert recognized schemas to the latest internal value, and\n              may reject unrecognized values.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n            type: string\n          kind:\n            description: |-\n              Kind is a string value representing the REST resource this object represents.\n              Servers may infer this from the endpoint the client submits requests to.\n              Cannot be updated.\n              In CamelCase.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n            type: string\n          metadata:\n            type: object\n          spec:\n            description: PoolSpec defines the desired state of Pool.\n            properties:\n              capacitySpec:\n                description: CapacitySpec controls the size of the resource pool.\n                properties:\n                  bufferMax:\n                    description: BufferMax is the maximum number of nodes kept in\n                      the warm buffer.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  bufferMin:\n                    description: BufferMin is the minimum number of nodes that must\n                      remain in the buffer.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  poolMax:\n                    description: PoolMax is the maximum total number of nodes allowed\n                      in the entire pool.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  poolMin:\n                    description: PoolMin is the minimum total size of the pool.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                required:\n                - bufferMax\n                - bufferMin\n                - poolMax\n                - poolMin\n                type: object\n              template:\n                description: Pod Template used to create pre-warmed nodes in the pool.\n                x-kubernetes-preserve-unknown-fields: true\n            required:\n            - capacitySpec\n            type: object\n          status:\n            description: PoolStatus defines the observed state of Pool.\n            properties:\n              allocated:\n                description: Allocated is the number of nodes currently allocated\n                  to sandboxes.\n                format: int32\n                type: integer\n              available:\n                description: Available is the number of nodes currently available\n                  in the pool.\n                format: int32\n                type: integer\n              observedGeneration:\n                description: |-\n                  ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n                  BatchSandbox's generation, which is updated on mutation by the API Server.\n                format: int64\n                type: integer\n              revision:\n                description: Revision is the latest version of pool\n                type: string\n              total:\n                description: Total is the total number of nodes in the pool.\n                format: int32\n                type: integer\n            required:\n            - allocated\n            - available\n            - revision\n            - total\n            type: object\n        type: object\n    served: true\n    storage: true\n    subresources:\n      status: {}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: opensandbox-controller-manager\n  namespace: {{ include \"opensandbox.namespace\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: controller-manager\nspec:\n  replicas: {{ .Values.controller.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"opensandbox.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      annotations:\n        kubectl.kubernetes.io/default-container: manager\n        {{- with .Values.controller.podAnnotations }}\n        {{- toYaml . | nindent 8 }}\n        {{- end }}\n      labels:\n        {{- include \"opensandbox.selectorLabels\" . | nindent 8 }}\n        {{- with .Values.controller.podLabels }}\n        {{- toYaml . | nindent 8 }}\n        {{- end }}\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      serviceAccountName: {{ include \"opensandbox.serviceAccountName\" . }}\n      {{- with .Values.controller.podSecurityContext }}\n      securityContext:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.controller.priorityClassName }}\n      priorityClassName: {{ . }}\n      {{- end }}\n      {{- with .Values.extraInitContainers }}\n      initContainers:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      containers:\n      - name: manager\n        image: {{ include \"opensandbox.controllerImage\" . }}\n        imagePullPolicy: {{ include \"opensandbox.imagePullPolicy\" . }}\n        command:\n        - /workspace/server\n        args:\n        {{- if .Values.controller.leaderElection.enabled }}\n        - --leader-elect\n        {{- end }}\n        - --health-probe-bind-address=:8081\n        - --zap-log-level={{ .Values.controller.logLevel }}\n        {{- if and .Values.controller.kubeClient (gt .Values.controller.kubeClient.qps 0) }}\n        - --kube-client-qps={{ .Values.controller.kubeClient.qps }}\n        {{- end }}\n        {{- if and .Values.controller.kubeClient (gt .Values.controller.kubeClient.burst 0) }}\n        - --kube-client-burst={{ .Values.controller.kubeClient.burst }}\n        {{- end }}\n        ports:\n        - name: health\n          containerPort: 8081\n          protocol: TCP\n        {{- with .Values.controller.containerSecurityContext }}\n        securityContext:\n          {{- toYaml . | nindent 10 }}\n        {{- end }}\n        {{- if .Values.controller.livenessProbe.enabled }}\n        livenessProbe:\n          httpGet:\n            path: {{ .Values.controller.livenessProbe.httpGet.path }}\n            port: {{ .Values.controller.livenessProbe.httpGet.port }}\n          initialDelaySeconds: {{ .Values.controller.livenessProbe.initialDelaySeconds }}\n          periodSeconds: {{ .Values.controller.livenessProbe.periodSeconds }}\n          timeoutSeconds: {{ .Values.controller.livenessProbe.timeoutSeconds }}\n          successThreshold: {{ .Values.controller.livenessProbe.successThreshold }}\n          failureThreshold: {{ .Values.controller.livenessProbe.failureThreshold }}\n        {{- end }}\n        {{- if .Values.controller.readinessProbe.enabled }}\n        readinessProbe:\n          httpGet:\n            path: {{ .Values.controller.readinessProbe.httpGet.path }}\n            port: {{ .Values.controller.readinessProbe.httpGet.port }}\n          initialDelaySeconds: {{ .Values.controller.readinessProbe.initialDelaySeconds }}\n          periodSeconds: {{ .Values.controller.readinessProbe.periodSeconds }}\n          timeoutSeconds: {{ .Values.controller.readinessProbe.timeoutSeconds }}\n          successThreshold: {{ .Values.controller.readinessProbe.successThreshold }}\n          failureThreshold: {{ .Values.controller.readinessProbe.failureThreshold }}\n        {{- end }}\n        resources:\n          {{- toYaml .Values.controller.resources | nindent 10 }}\n        {{- if .Values.extraEnv }}\n        env:\n        {{- toYaml .Values.extraEnv | nindent 8 }}\n        {{- end }}\n        volumeMounts:\n        {{- with .Values.extraVolumeMounts }}\n        {{- toYaml . | nindent 8 }}\n        {{- end }}\n      {{- with .Values.extraContainers }}\n      {{- toYaml . | nindent 6 }}\n      {{- end }}\n      volumes:\n      {{- with .Values.extraVolumes }}\n      {{- toYaml . | nindent 6 }}\n      {{- end }}\n      {{- with .Values.controller.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.controller.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.controller.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      terminationGracePeriodSeconds: 10\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/templates/serviceaccount.yaml",
    "content": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"opensandbox.serviceAccountName\" . }}\n  namespace: {{ include \"opensandbox.namespace\" . }}\n  labels:\n    {{- include \"opensandbox.labels\" . | nindent 4 }}\n    app.kubernetes.io/component: serviceaccount\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- if .Values.imagePullSecrets }}\nimagePullSecrets:\n  {{- toYaml .Values.imagePullSecrets | nindent 2 }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-controller/values.yaml",
    "content": "# Default values for opensandbox-controller.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\n# -- Override the name of the chart\nnameOverride: \"\"\n# -- Override the full name of the chart\nfullnameOverride: \"\"\n\n# -- Override the namespace where resources will be created\n# If not set, defaults to \"opensandbox-system\"\nnamespaceOverride: \"\"\n\n# Controller configuration\ncontroller:\n  # -- Controller image configuration\n  image:\n    # -- Controller image repository\n    repository: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/controller\n    # -- Image pull policy\n    pullPolicy: IfNotPresent\n    # -- Overrides the image tag whose default is the chart appVersion\n    tag: \"\"\n  \n  # -- Number of controller replicas\n  replicaCount: 1\n  \n  # -- Resource requests and limits for the controller\n  resources:\n    limits:\n      cpu: 500m\n      memory: 128Mi\n    requests:\n      cpu: 10m\n      memory: 64Mi\n  \n  # -- Log level for zap logger (debug, info, error)\n  logLevel: info\n\n  # -- Kubernetes client rate limiter configuration\n  kubeClient:\n    # -- QPS for Kubernetes client rate limiter.\n    qps: 100\n    # -- Burst for Kubernetes client rate limiter.\n    burst: 200\n\n  # -- Enable leader election for controller manager\n  leaderElection:\n    enabled: true\n  \n  # -- Liveness probe configuration\n  livenessProbe:\n    enabled: true\n    httpGet:\n      path: /healthz\n      port: 8081\n    initialDelaySeconds: 15\n    periodSeconds: 20\n    timeoutSeconds: 1\n    successThreshold: 1\n    failureThreshold: 3\n  \n  # -- Readiness probe configuration\n  readinessProbe:\n    enabled: true\n    httpGet:\n      path: /readyz\n      port: 8081\n    initialDelaySeconds: 5\n    periodSeconds: 10\n    timeoutSeconds: 1\n    successThreshold: 1\n    failureThreshold: 3\n  \n  # -- Node labels for controller pod assignment\n  nodeSelector: {}\n  \n  # -- Tolerations for controller pod assignment\n  tolerations: []\n  \n  # -- Affinity for controller pod assignment\n  affinity: {}\n  \n  # -- Pod security context\n  podSecurityContext:\n    runAsNonRoot: true\n    seccompProfile:\n      type: RuntimeDefault\n  \n  # -- Container security context\n  containerSecurityContext:\n    allowPrivilegeEscalation: false\n    capabilities:\n      drop:\n        - \"ALL\"\n    readOnlyRootFilesystem: false\n  \n  # -- Additional labels for controller pods\n  podLabels: {}\n  \n  # -- Additional annotations for controller pods\n  podAnnotations: {}\n  \n  # -- Priority class name for controller pods\n  priorityClassName: \"\"\n\n# -- Image pull secrets for private registries\nimagePullSecrets: []\n# - name: myregistrykey\n\n# ServiceAccount configuration\nserviceAccount:\n  # -- Specifies whether a service account should be created\n  create: true\n  # -- Annotations to add to the service account\n  annotations: {}\n  # -- The name of the service account to use.\n  # If not set and create is true, a name is generated using the fullname template\n  name: \"\"\n\n# RBAC configuration\nrbac:\n  # -- Specifies whether RBAC resources should be created\n  create: true\n\n# CRD configuration\ncrds:\n  # -- Specifies whether CRDs should be installed\n  install: true\n  # -- Keep CRDs on chart uninstall (adds helm.sh/resource-policy: keep annotation)\n  keep: true\n  # -- Additional annotations to add to CRDs (will be merged with resource-policy if keep is true)\n  annotations: {}\n\n# Network Policy configuration\nnetworkPolicy:\n  # -- Enable network policy\n  enabled: false\n  # -- Ingress rules for network policy\n  ingress: []\n  # -- Egress rules for network policy\n  egress: []\n\n# -- Additional environment variables for the controller\nextraEnv: []\n# - name: CUSTOM_VAR\n#   value: \"custom-value\"\n\n# -- Additional volumes for the controller\nextraVolumes: []\n# - name: custom-volume\n#   emptyDir: {}\n\n# -- Additional volume mounts for the controller\nextraVolumeMounts: []\n# - name: custom-volume\n#   mountPath: /custom-path\n\n# -- Additional init containers\nextraInitContainers: []\n\n# -- Additional sidecar containers\nextraContainers: []\n\n# Example values for different environments\n# You can create separate values files for different environments:\n# - values-dev.yaml\n# - values-staging.yaml\n# - values-prod.yaml\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/.helmignore",
    "content": "# Patterns to ignore when building packages.\n.DS_Store\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n.project\n.idea/\n*.tmproj\n.vscode/\nOWNERS\nMakefile\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/Chart.yaml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\napiVersion: v2\nname: opensandbox-server\ndescription: OpenSandbox Lifecycle API server for sandbox creation and management\ntype: application\nversion: 0.1.0\nappVersion: \"0.1.0\"\n\nkeywords:\n  - sandbox\n  - kubernetes\n  - api\n  - lifecycle\n  - batchsandbox\n  - ingress\n  - gateway\n\nhome: https://github.com/alibaba/OpenSandbox\nsources:\n  - https://github.com/alibaba/OpenSandbox/tree/main/server\n  - https://github.com/alibaba/OpenSandbox/tree/main/components/ingress\n\nmaintainers:\n  - name: OpenSandbox Team\n    email: opensandbox@example.com\n\n# Kubernetes version constraints\nkubeVersion: \">=1.21.1-0\"\n\nannotations:\n  artifacthub.io/category: integration-delivery\n  artifacthub.io/license: Apache-2.0\n  artifacthub.io/prerelease: \"false\"\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/README.md",
    "content": "# opensandbox-server Helm Chart\n\nOpenSandbox Lifecycle API server: provides sandbox create/delete and other lifecycle APIs, typically used with BatchSandbox/Pool on Kubernetes.\n\n## Prerequisites\n\n- Kubernetes 1.21.1+\n- Helm 3.0+\n- OpenSandbox CRDs installed (deploy opensandbox-controller first)\n\n## Install\n\n```bash\n# Server only (default namespace opensandbox-system)\nhelm install opensandbox-server ./kubernetes/charts/opensandbox-server \\\n  --namespace opensandbox-system \\\n  --create-namespace\n\n# With custom image and config\nhelm install opensandbox-server ./kubernetes/charts/opensandbox-server \\\n  --set server.image.repository=your-registry/opensandbox/server \\\n  --set server.image.tag=v0.1.0 \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n### Deploy server and ingress-gateway together\n\nTo run both the Lifecycle API server and the ingress gateway (components/ingress) in one release, set `server.gateway.enabled=true`. The chart will deploy the server and the gateway (Deployment, Service, RBAC), and write server config `[ingress] mode = \"gateway\"` so the server returns the correct gateway address to clients.\n\n```bash\nhelm install opensandbox-server ./kubernetes/charts/opensandbox-server \\\n  --namespace opensandbox-system \\\n  --create-namespace \\\n  --set server.gateway.enabled=true \\\n  --set server.gateway.host=gateway.example.com\n```\n\nOptional: override gateway image, replicas, or resources (see `server.gateway.*` in Configuration).\n\n## Configuration\n\n| Parameter | Description | Default |\n|-----------|-------------|---------|\n| `server.image.repository` | Server image repository | `sandbox-registry.../opensandbox/server` |\n| `server.image.tag` | Server image tag | Chart `appVersion` |\n| `server.replicaCount` | Server replicas | `2` |\n| `server.resources` | CPU/memory requests and limits | See values.yaml |\n| `namespaceOverride` | Deployment namespace | `opensandbox-system` |\n| `configToml` | config.toml content ([ingress] block generated from server.gateway) | See values.yaml |\n| `server.gateway.enabled` | When true: set server config to gateway and deploy components/ingress gateway | `false` |\n| `server.gateway.host` | config `gateway.address` (address returned to clients) | `opensandbox.example.com` |\n| `server.gateway.gatewayRouteMode` | server config and gateway route mode (header/uri) | `header` |\n| `server.gateway.*` | Gateway image, replicas, port, dataplaneNamespace, providerType, resources | See values.yaml |\n\n**Gateway**: When `server.gateway.enabled=true`, the chart writes `[ingress] mode = \"gateway\"` in config.toml and deploys **components/ingress** Deployment/Service/RBAC; gateway `--mode` matches config. External access must be configured separately.\n\nSet `[kubernetes].namespace` in config for the sandbox workload namespace. Override `api_key` via Secret or values in production.\n\n## Upgrade and uninstall\n\n```bash\nhelm upgrade opensandbox-server ./kubernetes/charts/opensandbox-server -n opensandbox-system\nhelm uninstall opensandbox-server -n opensandbox-system\n```\n\n## References\n\n- [OpenSandbox](https://github.com/alibaba/OpenSandbox)\n- [Helm deployment docs](../../docs/HELM-DEPLOYMENT.md)\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/templates/NOTES.txt",
    "content": "Thank you for installing {{ .Chart.Name }}!\n\nYour release is named {{ .Release.Name }}.\n\nTo learn more about the release, try:\n\n  $ helm status {{ .Release.Name }} -n {{ include \"opensandbox-server.namespace\" . }}\n  $ helm get all {{ .Release.Name }} -n {{ include \"opensandbox-server.namespace\" . }}\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nOpenSandbox Lifecycle API server has been installed.\n\nVerify the installation:\n\n  kubectl --namespace {{ include \"opensandbox-server.namespace\" . }} get pods -l \"app.kubernetes.io/name={{ include \"opensandbox-server.name\" . }}\"\n  kubectl --namespace {{ include \"opensandbox-server.namespace\" . }} get svc -l \"app.kubernetes.io/name={{ include \"opensandbox-server.name\" . }}\"\n\nThe server exposes the Lifecycle API (create/delete sandboxes, etc.).\n{{- if .Values.server.gateway.enabled }}\n\nServer config [ingress]: mode=gateway, gateway.address={{ .Values.server.gateway.host }}, gateway.route.mode={{ .Values.server.gateway.gatewayRouteMode }}. Ingress gateway (components/ingress) is deployed in this release.\n{{- else }}\n\nPort-forward to access locally:\n\n  kubectl port-forward -n {{ include \"opensandbox-server.namespace\" . }} svc/{{ include \"opensandbox-server.fullname\" . }} 8080:80\n\nThen use the API at http://localhost:8080 (set api_key in config if required).\n{{- end }}\n\nDocumentation: https://github.com/alibaba/OpenSandbox\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"opensandbox-server.name\" -}}\n{{- default \"opensandbox-server\" .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\n*/}}\n{{- define \"opensandbox-server.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nChart name and version for labels.\n*/}}\n{{- define \"opensandbox-server.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"opensandbox-server.labels\" -}}\nhelm.sh/chart: {{ include \"opensandbox-server.chart\" . }}\n{{ include \"opensandbox-server.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\napp.kubernetes.io/component: opensandbox-server\napp.kubernetes.io/part-of: opensandbox\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"opensandbox-server.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"opensandbox-server.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nNamespace to use\n*/}}\n{{- define \"opensandbox-server.namespace\" -}}\n{{- if .Values.namespaceOverride }}\n{{- .Values.namespaceOverride }}\n{{- else }}\n{{- print \"opensandbox-system\" }}\n{{- end }}\n{{- end }}\n\n{{/*\nServiceAccount name (same as fullname, always created by chart)\n*/}}\n{{- define \"opensandbox-server.serviceAccountName\" -}}\n{{- include \"opensandbox-server.fullname\" . }}\n{{- end }}\n\n{{/*\nServer image with tag (prepend v to semver if missing)\n*/}}\n{{- define \"opensandbox-server.serverImage\" -}}\n{{- $tag := .Values.server.image.tag | default .Chart.AppVersion }}\n{{- $finalTag := $tag }}\n{{- if and (not (hasPrefix \"v\" $tag)) (regexMatch \"^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\" $tag) }}\n{{- $finalTag = printf \"v%s\" $tag }}\n{{- end }}\n{{- printf \"%s:%s\" .Values.server.image.repository $finalTag }}\n{{- end }}\n\n{{/*\nImage pull policy\n*/}}\n{{- define \"opensandbox-server.imagePullPolicy\" -}}\n{{- .Values.server.image.pullPolicy | default \"IfNotPresent\" }}\n{{- end }}\n\n{{/*\nRBAC apiVersion\n*/}}\n{{- define \"opensandbox-server.rbac.apiVersion\" -}}\n{{- if .Capabilities.APIVersions.Has \"rbac.authorization.k8s.io/v1\" }}\n{{- print \"rbac.authorization.k8s.io/v1\" }}\n{{- else }}\n{{- print \"rbac.authorization.k8s.io/v1beta1\" }}\n{{- end }}\n{{- end }}\n\n{{/*\nClusterRole name for server\n*/}}\n{{- define \"opensandbox-server.roleName\" -}}\n{{- include \"opensandbox-server.fullname\" . }}-role\n{{- end }}\n\n{{/*\nRender [ingress] TOML block from server.gateway.\nWhen server.gateway.enabled=true: mode=gateway + gateway.address + gateway.route.mode; otherwise mode=direct.\n*/}}\n{{- define \"opensandbox-server.ingressConfigToml\" -}}\n[ingress]\nmode = {{ .Values.server.gateway.enabled | ternary \"gateway\" \"direct\" | quote }}\n{{- if .Values.server.gateway.enabled }}\n\ngateway.address = {{ .Values.server.gateway.host | quote }}\ngateway.route.mode = {{ .Values.server.gateway.gatewayRouteMode | quote }}\n{{- end }}\n\n{{- end }}\n\n{{/*\nGateway fixed name (independent of server)\n*/}}\n{{- define \"opensandbox-server.ingressGatewayFullname\" -}}\nopensandbox-ingress-gateway\n{{- end }}\n\n{{- define \"opensandbox-server.ingressGatewaySelectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{- define \"opensandbox-server.ingressGatewayImage\" -}}\n{{- $tag := .Values.server.gateway.image.tag | default \"v1.0.2\" }}\n{{- printf \"%s:%s\" .Values.server.gateway.image.repository $tag }}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/templates/ingress-gateway.yaml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# Gateway (components/ingress): proxies sandbox traffic, aligned with server config [ingress].\n# Includes ServiceAccount, Role, RoleBinding, Deployment, Service.\n{{- if .Values.server.gateway.enabled }}\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    app.kubernetes.io/name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n    app.kubernetes.io/part-of: opensandbox\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}-reader\n  namespace: {{ .Values.server.gateway.dataplaneNamespace }}\n  labels:\n    app.kubernetes.io/name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n    app.kubernetes.io/part-of: opensandbox\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"pods\", \"pods/status\", \"services\"]\n    verbs: [\"get\", \"list\", \"watch\"]\n  - apiGroups: [\"sandbox.opensandbox.io\"]\n    resources: [\"batchsandboxes\", \"batchsandboxes/status\"]\n    verbs: [\"get\", \"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}-reader\n  namespace: {{ .Values.server.gateway.dataplaneNamespace }}\n  labels:\n    app.kubernetes.io/name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n    app.kubernetes.io/part-of: opensandbox\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}-reader\nsubjects:\n  - kind: ServiceAccount\n    name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n    namespace: {{ include \"opensandbox-server.namespace\" . }}\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    helm.sh/chart: {{ include \"opensandbox-server.chart\" . }}\n    {{- include \"opensandbox-server.ingressGatewaySelectorLabels\" . | nindent 4 }}\n    app.kubernetes.io/component: ingress-gateway\n    app.kubernetes.io/part-of: opensandbox\nspec:\n  replicas: {{ .Values.server.gateway.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"opensandbox-server.ingressGatewaySelectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      labels:\n        {{- include \"opensandbox-server.ingressGatewaySelectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/component: ingress-gateway\n        app.kubernetes.io/part-of: opensandbox\n    spec:\n      serviceAccountName: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n      containers:\n        - name: main\n          image: {{ include \"opensandbox-server.ingressGatewayImage\" . }}\n          imagePullPolicy: IfNotPresent\n          args:\n            - \"--namespace={{ .Values.server.gateway.dataplaneNamespace }}\"\n            - \"--port={{ .Values.server.gateway.port }}\"\n            - \"--provider-type={{ .Values.server.gateway.providerType }}\"\n            - \"--mode={{ .Values.server.gateway.gatewayRouteMode }}\"\n            - \"--log-level={{ .Values.server.gateway.logLevel }}\"\n          ports:\n            - name: http\n              containerPort: {{ .Values.server.gateway.port }}\n              protocol: TCP\n          livenessProbe:\n            httpGet:\n              path: /status.ok\n              port: http\n            initialDelaySeconds: 10\n            periodSeconds: 15\n            timeoutSeconds: 5\n          readinessProbe:\n            httpGet:\n              path: /status.ok\n              port: http\n            initialDelaySeconds: 5\n            periodSeconds: 10\n            timeoutSeconds: 3\n          resources:\n            {{- toYaml .Values.server.gateway.resources | nindent 12 }}\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"opensandbox-server.ingressGatewayFullname\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    helm.sh/chart: {{ include \"opensandbox-server.chart\" . }}\n    {{- include \"opensandbox-server.ingressGatewaySelectorLabels\" . | nindent 4 }}\n    app.kubernetes.io/component: ingress-gateway\n    app.kubernetes.io/part-of: opensandbox\nspec:\n  type: ClusterIP\n  ports:\n    - port: 80\n      targetPort: {{ .Values.server.gateway.port }}\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"opensandbox-server.ingressGatewaySelectorLabels\" . | nindent 4 }}\n{{- end }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/templates/server.yaml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# Server resources: ServiceAccount, ClusterRole, ClusterRoleBinding, ConfigMap, Deployment, Service.\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"opensandbox-server.serviceAccountName\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\n---\napiVersion: {{ include \"opensandbox-server.rbac.apiVersion\" . }}\nkind: ClusterRole\nmetadata:\n  name: {{ include \"opensandbox-server.roleName\" . }}\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"pods\", \"pods/status\", \"events\", \"services\", \"configmaps\"]\n    verbs: [\"create\", \"delete\", \"get\", \"list\", \"patch\", \"update\", \"watch\"]\n  - apiGroups: [\"\"]\n    resources: [\"secrets\"]\n    verbs: [\"create\", \"delete\", \"get\"]\n  - apiGroups: [\"node.k8s.io\"]\n    resources: [\"runtimeclasses\"]\n    verbs: [\"get\", \"list\"]\n  - apiGroups: [\"sandbox.opensandbox.io\"]\n    resources: [\"batchsandboxes\", \"batchsandboxes/status\", \"batchsandboxes/finalizers\"]\n    verbs: [\"create\", \"delete\", \"get\", \"list\", \"patch\", \"update\", \"watch\"]\n  - apiGroups: [\"sandbox.opensandbox.io\"]\n    resources: [\"pools\", \"pools/status\", \"pools/finalizers\"]\n    verbs: [\"create\", \"delete\", \"get\", \"list\", \"patch\", \"update\", \"watch\"]\n---\napiVersion: {{ include \"opensandbox-server.rbac.apiVersion\" . }}\nkind: ClusterRoleBinding\nmetadata:\n  name: {{ include \"opensandbox-server.fullname\" . }}-rolebinding\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: {{ include \"opensandbox-server.roleName\" . }}\nsubjects:\n  - kind: ServiceAccount\n    name: {{ include \"opensandbox-server.serviceAccountName\" . }}\n    namespace: {{ include \"opensandbox-server.namespace\" . }}\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"opensandbox-server.fullname\" . }}-config\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\ndata:\n  config.toml: |\n{{ .Values.configToml | indent 4 }}\n{{ include \"opensandbox-server.ingressConfigToml\" . | indent 4 }}\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"opensandbox-server.fullname\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\nspec:\n  replicas: {{ .Values.server.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"opensandbox-server.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      labels:\n        {{- include \"opensandbox-server.selectorLabels\" . | nindent 8 }}\n        app.kubernetes.io/part-of: opensandbox\n    spec:\n      serviceAccountName: {{ include \"opensandbox-server.serviceAccountName\" . }}\n      containers:\n        - name: main\n          image: {{ include \"opensandbox-server.serverImage\" . }}\n          imagePullPolicy: {{ include \"opensandbox-server.imagePullPolicy\" . }}\n          args:\n            - \"--config\"\n            - \"/etc/opensandbox/config.toml\"\n          ports:\n            - name: http\n              containerPort: 80\n              protocol: TCP\n          env:\n            - name: SANDBOX_CONFIG_PATH\n              value: \"/etc/opensandbox/config.toml\"\n          volumeMounts:\n            - name: config\n              mountPath: /etc/opensandbox/config.toml\n              subPath: config.toml\n              readOnly: true\n          livenessProbe:\n            httpGet:\n              path: /health\n              port: http\n            initialDelaySeconds: 10\n            periodSeconds: 15\n            timeoutSeconds: 5\n          readinessProbe:\n            httpGet:\n              path: /health\n              port: http\n            initialDelaySeconds: 5\n            periodSeconds: 10\n            timeoutSeconds: 3\n          resources:\n            {{- toYaml .Values.server.resources | nindent 12 }}\n      volumes:\n        - name: config\n          configMap:\n            name: {{ include \"opensandbox-server.fullname\" . }}-config\n      {{- with .Values.server.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.server.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"opensandbox-server.fullname\" . }}\n  namespace: {{ include \"opensandbox-server.namespace\" . }}\n  labels:\n    {{- include \"opensandbox-server.labels\" . | nindent 4 }}\nspec:\n  type: ClusterIP\n  ports:\n    - port: 80\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"opensandbox-server.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "kubernetes/charts/opensandbox-server/values.yaml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# Default values for opensandbox-server.\n\n# -- Override the name of the chart\nnameOverride: \"\"\n# -- Resource names and app.kubernetes.io/name are fixed to this value, independent of release name\nfullnameOverride: \"opensandbox-server\"\n\n# -- Override the namespace (default: opensandbox-system)\nnamespaceOverride: \"\"\n\n# Server configuration\nserver:\n  # -- Server image configuration\n  image:\n    repository: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/server\n    tag: \"v0.1.7\"\n\n  # -- Number of server replicas\n  replicaCount: 2\n\n  # -- Resource requests and limits\n  resources:\n    limits:\n      cpu: \"2\"\n      memory: 8Gi\n    requests:\n      cpu: \"1\"\n      memory: 4Gi\n\n  tolerations: []\n  affinity: {}\n\n  # Gateway (components/ingress): when enabled, writes config [ingress] and deploys the gateway\n  gateway:\n    enabled: false\n    host: opensandbox.example.com\n    gatewayRouteMode: \"header\"\n    image:\n      repository: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress\n      tag: \"v1.0.4\"\n    replicaCount: 2\n    port: 28888\n    dataplaneNamespace: \"opensandbox\"\n    providerType: \"batchsandbox\"\n    logLevel: \"info\"\n    resources:\n      limits:\n        cpu: \"2\"\n        memory: 8Gi\n      requests:\n        cpu: \"1\"\n        memory: 4Gi\n\n# -- Server config (TOML). Mounted at /etc/opensandbox/config.toml.\nconfigToml: |\n  [server]\n  host = \"0.0.0.0\"\n  port = 80\n  log_level = \"INFO\"\n  api_key = \"\"\n\n  [runtime]\n  type = \"kubernetes\"\n  execd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n  [kubernetes]\n  kubeconfig_path = \"\"\n  namespace = \"opensandbox\"\n  informer_enabled = true\n  informer_resync_seconds = 300\n  informer_watch_timeout_seconds = 60\n  workload_provider = \"batchsandbox\"\n  batchsandbox_template_file = \"/etc/opensandbox/example.batchsandbox-template.yaml\"\n  \n  [egress]\n  image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3\"\n  mode = \"dns+nft\"\n\n"
  },
  {
    "path": "kubernetes/cmd/controller/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"crypto/tls\"\n\t\"flag\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)\n\t// to ensure that exec-entrypoint and run can make use of them.\n\t_ \"k8s.io/client-go/plugin/pkg/client/auth\"\n\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n\tclientgoscheme \"k8s.io/client-go/kubernetes/scheme\"\n\tctrl \"sigs.k8s.io/controller-runtime\"\n\t\"sigs.k8s.io/controller-runtime/pkg/certwatcher\"\n\t\"sigs.k8s.io/controller-runtime/pkg/healthz\"\n\t\"sigs.k8s.io/controller-runtime/pkg/log/zap\"\n\t\"sigs.k8s.io/controller-runtime/pkg/metrics/filters\"\n\tmetricsserver \"sigs.k8s.io/controller-runtime/pkg/metrics/server\"\n\t\"sigs.k8s.io/controller-runtime/pkg/webhook\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/logging\"\n\t// +kubebuilder:scaffold:imports\n)\n\nvar (\n\tscheme   = runtime.NewScheme()\n\tsetupLog = ctrl.Log.WithName(\"setup\")\n)\n\nfunc init() {\n\tutilruntime.Must(clientgoscheme.AddToScheme(scheme))\n\n\tutilruntime.Must(sandboxv1alpha1.AddToScheme(scheme))\n\t// +kubebuilder:scaffold:scheme\n}\n\n// nolint:gocyclo\nfunc main() {\n\tvar metricsAddr string\n\tvar metricsCertPath, metricsCertName, metricsCertKey string\n\tvar webhookCertPath, webhookCertName, webhookCertKey string\n\tvar enableLeaderElection bool\n\tvar probeAddr string\n\tvar secureMetrics bool\n\tvar enableHTTP2 bool\n\tvar tlsOpts []func(*tls.Config)\n\n\t// Log file options\n\tvar enableFileLog bool\n\tvar logFilePath string\n\tvar logMaxSize int\n\tvar logMaxBackups int\n\tvar logMaxAge int\n\tvar logCompress bool\n\n\t// Kubernetes client rate limiter options\n\tvar kubeClientQPS float64\n\tvar kubeClientBurst int\n\n\tflag.StringVar(&metricsAddr, \"metrics-bind-address\", \"0\", \"The address the metrics endpoint binds to. \"+\n\t\t\"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.\")\n\tflag.StringVar(&probeAddr, \"health-probe-bind-address\", \":8081\", \"The address the probe endpoint binds to.\")\n\tflag.BoolVar(&enableLeaderElection, \"leader-elect\", false,\n\t\t\"Enable leader election for controller manager. \"+\n\t\t\t\"Enabling this will ensure there is only one active controller manager.\")\n\tflag.BoolVar(&secureMetrics, \"metrics-secure\", true,\n\t\t\"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.\")\n\tflag.StringVar(&webhookCertPath, \"webhook-cert-path\", \"\", \"The directory that contains the webhook certificate.\")\n\tflag.StringVar(&webhookCertName, \"webhook-cert-name\", \"tls.crt\", \"The name of the webhook certificate file.\")\n\tflag.StringVar(&webhookCertKey, \"webhook-cert-key\", \"tls.key\", \"The name of the webhook key file.\")\n\tflag.StringVar(&metricsCertPath, \"metrics-cert-path\", \"\",\n\t\t\"The directory that contains the metrics server certificate.\")\n\tflag.StringVar(&metricsCertName, \"metrics-cert-name\", \"tls.crt\", \"The name of the metrics server certificate file.\")\n\tflag.StringVar(&metricsCertKey, \"metrics-cert-key\", \"tls.key\", \"The name of the metrics server key file.\")\n\tflag.BoolVar(&enableHTTP2, \"enable-http2\", false,\n\t\t\"If set, HTTP/2 will be enabled for the metrics and webhook servers\")\n\n\t// Log file flags\n\tflag.BoolVar(&enableFileLog, \"enable-file-log\", false, \"Enable log output to file\")\n\tflag.StringVar(&logFilePath, \"log-file-path\", \"/var/log/sandbox-controller/controller.log\", \"Path to the log file\")\n\tflag.IntVar(&logMaxSize, \"log-max-size\", 100, \"Maximum size in megabytes of the log file before it gets rotated\")\n\tflag.IntVar(&logMaxBackups, \"log-max-backups\", 10, \"Maximum number of old log files to retain\")\n\tflag.IntVar(&logMaxAge, \"log-max-age\", 30, \"Maximum number of days to retain old log files\")\n\tflag.BoolVar(&logCompress, \"log-compress\", true, \"Compress determines if the rotated log files should be compressed using gzip\")\n\tflag.Float64Var(&kubeClientQPS, \"kube-client-qps\", 100, \"QPS for Kubernetes client rate limiter.\")\n\tflag.IntVar(&kubeClientBurst, \"kube-client-burst\", 200, \"Burst for Kubernetes client rate limiter.\")\n\n\topts := zap.Options{}\n\topts.BindFlags(flag.CommandLine)\n\n\tflag.Parse()\n\n\t// Setup logger with file rotation support\n\tlogOpts := logging.Options{\n\t\tDevelopment:      opts.Development,\n\t\tEnableFileOutput: enableFileLog,\n\t\tLogFilePath:      logFilePath,\n\t\tMaxSize:          logMaxSize,\n\t\tMaxBackups:       logMaxBackups,\n\t\tMaxAge:           logMaxAge,\n\t\tCompress:         logCompress,\n\t\tZapOptions:       opts,\n\t}\n\n\tlogger := logging.NewLoggerWithZapOptions(logOpts)\n\tctrl.SetLogger(logger)\n\n\t// if the enable-http2 flag is false (the default), http/2 should be disabled\n\t// due to its vulnerabilities. More specifically, disabling http/2 will\n\t// prevent from being vulnerable to the HTTP/2 Stream Cancellation and\n\t// Rapid Reset CVEs. For more information see:\n\t// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3\n\t// - https://github.com/advisories/GHSA-4374-p667-p6c8\n\tdisableHTTP2 := func(c *tls.Config) {\n\t\tsetupLog.Info(\"disabling http/2\")\n\t\tc.NextProtos = []string{\"http/1.1\"}\n\t}\n\n\tif !enableHTTP2 {\n\t\ttlsOpts = append(tlsOpts, disableHTTP2)\n\t}\n\n\t// Create watchers for metrics and webhooks certificates\n\tvar metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher\n\n\t// Initial webhook TLS options\n\twebhookTLSOpts := tlsOpts\n\n\tif len(webhookCertPath) > 0 {\n\t\tsetupLog.Info(\"Initializing webhook certificate watcher using provided certificates\",\n\t\t\t\"webhook-cert-path\", webhookCertPath, \"webhook-cert-name\", webhookCertName, \"webhook-cert-key\", webhookCertKey)\n\n\t\tvar err error\n\t\twebhookCertWatcher, err = certwatcher.New(\n\t\t\tfilepath.Join(webhookCertPath, webhookCertName),\n\t\t\tfilepath.Join(webhookCertPath, webhookCertKey),\n\t\t)\n\t\tif err != nil {\n\t\t\tsetupLog.Error(err, \"Failed to initialize webhook certificate watcher\")\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\twebhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) {\n\t\t\tconfig.GetCertificate = webhookCertWatcher.GetCertificate\n\t\t})\n\t}\n\n\twebhookServer := webhook.NewServer(webhook.Options{\n\t\tTLSOpts: webhookTLSOpts,\n\t})\n\n\t// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.\n\t// More info:\n\t// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/server\n\t// - https://book.kubebuilder.io/reference/metrics.html\n\tmetricsServerOptions := metricsserver.Options{\n\t\tBindAddress:   metricsAddr,\n\t\tSecureServing: secureMetrics,\n\t\tTLSOpts:       tlsOpts,\n\t}\n\n\tif secureMetrics {\n\t\t// FilterProvider is used to protect the metrics endpoint with authn/authz.\n\t\t// These configurations ensure that only authorized users and service accounts\n\t\t// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:\n\t\t// https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/metrics/filters#WithAuthenticationAndAuthorization\n\t\tmetricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization\n\t}\n\n\t// If the certificate is not specified, controller-runtime will automatically\n\t// generate self-signed certificates for the metrics server. While convenient for development and testing,\n\t// this setup is not recommended for production.\n\t//\n\t// TODO(user): If you enable certManager, uncomment the following lines:\n\t// - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates\n\t// managed by cert-manager for the metrics server.\n\t// - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification.\n\tif len(metricsCertPath) > 0 {\n\t\tsetupLog.Info(\"Initializing metrics certificate watcher using provided certificates\",\n\t\t\t\"metrics-cert-path\", metricsCertPath, \"metrics-cert-name\", metricsCertName, \"metrics-cert-key\", metricsCertKey)\n\n\t\tvar err error\n\t\tmetricsCertWatcher, err = certwatcher.New(\n\t\t\tfilepath.Join(metricsCertPath, metricsCertName),\n\t\t\tfilepath.Join(metricsCertPath, metricsCertKey),\n\t\t)\n\t\tif err != nil {\n\t\t\tsetupLog.Error(err, \"to initialize metrics certificate watcher\", \"error\", err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\tmetricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) {\n\t\t\tconfig.GetCertificate = metricsCertWatcher.GetCertificate\n\t\t})\n\t}\n\n\tconfig := ctrl.GetConfigOrDie()\n\t// Set client rate limiter if specified\n\tif kubeClientQPS > 0 {\n\t\tconfig.QPS = float32(kubeClientQPS)\n\t}\n\tif kubeClientBurst > 0 {\n\t\tconfig.Burst = kubeClientBurst\n\t}\n\n\tmgr, err := ctrl.NewManager(config, ctrl.Options{\n\t\tScheme:                 scheme,\n\t\tMetrics:                metricsServerOptions,\n\t\tWebhookServer:          webhookServer,\n\t\tHealthProbeBindAddress: probeAddr,\n\t\tLeaderElection:         enableLeaderElection,\n\t\tLeaderElectionID:       \"2fa1c467.opensandbox.io\",\n\t\t// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily\n\t\t// when the Manager ends. This requires the binary to immediately end when the\n\t\t// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly\n\t\t// speeds up voluntary leader transitions as the new leader don't have to wait\n\t\t// LeaseDuration time first.\n\t\t//\n\t\t// In the default scaffold provided, the program ends immediately after\n\t\t// the manager stops, so would be fine to enable this option. However,\n\t\t// if you are doing or is intended to do any operation such as perform cleanups\n\t\t// after the manager stops then its usage might be unsafe.\n\t\t// LeaderElectionReleaseOnCancel: true,\n\t})\n\tif err != nil {\n\t\tsetupLog.Error(err, \"unable to start manager\")\n\t\tos.Exit(1)\n\t}\n\tsetupLog.Info(\"register field index\")\n\tif err := fieldindex.RegisterFieldIndexes(mgr.GetCache()); err != nil {\n\t\tsetupLog.Error(err, \"failed to register field index\")\n\t\tos.Exit(1)\n\t}\n\tif err := (&controller.BatchSandboxReconciler{\n\t\tClient:   mgr.GetClient(),\n\t\tScheme:   mgr.GetScheme(),\n\t\tRecorder: mgr.GetEventRecorderFor(\"batchsandbox-controller\"),\n\t}).SetupWithManager(mgr); err != nil {\n\t\tsetupLog.Error(err, \"unable to create controller\", \"controller\", \"BatchSandbox\")\n\t\tos.Exit(1)\n\t}\n\tif err := (&controller.PoolReconciler{\n\t\tClient:    mgr.GetClient(),\n\t\tScheme:    mgr.GetScheme(),\n\t\tRecorder:  mgr.GetEventRecorderFor(\"pool-controller\"),\n\t\tAllocator: controller.NewDefaultAllocator(mgr.GetClient()),\n\t}).SetupWithManager(mgr); err != nil {\n\t\tsetupLog.Error(err, \"unable to create controller\", \"controller\", \"Pool\")\n\t\tos.Exit(1)\n\t}\n\t// +kubebuilder:scaffold:builder\n\n\tif metricsCertWatcher != nil {\n\t\tsetupLog.Info(\"Adding metrics certificate watcher to manager\")\n\t\tif err := mgr.Add(metricsCertWatcher); err != nil {\n\t\t\tsetupLog.Error(err, \"unable to add metrics certificate watcher to manager\")\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif webhookCertWatcher != nil {\n\t\tsetupLog.Info(\"Adding webhook certificate watcher to manager\")\n\t\tif err := mgr.Add(webhookCertWatcher); err != nil {\n\t\t\tsetupLog.Error(err, \"unable to add webhook certificate watcher to manager\")\n\t\t\tos.Exit(1)\n\t\t}\n\t}\n\n\tif err := mgr.AddHealthzCheck(\"healthz\", healthz.Ping); err != nil {\n\t\tsetupLog.Error(err, \"unable to set up health check\")\n\t\tos.Exit(1)\n\t}\n\tif err := mgr.AddReadyzCheck(\"readyz\", healthz.Ping); err != nil {\n\t\tsetupLog.Error(err, \"unable to set up ready check\")\n\t\tos.Exit(1)\n\t}\n\n\tsetupLog.Info(\"starting manager\")\n\tif err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {\n\t\tsetupLog.Error(err, \"problem running manager\")\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "kubernetes/cmd/task-executor/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/manager\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/runtime\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/server\"\n\tstore \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/storage\"\n)\n\nfunc main() {\n\t// Load configuration\n\tcfg := config.NewConfig()\n\tcfg.LoadFromEnv()\n\tcfg.LoadFromFlags()\n\tif err := cfg.InitKlog(); err != nil {\n\t\tfmt.Println(\"failed to init klog\")\n\t\tos.Exit(1)\n\t}\n\tklog.InfoS(\"task-executor starting\", \"dataDir\", cfg.DataDir, \"listenAddr\", cfg.ListenAddr, \"sidecarMode\", cfg.EnableSidecarMode)\n\n\t// Initialize TaskStore\n\ttaskStore, err := store.NewFileStore(cfg.DataDir)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to create task store\")\n\t\tos.Exit(1)\n\t}\n\tklog.InfoS(\"task store initialized\", \"dataDir\", cfg.DataDir)\n\n\t// Initialize Executor\n\texec, err := runtime.NewExecutor(cfg)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to create executor\")\n\t\tos.Exit(1)\n\t}\n\n\t// Initialize TaskManager\n\ttaskManager, err := manager.NewTaskManager(cfg, taskStore, exec)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to create task manager\")\n\t\tos.Exit(1)\n\t}\n\n\t// Start TaskManager\n\ttaskManager.Start(context.Background())\n\tklog.InfoS(\"task manager started\")\n\n\t// Initialize HTTP Handler and Router\n\thandler := server.NewHandler(taskManager, cfg)\n\trouter := server.NewRouter(handler)\n\n\t// Create HTTP Server\n\tsvr := &http.Server{\n\t\tAddr:         cfg.ListenAddr,\n\t\tHandler:      router,\n\t\tReadTimeout:  cfg.ReadTimeout,\n\t\tWriteTimeout: cfg.WriteTimeout,\n\t}\n\n\t// Start HTTP server in goroutine\n\tgo func() {\n\t\tklog.InfoS(\"HTTP server listening\", \"address\", cfg.ListenAddr)\n\t\tif err := svr.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\tklog.ErrorS(err, \"HTTP server error\")\n\t\t\tos.Exit(1)\n\t\t}\n\t}()\n\n\t// Wait for interrupt signal\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n\t<-quit\n\n\tklog.InfoS(\"shutting down task-executor gracefully...\")\n\n\t// Shutdown context with timeout\n\tshutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer shutdownCancel()\n\n\t// 1. Stop HTTP server first\n\tif err := svr.Shutdown(shutdownCtx); err != nil {\n\t\tklog.ErrorS(err, \"HTTP server shutdown error\")\n\t} else {\n\t\tklog.InfoS(\"HTTP server stopped\")\n\t}\n\n\t// 2. Stop TaskManager\n\ttaskManager.Stop()\n\tklog.InfoS(\"task manager stopped\")\n\n\tklog.InfoS(\"task-executor stopped successfully\")\n}\n"
  },
  {
    "path": "kubernetes/config/crd/bases/sandbox.opensandbox.io_batchsandboxes.yaml",
    "content": "---\napiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  annotations:\n    controller-gen.kubebuilder.io/version: v0.18.0\n  name: batchsandboxes.sandbox.opensandbox.io\nspec:\n  group: sandbox.opensandbox.io\n  names:\n    kind: BatchSandbox\n    listKind: BatchSandboxList\n    plural: batchsandboxes\n    shortNames:\n    - bsbx\n    singular: batchsandbox\n  scope: Namespaced\n  versions:\n  - additionalPrinterColumns:\n    - description: The desired number of pods.\n      jsonPath: .spec.replicas\n      name: DESIRED\n      type: integer\n    - description: The number of currently all pods.\n      jsonPath: .status.replicas\n      name: TOTAL\n      type: integer\n    - description: The number of currently all allocated pods.\n      jsonPath: .status.allocated\n      name: ALLOCATED\n      type: integer\n    - description: The number of currently all ready pods.\n      jsonPath: .status.ready\n      name: Ready\n      type: integer\n    - description: The number of currently all running tasks.\n      jsonPath: .status.taskRunning\n      name: TASK_RUNNING\n      priority: 1\n      type: integer\n    - description: The number of currently all succeed tasks.\n      jsonPath: .status.taskSucceed\n      name: TASK_SUCCEED\n      priority: 1\n      type: integer\n    - description: The number of currently all failed tasks.\n      jsonPath: .status.taskFailed\n      name: TASK_FAILED\n      priority: 1\n      type: integer\n    - description: The number of currently all unknown tasks.\n      jsonPath: .status.taskUnknown\n      name: TASK_UNKNOWN\n      priority: 1\n      type: integer\n    - description: sandbox expire time\n      jsonPath: .spec.expireTime\n      name: EXPIRE\n      type: string\n    - description: CreationTimestamp is a timestamp representing the server time when\n        this object was created. It is not guaranteed to be set in happens-before\n        order across separate operations. Clients may not set this value. It is represented\n        in RFC3339 form and is in UTC.\n      jsonPath: .metadata.creationTimestamp\n      name: AGE\n      type: date\n    name: v1alpha1\n    schema:\n      openAPIV3Schema:\n        description: BatchSandbox is the Schema for the batchsandboxes API.\n        properties:\n          apiVersion:\n            description: |-\n              APIVersion defines the versioned schema of this representation of an object.\n              Servers should convert recognized schemas to the latest internal value, and\n              may reject unrecognized values.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n            type: string\n          kind:\n            description: |-\n              Kind is a string value representing the REST resource this object represents.\n              Servers may infer this from the endpoint the client submits requests to.\n              Cannot be updated.\n              In CamelCase.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n            type: string\n          metadata:\n            type: object\n          spec:\n            description: BatchSandboxSpec defines the desired state of BatchSandbox.\n            properties:\n              expireTime:\n                description: |-\n                  ExpireTime - Absolute time when the batch-sandbox is deleted.\n                  If a time in the past is provided, the batch-sandbox will be deleted immediately.\n                format: date-time\n                type: string\n              poolRef:\n                description: |-\n                  PoolRef references the Pool resource name for pooled sandbox creation.\n                  Mutually exclusive with Template - use PoolRef for pool-based allocation or Template for direct sandbox creation.\n                type: string\n              replicas:\n                default: 1\n                description: Replicas is the number of desired replicas.\n                format: int32\n                minimum: 0\n                type: integer\n              shardPatches:\n                description: ShardPatches indicates patching to the Template for BatchSandbox.\n                x-kubernetes-preserve-unknown-fields: true\n              shardTaskPatches:\n                description: ShardTaskPatches indicates patching to the TaskTemplate\n                  for individual Task.\n                x-kubernetes-preserve-unknown-fields: true\n              taskResourcePolicyWhenCompleted:\n                default: Retain\n                description: |-\n                  TaskResourcePolicyWhenCompleted specifies how resources should be handled once a task reaches a completed state (SUCCEEDED or FAILED).\n                  - Retain: Keep the resources until the BatchSandbox is deleted.\n                  - Release: Free the resources immediately when the task completes.\n                type: string\n              taskTemplate:\n                description: |-\n                  Task is a custom task spec that is automatically dispatched after the sandbox is successfully created.\n                  The Sandbox is responsible for managing the lifecycle of the task.\n                x-kubernetes-preserve-unknown-fields: true\n              template:\n                description: Template describes the pods that will be created.\n                x-kubernetes-preserve-unknown-fields: true\n            required:\n            - replicas\n            type: object\n          status:\n            description: BatchSandboxStatus defines the observed state of BatchSandbox.\n            properties:\n              allocated:\n                description: \"\\tAllocated is the number of actual scheduled Pod\"\n                format: int32\n                type: integer\n              observedGeneration:\n                description: |-\n                  ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n                  BatchSandbox's generation, which is updated on mutation by the API Server.\n                format: int64\n                type: integer\n              ready:\n                description: \"\\tReady is the number of actual Ready Pod\"\n                format: int32\n                type: integer\n              replicas:\n                description: Replicas is the number of actual Pods\n                format: int32\n                type: integer\n              taskFailed:\n                description: TaskFailed is the number of Failed task\n                format: int32\n                type: integer\n              taskPending:\n                description: TaskPending is the number of Pending task which is unassigned\n                format: int32\n                type: integer\n              taskRunning:\n                description: TaskRunning is the number of Running task\n                format: int32\n                type: integer\n              taskSucceed:\n                description: TaskSucceed is the number of Succeed task\n                format: int32\n                type: integer\n              taskUnknown:\n                description: TaskUnknown is the number of Unknown task\n                format: int32\n                type: integer\n            required:\n            - allocated\n            - ready\n            - replicas\n            - taskFailed\n            - taskPending\n            - taskRunning\n            - taskSucceed\n            - taskUnknown\n            type: object\n        type: object\n    served: true\n    storage: true\n    subresources:\n      status: {}\n"
  },
  {
    "path": "kubernetes/config/crd/bases/sandbox.opensandbox.io_pools.yaml",
    "content": "---\napiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  annotations:\n    controller-gen.kubebuilder.io/version: v0.18.0\n  name: pools.sandbox.opensandbox.io\nspec:\n  group: sandbox.opensandbox.io\n  names:\n    kind: Pool\n    listKind: PoolList\n    plural: pools\n    singular: pool\n  scope: Namespaced\n  versions:\n  - additionalPrinterColumns:\n    - description: The number of all nodes in pool.\n      jsonPath: .status.total\n      name: TOTAL\n      type: integer\n    - description: The number of allocated nodes in pool.\n      jsonPath: .status.allocated\n      name: ALLOCATED\n      type: integer\n    - description: The number of available nodes in pool.\n      jsonPath: .status.available\n      name: AVAILABLE\n      type: integer\n    name: v1alpha1\n    schema:\n      openAPIV3Schema:\n        description: Pool is the Schema for the pools API.\n        properties:\n          apiVersion:\n            description: |-\n              APIVersion defines the versioned schema of this representation of an object.\n              Servers should convert recognized schemas to the latest internal value, and\n              may reject unrecognized values.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n            type: string\n          kind:\n            description: |-\n              Kind is a string value representing the REST resource this object represents.\n              Servers may infer this from the endpoint the client submits requests to.\n              Cannot be updated.\n              In CamelCase.\n              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n            type: string\n          metadata:\n            type: object\n          spec:\n            description: PoolSpec defines the desired state of Pool.\n            properties:\n              capacitySpec:\n                description: CapacitySpec controls the size of the resource pool.\n                properties:\n                  bufferMax:\n                    description: BufferMax is the maximum number of nodes kept in\n                      the warm buffer.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  bufferMin:\n                    description: BufferMin is the minimum number of nodes that must\n                      remain in the buffer.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  poolMax:\n                    description: PoolMax is the maximum total number of nodes allowed\n                      in the entire pool.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                  poolMin:\n                    description: PoolMin is the minimum total size of the pool.\n                    format: int32\n                    minimum: 0\n                    type: integer\n                required:\n                - bufferMax\n                - bufferMin\n                - poolMax\n                - poolMin\n                type: object\n              template:\n                description: Pod Template used to create pre-warmed nodes in the pool.\n                x-kubernetes-preserve-unknown-fields: true\n            required:\n            - capacitySpec\n            type: object\n          status:\n            description: PoolStatus defines the observed state of Pool.\n            properties:\n              allocated:\n                description: Allocated is the number of nodes currently allocated\n                  to sandboxes.\n                format: int32\n                type: integer\n              available:\n                description: Available is the number of nodes currently available\n                  in the pool.\n                format: int32\n                type: integer\n              observedGeneration:\n                description: |-\n                  ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the\n                  BatchSandbox's generation, which is updated on mutation by the API Server.\n                format: int64\n                type: integer\n              revision:\n                description: Revision is the latest version of pool\n                type: string\n              total:\n                description: Total is the total number of nodes in the pool.\n                format: int32\n                type: integer\n            required:\n            - allocated\n            - available\n            - revision\n            - total\n            type: object\n        type: object\n    served: true\n    storage: true\n    subresources:\n      status: {}\n"
  },
  {
    "path": "kubernetes/config/crd/kustomization.yaml",
    "content": "# This kustomization.yaml is not intended to be run by itself,\n# since it depends on service name and namespace that are out of this kustomize package.\n# It should be run by config/default\nresources:\n- bases/sandbox.opensandbox.io_batchsandboxes.yaml\n- bases/sandbox.opensandbox.io_pools.yaml\n# +kubebuilder:scaffold:crdkustomizeresource\n\npatches:\n# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.\n# patches here are for enabling the conversion webhook for each CRD\n# +kubebuilder:scaffold:crdkustomizewebhookpatch\n\n# [WEBHOOK] To enable webhook, uncomment the following section\n# the following config is for teaching kustomize how to do kustomization for CRDs.\n#configurations:\n#- kustomizeconfig.yaml\n"
  },
  {
    "path": "kubernetes/config/crd/kustomizeconfig.yaml",
    "content": "# This file is for teaching kustomize how to substitute name and namespace reference in CRD\nnameReference:\n- kind: Service\n  version: v1\n  fieldSpecs:\n  - kind: CustomResourceDefinition\n    version: v1\n    group: apiextensions.k8s.io\n    path: spec/conversion/webhook/clientConfig/service/name\n\nnamespace:\n- kind: CustomResourceDefinition\n  version: v1\n  group: apiextensions.k8s.io\n  path: spec/conversion/webhook/clientConfig/service/namespace\n  create: false\n\nvarReference:\n- path: metadata/annotations\n"
  },
  {
    "path": "kubernetes/config/default/cert_metrics_manager_patch.yaml",
    "content": "# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs.\n\n# Add the volumeMount for the metrics-server certs\n- op: add\n  path: /spec/template/spec/containers/0/volumeMounts/-\n  value:\n    mountPath: /tmp/k8s-metrics-server/metrics-certs\n    name: metrics-certs\n    readOnly: true\n\n# Add the --metrics-cert-path argument for the metrics server\n- op: add\n  path: /spec/template/spec/containers/0/args/-\n  value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs\n\n# Add the metrics-server certs volume configuration\n- op: add\n  path: /spec/template/spec/volumes/-\n  value:\n    name: metrics-certs\n    secret:\n      secretName: metrics-server-cert\n      optional: false\n      items:\n        - key: ca.crt\n          path: ca.crt\n        - key: tls.crt\n          path: tls.crt\n        - key: tls.key\n          path: tls.key\n"
  },
  {
    "path": "kubernetes/config/default/kustomization.yaml",
    "content": "# Adds namespace to all resources.\nnamespace: opensandbox-system\n\n# Value of this field is prepended to the\n# names of all resources, e.g. a deployment named\n# \"wordpress\" becomes \"alices-wordpress\".\n# Note that it should also match with the prefix (text before '-') of the namespace\n# field above.\nnamePrefix: opensandbox-\n\n# Labels to add to all resources and selectors.\n#labels:\n#- includeSelectors: true\n#  pairs:\n#    someName: someValue\n\nresources:\n- ../crd\n- ../rbac\n- ../manager\n# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in\n# crd/kustomization.yaml\n#- ../webhook\n# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.\n#- ../certmanager\n# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.\n#- ../prometheus\n# [METRICS] Expose the controller manager metrics service.\n- metrics_service.yaml\n# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy.\n# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics.\n# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will\n# be able to communicate with the Webhook Server.\n#- ../network-policy\n\n# Uncomment the patches line if you enable Metrics\npatches:\n# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443.\n# More info: https://book.kubebuilder.io/reference/metrics\n- path: manager_metrics_patch.yaml\n  target:\n    kind: Deployment\n\n# Uncomment the patches line if you enable Metrics and CertManager\n# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line.\n# This patch will protect the metrics with certManager self-signed certs.\n#- path: cert_metrics_manager_patch.yaml\n#  target:\n#    kind: Deployment\n\n# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in\n# crd/kustomization.yaml\n#- path: manager_webhook_patch.yaml\n#  target:\n#    kind: Deployment\n\n# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.\n# Uncomment the following replacements to add the cert-manager CA injection annotations\n#replacements:\n# - source: # Uncomment the following block to enable certificates for metrics\n#     kind: Service\n#     version: v1\n#     name: controller-manager-metrics-service\n#     fieldPath: metadata.name\n#   targets:\n#     - select:\n#         kind: Certificate\n#         group: cert-manager.io\n#         version: v1\n#         name: metrics-certs\n#       fieldPaths:\n#         - spec.dnsNames.0\n#         - spec.dnsNames.1\n#       options:\n#         delimiter: '.'\n#         index: 0\n#         create: true\n#     - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor\n#         kind: ServiceMonitor\n#         group: monitoring.coreos.com\n#         version: v1\n#         name: controller-manager-metrics-monitor\n#       fieldPaths:\n#         - spec.endpoints.0.tlsConfig.serverName\n#       options:\n#         delimiter: '.'\n#         index: 0\n#         create: true\n#\n# - source:\n#     kind: Service\n#     version: v1\n#     name: controller-manager-metrics-service\n#     fieldPath: metadata.namespace\n#   targets:\n#     - select:\n#         kind: Certificate\n#         group: cert-manager.io\n#         version: v1\n#         name: metrics-certs\n#       fieldPaths:\n#         - spec.dnsNames.0\n#         - spec.dnsNames.1\n#       options:\n#         delimiter: '.'\n#         index: 1\n#         create: true\n#     - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor\n#         kind: ServiceMonitor\n#         group: monitoring.coreos.com\n#         version: v1\n#         name: controller-manager-metrics-monitor\n#       fieldPaths:\n#         - spec.endpoints.0.tlsConfig.serverName\n#       options:\n#         delimiter: '.'\n#         index: 1\n#         create: true\n#\n# - source: # Uncomment the following block if you have any webhook\n#     kind: Service\n#     version: v1\n#     name: webhook-service\n#     fieldPath: .metadata.name # Name of the service\n#   targets:\n#     - select:\n#         kind: Certificate\n#         group: cert-manager.io\n#         version: v1\n#         name: serving-cert\n#       fieldPaths:\n#         - .spec.dnsNames.0\n#         - .spec.dnsNames.1\n#       options:\n#         delimiter: '.'\n#         index: 0\n#         create: true\n# - source:\n#     kind: Service\n#     version: v1\n#     name: webhook-service\n#     fieldPath: .metadata.namespace # Namespace of the service\n#   targets:\n#     - select:\n#         kind: Certificate\n#         group: cert-manager.io\n#         version: v1\n#         name: serving-cert\n#       fieldPaths:\n#         - .spec.dnsNames.0\n#         - .spec.dnsNames.1\n#       options:\n#         delimiter: '.'\n#         index: 1\n#         create: true\n#\n# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert # This name should match the one in certificate.yaml\n#     fieldPath: .metadata.namespace # Namespace of the certificate CR\n#   targets:\n#     - select:\n#         kind: ValidatingWebhookConfiguration\n#       fieldPaths:\n#         - .metadata.annotations.[cert-manager.io/inject-ca-from]\n#       options:\n#         delimiter: '/'\n#         index: 0\n#         create: true\n# - source:\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert\n#     fieldPath: .metadata.name\n#   targets:\n#     - select:\n#         kind: ValidatingWebhookConfiguration\n#       fieldPaths:\n#         - .metadata.annotations.[cert-manager.io/inject-ca-from]\n#       options:\n#         delimiter: '/'\n#         index: 1\n#         create: true\n#\n# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert\n#     fieldPath: .metadata.namespace # Namespace of the certificate CR\n#   targets:\n#     - select:\n#         kind: MutatingWebhookConfiguration\n#       fieldPaths:\n#         - .metadata.annotations.[cert-manager.io/inject-ca-from]\n#       options:\n#         delimiter: '/'\n#         index: 0\n#         create: true\n# - source:\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert\n#     fieldPath: .metadata.name\n#   targets:\n#     - select:\n#         kind: MutatingWebhookConfiguration\n#       fieldPaths:\n#         - .metadata.annotations.[cert-manager.io/inject-ca-from]\n#       options:\n#         delimiter: '/'\n#         index: 1\n#         create: true\n#\n# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert\n#     fieldPath: .metadata.namespace # Namespace of the certificate CR\n#   targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.\n# +kubebuilder:scaffold:crdkustomizecainjectionns\n# - source:\n#     kind: Certificate\n#     group: cert-manager.io\n#     version: v1\n#     name: serving-cert\n#     fieldPath: .metadata.name\n#   targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD.\n# +kubebuilder:scaffold:crdkustomizecainjectionname\n"
  },
  {
    "path": "kubernetes/config/default/manager_metrics_patch.yaml",
    "content": "# This patch adds the args to allow exposing the metrics endpoint using HTTPS\n- op: add\n  path: /spec/template/spec/containers/0/args/0\n  value: --metrics-bind-address=:8443\n"
  },
  {
    "path": "kubernetes/config/default/metrics_service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    control-plane: controller-manager\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: controller-manager-metrics-service\n  namespace: system\nspec:\n  ports:\n  - name: https\n    port: 8443\n    protocol: TCP\n    targetPort: 8443\n  selector:\n    control-plane: controller-manager\n    app.kubernetes.io/name: opensandbox\n"
  },
  {
    "path": "kubernetes/config/manager/kustomization.yaml",
    "content": "resources:\n- manager.yaml\napiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nimages:\n- name: controller\n  newName: controller\n  newTag: dev\n- name: manager\n  newName: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/controller\n  newTag: v0.0.1\n"
  },
  {
    "path": "kubernetes/config/manager/manager.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  labels:\n    control-plane: controller-manager\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: system\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: controller-manager\n  namespace: system\n  labels:\n    control-plane: controller-manager\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\nspec:\n  selector:\n    matchLabels:\n      control-plane: controller-manager\n      app.kubernetes.io/name: opensandbox\n  replicas: 1\n  template:\n    metadata:\n      annotations:\n        kubectl.kubernetes.io/default-container: manager\n      labels:\n        control-plane: controller-manager\n        app.kubernetes.io/name: opensandbox\n    spec:\n      # TODO(user): Uncomment the following code to configure the nodeAffinity expression\n      # according to the platforms which are supported by your solution.\n      # It is considered best practice to support multiple architectures. You can\n      # build your manager image using the makefile target docker-buildx.\n      # affinity:\n      #   nodeAffinity:\n      #     requiredDuringSchedulingIgnoredDuringExecution:\n      #       nodeSelectorTerms:\n      #         - matchExpressions:\n      #           - key: kubernetes.io/arch\n      #             operator: In\n      #             values:\n      #               - amd64\n      #               - arm64\n      #               - ppc64le\n      #               - s390x\n      #           - key: kubernetes.io/os\n      #             operator: In\n      #             values:\n      #               - linux\n      securityContext:\n        # Projects are configured by default to adhere to the \"restricted\" Pod Security Standards.\n        # This ensures that deployments meet the highest security requirements for Kubernetes.\n        # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted\n        runAsNonRoot: true\n        seccompProfile:\n          type: RuntimeDefault\n      containers:\n      - command:\n        - /workspace/server\n        args:\n          - --leader-elect\n          - --health-probe-bind-address=:8081\n        image: controller:dev\n        name: manager\n        ports: []\n        securityContext:\n          allowPrivilegeEscalation: false\n          capabilities:\n            drop:\n            - \"ALL\"\n        livenessProbe:\n          httpGet:\n            path: /healthz\n            port: 8081\n          initialDelaySeconds: 15\n          periodSeconds: 20\n        readinessProbe:\n          httpGet:\n            path: /readyz\n            port: 8081\n          initialDelaySeconds: 5\n          periodSeconds: 10\n        # TODO(user): Configure the resources accordingly based on the project requirements.\n        # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/\n        resources:\n          limits:\n            cpu: 500m\n            memory: 128Mi\n          requests:\n            cpu: 10m\n            memory: 64Mi\n        volumeMounts: []\n      volumes: []\n      serviceAccountName: controller-manager\n      terminationGracePeriodSeconds: 10\n\n"
  },
  {
    "path": "kubernetes/config/manifests/kustomization.yaml",
    "content": "# These resources constitute the fully configured set of manifests\n# used to generate the 'manifests/' directory in a bundle.\nresources:\n- bases/sandbox-k8s.clusterserviceversion.yaml\n- ../default\n- ../samples\n- ../scorecard\n\n# [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix.\n# Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager.\n# These patches remove the unnecessary \"cert\" volume and its manager container volumeMount.\n#patches:\n#- target:\n#    group: apps\n#    version: v1\n#    kind: Deployment\n#    name: controller-manager\n#    namespace: system\n#  patch: |-\n#    # Remove the manager container's \"cert\" volumeMount, since OLM will create and mount a set of certs.\n#    # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment.\n#    - op: remove\n\n#      path: /spec/template/spec/containers/0/volumeMounts/0\n#    # Remove the \"cert\" volume, since OLM will create and mount a set of certs.\n#    # Update the indices in this path if adding or removing volumes in the manager's Deployment.\n#    - op: remove\n#      path: /spec/template/spec/volumes/0\n"
  },
  {
    "path": "kubernetes/config/network-policy/allow-metrics-traffic.yaml",
    "content": "# This NetworkPolicy allows ingress traffic\n# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those\n# namespaces are able to gather data from the metrics endpoint.\napiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: allow-metrics-traffic\n  namespace: system\nspec:\n  podSelector:\n    matchLabels:\n      control-plane: controller-manager\n      app.kubernetes.io/name: opensandbox\n  policyTypes:\n    - Ingress\n  ingress:\n    # This allows ingress traffic from any namespace with the label metrics: enabled\n    - from:\n      - namespaceSelector:\n          matchLabels:\n            metrics: enabled  # Only from namespaces with this label\n      ports:\n        - port: 8443\n          protocol: TCP\n"
  },
  {
    "path": "kubernetes/config/network-policy/kustomization.yaml",
    "content": "resources:\n- allow-metrics-traffic.yaml\n"
  },
  {
    "path": "kubernetes/config/prometheus/kustomization.yaml",
    "content": "resources:\n- monitor.yaml\n\n# [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus\n# to securely reference certificates created and managed by cert-manager.\n# Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml\n# to mount the \"metrics-server-cert\" secret in the Manager Deployment.\n#patches:\n#  - path: monitor_tls_patch.yaml\n#    target:\n#      kind: ServiceMonitor\n"
  },
  {
    "path": "kubernetes/config/prometheus/monitor.yaml",
    "content": "# Prometheus Monitor Service (Metrics)\napiVersion: monitoring.coreos.com/v1\nkind: ServiceMonitor\nmetadata:\n  labels:\n    control-plane: controller-manager\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: controller-manager-metrics-monitor\n  namespace: system\nspec:\n  endpoints:\n    - path: /metrics\n      port: https # Ensure this is the name of the port that exposes HTTPS metrics\n      scheme: https\n      bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token\n      tlsConfig:\n        # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables\n        # certificate verification, exposing the system to potential man-in-the-middle attacks.\n        # For production environments, it is recommended to use cert-manager for automatic TLS certificate management.\n        # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml,\n        # which securely references the certificate from the 'metrics-server-cert' secret.\n        insecureSkipVerify: true\n  selector:\n    matchLabels:\n      control-plane: controller-manager\n      app.kubernetes.io/name: opensandbox\n"
  },
  {
    "path": "kubernetes/config/prometheus/monitor_tls_patch.yaml",
    "content": "# Patch for Prometheus ServiceMonitor to enable secure TLS configuration\n# using certificates managed by cert-manager\n- op: replace\n  path: /spec/endpoints/0/tlsConfig\n  value:\n    # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize\n    serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc\n    insecureSkipVerify: false\n    ca:\n      secret:\n        name: metrics-server-cert\n        key: ca.crt\n    cert:\n      secret:\n        name: metrics-server-cert\n        key: tls.crt\n    keySecret:\n      name: metrics-server-cert\n      key: tls.key\n"
  },
  {
    "path": "kubernetes/config/rbac/batchsandbox_admin_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants full permissions ('*') over sandbox.opensandbox.io.\n# This role is intended for users authorized to modify roles and bindings within the cluster,\n# enabling them to delegate specific permissions to other users or groups as needed.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-admin-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes\n  verbs:\n  - '*'\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/batchsandbox_editor_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants permissions to create, update, and delete resources within the sandbox.opensandbox.io.\n# This role is intended for users who need to manage these resources\n# but should not control RBAC or manage permissions for others.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-editor-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/batchsandbox_viewer_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants read-only access to sandbox.opensandbox.io resources.\n# This role is intended for users who need visibility into these resources\n# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-viewer-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes\n  verbs:\n  - get\n  - list\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/kustomization.yaml",
    "content": "resources:\n# All RBAC will be applied under this service account in\n# the deployment namespace. You may comment out this resource\n# if your manager will use a service account that exists at\n# runtime. Be sure to update RoleBinding and ClusterRoleBinding\n# subjects if changing service account names.\n- service_account.yaml\n- role.yaml\n- role_binding.yaml\n- leader_election_role.yaml\n- leader_election_role_binding.yaml\n# The following RBAC configurations are used to protect\n# the metrics endpoint with authn/authz. These configurations\n# ensure that only authorized users and service accounts\n# can access the metrics endpoint. Comment the following\n# permissions if you want to disable this protection.\n# More info: https://book.kubebuilder.io/reference/metrics.html\n- metrics_auth_role.yaml\n- metrics_auth_role_binding.yaml\n- metrics_reader_role.yaml\n# For each CRD, \"Admin\", \"Editor\" and \"Viewer\" roles are scaffolded by\n# default, aiding admins in cluster management. Those roles are\n# not used by the sandbox-k8s itself. You can comment the following lines\n# if you do not want those helpers be installed with your Project.\n- pool_admin_role.yaml\n- pool_editor_role.yaml\n- pool_viewer_role.yaml\n- batchsandbox_admin_role.yaml\n- batchsandbox_editor_role.yaml\n- batchsandbox_viewer_role.yaml\n\n"
  },
  {
    "path": "kubernetes/config/rbac/leader_election_role.yaml",
    "content": "# permissions to do leader election.\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: leader-election-role\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - configmaps\n  verbs:\n  - get\n  - list\n  - watch\n  - create\n  - update\n  - patch\n  - delete\n- apiGroups:\n  - coordination.k8s.io\n  resources:\n  - leases\n  verbs:\n  - get\n  - list\n  - watch\n  - create\n  - update\n  - patch\n  - delete\n- apiGroups:\n  - \"\"\n  resources:\n  - events\n  verbs:\n  - create\n  - patch\n"
  },
  {
    "path": "kubernetes/config/rbac/leader_election_role_binding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: leader-election-rolebinding\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: leader-election-role\nsubjects:\n- kind: ServiceAccount\n  name: controller-manager\n  namespace: system\n"
  },
  {
    "path": "kubernetes/config/rbac/metrics_auth_role.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: metrics-auth-role\nrules:\n- apiGroups:\n  - authentication.k8s.io\n  resources:\n  - tokenreviews\n  verbs:\n  - create\n- apiGroups:\n  - authorization.k8s.io\n  resources:\n  - subjectaccessreviews\n  verbs:\n  - create\n"
  },
  {
    "path": "kubernetes/config/rbac/metrics_auth_role_binding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: metrics-auth-rolebinding\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: metrics-auth-role\nsubjects:\n- kind: ServiceAccount\n  name: controller-manager\n  namespace: system\n"
  },
  {
    "path": "kubernetes/config/rbac/metrics_reader_role.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: metrics-reader\nrules:\n- nonResourceURLs:\n  - \"/metrics\"\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/pool_admin_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants full permissions ('*') over sandbox.opensandbox.io.\n# This role is intended for users authorized to modify roles and bindings within the cluster,\n# enabling them to delegate specific permissions to other users or groups as needed.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: pool-admin-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools\n  verbs:\n  - '*'\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/pool_editor_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants permissions to create, update, and delete resources within the sandbox.opensandbox.io.\n# This role is intended for users who need to manage these resources\n# but should not control RBAC or manage permissions for others.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: pool-editor-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/pool_viewer_role.yaml",
    "content": "# This rule is not used by the project sandbox-k8s itself.\n# It is provided to allow the cluster admin to help manage permissions for users.\n#\n# Grants read-only access to sandbox.opensandbox.io resources.\n# This role is intended for users who need visibility into these resources\n# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: pool-viewer-role\nrules:\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools\n  verbs:\n  - get\n  - list\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - pools/status\n  verbs:\n  - get\n"
  },
  {
    "path": "kubernetes/config/rbac/role.yaml",
    "content": "---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: manager-role\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - events\n  - pods\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - \"\"\n  resources:\n  - pods/status\n  verbs:\n  - get\n  - patch\n  - update\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes\n  - pools\n  verbs:\n  - create\n  - delete\n  - get\n  - list\n  - patch\n  - update\n  - watch\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/finalizers\n  - pools/finalizers\n  verbs:\n  - update\n- apiGroups:\n  - sandbox.opensandbox.io\n  resources:\n  - batchsandboxes/status\n  - pools/status\n  verbs:\n  - get\n  - patch\n  - update\n"
  },
  {
    "path": "kubernetes/config/rbac/role_binding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: manager-rolebinding\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: manager-role\nsubjects:\n- kind: ServiceAccount\n  name: controller-manager\n  namespace: system\n"
  },
  {
    "path": "kubernetes/config/rbac/service_account.yaml",
    "content": "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: controller-manager\n  namespace: system\n"
  },
  {
    "path": "kubernetes/config/samples/kustomization.yaml",
    "content": "## Append samples of your project ##\nresources:\n- sandbox_v1alpha1_sandbox.yaml\n- sandbox_v1alpha1_batchsandbox.yaml\n- sandbox_v1alpha1_pool.yaml\n# +kubebuilder:scaffold:manifestskustomizesamples\n"
  },
  {
    "path": "kubernetes/config/samples/sandbox_v1alpha1_batchsandbox-with-task.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-sample\n  namespace: opensandbox\nspec:\n  replicas: 2\n  template:\n    metadata:\n      labels:\n        app: example\n    spec:\n      containers:\n      - name: main\n        image: registry.k8s.io/e2e-test-images/httpd:2.4.38-4\n        command:\n        - tail\n        - -f\n        - /dev/null\n  expireTime: \"2025-12-03T12:55:41Z\"\n  taskTemplate:\n    spec:\n      process:\n        command:\n        - sleep\n        args:\n        - infinite\n        env:\n        - name: foo\n          value: bar\n  shardTaskPatches:\n  - spec:\n      process:\n        command: # patch command and args, the final command is `python -m http.server 8080` with process envs(foo=bar)\n        - python\n        args:\n        - -m\n        - http.server\n        - \"8080\"\n  - spec:\n      process:\n        args: # patch args, the final command is `sleep 3600` with process envs(foo=bar;hello=world)\n        - 3600\n        env:\n        - name: hello\n          value: world\n"
  },
  {
    "path": "kubernetes/config/samples/sandbox_v1alpha1_batchsandbox.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-sample\n  namespace: opensandbox\nspec:\n  replicas: 1\n  poolRef: pool-sample\n  expireTime: \"2026-12-03T12:55:41Z\""
  },
  {
    "path": "kubernetes/config/samples/sandbox_v1alpha1_pool.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: pool-sample\n  namespace: opensandbox\nspec:\n  template:\n    metadata:\n      labels:\n        app: example\n    spec:\n      volumes:\n        - name: sandbox-storage\n          emptyDir: { }\n        - name: opensandbox-bin\n          emptyDir: { }\n        - name: sandbox-logs\n          emptyDir: { }\n      initContainers:\n        - name: task-executor-installer\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:v0.1.0\n          command: [ \"/bin/sh\", \"-c\" ]\n          args:\n            - |\n              cp /workspace/server /opt/opensandbox/bin/task-executor && \n              chmod +x /opt/opensandbox/bin/task-executor\n          volumeMounts:\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n        - name: execd-installer\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\n          command: [ \"/bin/sh\", \"-c\" ]\n          args:\n            - |\n              cp ./execd /opt/opensandbox/bin/execd && \n              cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh &&\n              chmod +x /opt/opensandbox/bin/execd &&\n              chmod +x /opt/opensandbox/bin/bootstrap.sh\n          volumeMounts:\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n      containers:\n        - name: sandbox\n          image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\n          command:\n          - \"/bin/sh\"\n          - \"-c\"\n          - |\n            /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1\n          env:\n          - name: SANDBOX_MAIN_CONTAINER\n            value: main\n          - name: EXECD_ENVS\n            value: /opt/opensandbox/.env\n          - name: EXECD\n            value: /opt/opensandbox/bin/execd\n          volumeMounts:\n            - name: sandbox-storage\n              mountPath: /var/lib/sandbox\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n            - name: sandbox-logs\n              mountPath: /workspace/logs\n      tolerations:\n        - operator: \"Exists\"\n  capacitySpec:\n    bufferMax: 3\n    bufferMin: 1\n    poolMax: 5\n    poolMin: 0\n"
  },
  {
    "path": "kubernetes/config/samples/sandbox_v1alpha1_pooled_batchsandbox.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  labels:\n    app.kubernetes.io/name: opensandbox\n    app.kubernetes.io/managed-by: kustomize\n  name: batchsandbox-pool-sample\n  namespace: opensandbox\nspec:\n  poolRef: pool-sample\n  replicas: 2\n  expireTime: \"2026-12-03T12:55:41Z\"\n"
  },
  {
    "path": "kubernetes/config/scorecard/bases/config.yaml",
    "content": "apiVersion: scorecard.operatorframework.io/v1alpha3\nkind: Configuration\nmetadata:\n  name: config\nstages:\n- parallel: true\n  tests: []\n"
  },
  {
    "path": "kubernetes/config/scorecard/kustomization.yaml",
    "content": "resources:\n- bases/config.yaml\napiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\npatches:\n- path: patches/basic.config.yaml\n  target:\n    group: scorecard.operatorframework.io\n    kind: Configuration\n    name: config\n    version: v1alpha3\n- path: patches/olm.config.yaml\n  target:\n    group: scorecard.operatorframework.io\n    kind: Configuration\n    name: config\n    version: v1alpha3\n# +kubebuilder:scaffold:patches\n"
  },
  {
    "path": "kubernetes/config/scorecard/patches/basic.config.yaml",
    "content": "- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - basic-check-spec\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: basic\n      test: basic-check-spec-test\n"
  },
  {
    "path": "kubernetes/config/scorecard/patches/olm.config.yaml",
    "content": "- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - olm-bundle-validation\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: olm\n      test: olm-bundle-validation-test\n- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - olm-crds-have-validation\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: olm\n      test: olm-crds-have-validation-test\n- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - olm-crds-have-resources\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: olm\n      test: olm-crds-have-resources-test\n- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - olm-spec-descriptors\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: olm\n      test: olm-spec-descriptors-test\n- op: add\n  path: /stages/0/tests/-\n  value:\n    entrypoint:\n    - scorecard-test\n    - olm-status-descriptors\n    image: quay.io/operator-framework/scorecard-test:v1.42.0\n    labels:\n      suite: olm\n      test: olm-status-descriptors-test\n"
  },
  {
    "path": "kubernetes/docs/BUILD-IMAGES.md",
    "content": "# 镜像构建指南\n\n本文档介绍如何构建 OpenSandbox Kubernetes Controller 和 Task Executor 镜像。\n\n## 方式一: 使用构建脚本（推荐）\n\n### 本地构建\n\n```bash\ncd kubernetes\n\n# 构建 controller 镜像\nCOMPONENT=controller TAG=v0.1.0 PUSH=false ./build.sh\n\n# 构建 task-executor 镜像\nCOMPONENT=task-executor TAG=v0.1.0 PUSH=false ./build.sh\n```\n\n### 构建并推送到镜像仓库\n\n```bash\n# 确保已登录阿里云 ACR\ndocker login sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com\n\n# 构建并推送 controller 镜像\nCOMPONENT=controller TAG=v0.1.0 ./build.sh\n\n# 构建并推送 task-executor 镜像\nCOMPONENT=task-executor TAG=v0.1.0 ./build.sh\n```\n\n### 环境变量说明\n\n- `COMPONENT`: 要构建的组件，可选值: `controller`, `task-executor`\n- `TAG`: 镜像标签，默认为 `latest`\n- `PUSH`: 是否推送到远程仓库，默认为 `true`\n\n## 方式二: 使用 GitHub Actions\n\n### 手动触发工作流\n\n1. 打开 [Actions 页面](https://github.com/alibaba/OpenSandbox/actions)\n2. 选择 \"Publish Components Image\" 工作流\n3. 点击 \"Run workflow\"\n4. 选择组件和镜像标签:\n   - Component: 在下拉菜单中选择组件名称\n     - Controller: `controller`\n     - Task Executor: `task-executor`\n   - Image tag: 输入镜像标签，例如 `v0.1.0`\n5. 点击 \"Run workflow\" 开始构建\n\n### 通过 Git Tag 触发（推荐）\n\n创建带有特定前缀的 tag 即可自动触发构建:\n\n```bash\n# 构建 controller v0.1.0\ngit tag k8s/controller/v0.1.0\ngit push origin k8s/controller/v0.1.0\n\n# 构建 task-executor v0.1.0\ngit tag k8s/task-executor/v0.1.0\ngit push origin k8s/task-executor/v0.1.0\n```\n\n**Tag 命名规则**: `k8s/<component>/<version>`\n- `<component>`: 组件名称 `controller` 或 `task-executor`\n- `<version>`: 镜像版本号，例如 `v0.1.0`\n\n## 方式三: 使用 Makefile\n\n```bash\ncd kubernetes\n\n# 构建 controller 镜像（仅本地）\nmake docker-build CONTROLLER_IMG=myregistry/opensandbox-controller:v0.1.0\n\n# 构建 task-executor 镜像（仅本地）\nmake docker-build-task-executor TASK_EXECUTOR_IMG=myregistry/opensandbox-task-executor:v0.1.0\n\n# 推送镜像\nmake docker-push CONTROLLER_IMG=myregistry/opensandbox-controller:v0.1.0\nmake docker-push-task-executor TASK_EXECUTOR_IMG=myregistry/opensandbox-task-executor:v0.1.0\n```\n\n## 镜像仓库\n\n构建的镜像会推送到以下仓库:\n\n### 阿里云容器镜像服务 (ACR)\n- Controller: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/controller:<tag>`\n- Task Executor: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:<tag>`\n\n## 多架构支持\n\n构建脚本默认支持以下架构:\n- `linux/amd64`\n- `linux/arm64`\n\n如需构建其他架构，请修改 `build.sh` 中的 `PLATFORMS` 变量。\n\n## 本地测试\n\n如果只想在本地测试镜像而不推送:\n\n```bash\n# 构建本地镜像\nCOMPONENT=controller TAG=test PUSH=false ./build.sh\n\n# 加载到 kind 集群测试\nkind load docker-image opensandbox-controller:test\n\n# 或加载到 minikube 测试\nminikube image load opensandbox-controller:test\n```\n\n## 故障排查\n\n### 权限问题\n\n如果遇到 Docker 权限问题:\n```bash\nsudo usermod -aG docker $USER\nnewgrp docker\n```\n\n### Buildx 不可用\n\n确保启用 Docker Buildx:\n```bash\ndocker buildx create --use\ndocker buildx inspect --bootstrap\n```\n\n### 磁盘空间不足\n\n清理 Docker 缓存:\n```bash\ndocker system prune -a\ndocker builder prune -a\n```\n\n## 配置私有镜像仓库\n\n如需使用自己的镜像仓库，修改 `build.sh` 中的仓库地址:\n\n```bash\n# 编辑 build.sh\nACR_REPO=\"your-acr-registry.cr.aliyuncs.com/your-namespace\"\n```\n\n或者直接在构建时使用环境变量:\n```bash\nACR_REPO=myregistry.com/myrepo COMPONENT=controller TAG=v0.1.0 ./build.sh\n```\n"
  },
  {
    "path": "kubernetes/docs/HELM-DEPLOYMENT.md",
    "content": "# Helm Chart 部署方式\n\n本文档介绍如何使用 Helm Chart 部署 OpenSandbox Controller。\n\n## 前置要求\n\n- Kubernetes 1.22.4+\n- Helm 3.0+\n- kubectl 已配置并可访问目标集群\n\n## 快速开始\n\n### 方式一: 直接从 GitHub Release 安装 (推荐)\n\n直接下载并安装发布的 Chart 包:\n\n```bash\n# 安装最新版本 (0.1.0)\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n如需使用自定义镜像:\n\n```bash\nhelm install opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \\\n  --set controller.image.repository=<your-registry>/controller \\\n  --set controller.image.tag=v0.0.1 \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n### 方式二: 本地 Chart 安装\n\n如果您从源码构建,可以使用本地 Chart:\n\n#### 1. 构建镜像\n\n首先构建 controller 和 task-executor 镜像:\n\n```bash\n# 构建 controller 镜像\ncd kubernetes\nCOMPONENT=controller TAG=v0.0.1 ./build.sh\n\n# 构建 task-executor 镜像\nCOMPONENT=task-executor TAG=v0.0.1 ./build.sh\n```\n\n#### 2. 安装本地 Helm Chart\n\n```bash\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  --set controller.image.repository=<your-registry>/controller \\\n  --set controller.image.tag=v0.0.1 \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n或者使用 Makefile:\n\n```bash\nmake helm-install \\\n  IMAGE_TAG_BASE=<your-registry>/controller \\\n  VERSION=v0.0.1\n```\n\n### 3. 验证安装\n\n```bash\n# 检查 Pod 状态\nkubectl get pods -n opensandbox-system\n\n# 检查 CRD\nkubectl get crd | grep opensandbox\n\n# 查看安装状态\nhelm status opensandbox-controller -n opensandbox-system\n\n# 查看已安装的 Chart 版本\nhelm list -n opensandbox-system\n```\n\n## 版本管理\n\n### 查看可用版本\n\n访问 GitHub Releases 查看所有可用版本:\nhttps://github.com/alibaba/OpenSandbox/releases\n\n查找以 `helm/opensandbox-controller/` 开头的 tag,如 `helm/opensandbox-controller/0.1.0`\n\n### 升级到指定版本\n\n```bash\n# 直接从 GitHub Release 升级\nhelm upgrade opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \\\n  --namespace opensandbox-system\n```\n\n## 自定义配置\n\n### 使用自定义 values 文件\n\n创建自定义 values 文件 `custom-values.yaml`:\n\n```yaml\ncontroller:\n  image:\n    repository: myregistry.example.com/opensandbox-controller\n    tag: v0.1.0\n  \n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n    requests:\n      cpu: 100m\n      memory: 128Mi\n  \n  logLevel: debug\n\nimagePullSecrets:\n  - name: myregistrykey\n```\n\n使用自定义配置安装:\n\n```bash\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  -f custom-values.yaml \\\n  --namespace opensandbox-system \\\n  --create-namespace\n```\n\n### 常用配置示例\n\n#### 1. 调整资源配置\n\n```bash\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  --set controller.resources.limits.cpu=1000m \\\n  --set controller.resources.limits.memory=512Mi \\\n  --namespace opensandbox-system\n```\n\n#### 3. 配置节点亲和性\n\n创建 `affinity-values.yaml`:\n\n```yaml\ncontroller:\n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n  affinity:\n    nodeAffinity:\n      requiredDuringSchedulingIgnoredDuringExecution:\n        nodeSelectorTerms:\n        - matchExpressions:\n          - key: node-role.kubernetes.io/control-plane\n            operator: Exists\n```\n\n```bash\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  -f affinity-values.yaml \\\n  --namespace opensandbox-system\n```\n\n## 升级\n\n### 升级 Helm Release\n\n从 GitHub Release 升级:\n\n```bash\n# 升级到指定版本\nhelm upgrade opensandbox-controller \\\n  https://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \\\n  --namespace opensandbox-system\n```\n\n从本地 Chart 升级:\n\n```bash\nhelm upgrade opensandbox-controller ./charts/opensandbox-controller \\\n  --set controller.image.tag=v0.0.2 \\\n  --namespace opensandbox-system\n```\n\n或使用 Makefile:\n\n```bash\nmake helm-upgrade VERSION=v0.0.2\n```\n\n### 查看升级历史\n\n```bash\nhelm history opensandbox-controller -n opensandbox-system\n```\n\n### 回滚\n\n```bash\n# 回滚到上一个版本\nhelm rollback opensandbox-controller -n opensandbox-system\n\n# 回滚到指定版本\nhelm rollback opensandbox-controller 1 -n opensandbox-system\n```\n\n## 卸载\n\n### 卸载 Helm Release\n\n```bash\nhelm uninstall opensandbox-controller -n opensandbox-system\n```\n\n或使用 Makefile:\n\n```bash\nmake helm-uninstall\n```\n\n**注意**: 默认情况下,CRD 会被保留。如需删除 CRD:\n\n```bash\nkubectl delete crd batchsandboxes.sandbox.opensandbox.io\nkubectl delete crd pools.sandbox.opensandbox.io\n```\n\n### 清理 Namespace\n\n如果要完全清理:\n\n```bash\nkubectl delete namespace opensandbox-system\n```\n\n## Makefile 命令\n\n项目提供了一系列 Makefile 命令来简化 Helm 操作:\n\n```bash\n# 检查 Helm Chart 语法\nmake helm-lint\n\n# 生成 Kubernetes 清单(不安装)\nmake helm-template\n\n# 生成清单并显示调试信息\nmake helm-template-debug\n\n# 打包 Helm Chart\nmake helm-package\n\n# 安装 Helm Chart\nmake helm-install\n\n# 升级 Helm Chart\nmake helm-upgrade\n\n# 卸载 Helm Chart\nmake helm-uninstall\n\n# 测试已安装的 Chart\nmake helm-test\n\n# 执行 dry-run 安装\nmake helm-dry-run\n\n# 执行所有 Helm 相关任务\nmake helm-all\n```\n\n## 验证部署\n\n### 1. 检查 Controller 状态\n\n```bash\nkubectl get deployment -n opensandbox-system\nkubectl get pods -n opensandbox-system\nkubectl logs -n opensandbox-system -l control-plane=controller-manager -f\n```\n\n### 2. 验证 CRD\n\n```bash\nkubectl get crd batchsandboxes.sandbox.opensandbox.io -o yaml\nkubectl get crd pools.sandbox.opensandbox.io -o yaml\n```\n\n### 3. 创建测试资源\n\n```bash\n# 创建 Pool\nkubectl apply -f config/samples/sandbox_v1alpha1_pool.yaml\n\n# 创建 BatchSandbox\nkubectl apply -f config/samples/sandbox_v1alpha1_batchsandbox.yaml\n\n# 查看状态\nkubectl get pools -n opensandbox-system\nkubectl get batchsandboxes -n opensandbox-system\n```\n\n## 故障排查\n\n### Chart 验证失败\n\n```bash\n# 检查 Chart 语法\nmake helm-lint\n\n# 查看详细模板输出\nmake helm-template-debug\n```\n\n### Controller 无法启动\n\n```bash\n# 查看 Pod 状态\nkubectl describe pod -n opensandbox-system -l control-plane=controller-manager\n\n# 查看日志\nkubectl logs -n opensandbox-system -l control-plane=controller-manager\n\n# 检查 RBAC 权限\nkubectl auth can-i --as=system:serviceaccount:opensandbox-system:opensandbox-opensandbox-controller-controller-manager create pods\n```\n\n### 镜像拉取失败\n\n```bash\n# 检查镜像配置\nhelm get values opensandbox-controller -n opensandbox-system\n\n# 添加镜像拉取密钥\nkubectl create secret docker-registry myregistrykey \\\n  --docker-server=<your-registry> \\\n  --docker-username=<username> \\\n  --docker-password=<password> \\\n  -n opensandbox-system\n\n# 使用密钥重新安装\nhelm upgrade opensandbox-controller ./charts/opensandbox-controller \\\n  --set imagePullSecrets[0].name=myregistrykey \\\n  --namespace opensandbox-system\n```\n\n## 高级配置\n\n### 多环境部署\n\n为不同环境创建专用的 values 文件:\n\n#### values-dev.yaml\n```yaml\ncontroller:\n  logLevel: debug\n  resources:\n    limits:\n      cpu: 200m\n      memory: 128Mi\n```\n\n#### values-prod.yaml\n```yaml\ncontroller:\n  logLevel: warn\n  replicaCount: 3\n  resources:\n    limits:\n      cpu: 1000m\n      memory: 512Mi\n  affinity:\n    podAntiAffinity:\n      requiredDuringSchedulingIgnoredDuringExecution:\n      - labelSelector:\n          matchExpressions:\n          - key: control-plane\n            operator: In\n            values:\n            - controller-manager\n        topologyKey: kubernetes.io/hostname\n```\n\n部署到不同环境:\n\n```bash\n# 开发环境\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  -f values-dev.yaml \\\n  --namespace opensandbox-dev\n\n# 生产环境\nhelm install opensandbox-controller ./charts/opensandbox-controller \\\n  -f values-prod.yaml \\\n  --namespace opensandbox-prod\n```\n\n## 发布 Helm Chart (维护者使用)\n\n### 自动发布\n\n通过 GitHub Actions 自动发布 Helm Chart:\n\n#### 方式一: 通过 Git Tag 触发\n\n```bash\n# 发布 opensandbox-controller chart 版本 0.1.0\ngit tag helm/opensandbox-controller/0.1.0\ngit push origin helm/opensandbox-controller/0.1.0\n```\n\nTag 命名规则: `helm/{component}/{version}`\n- `helm`: 前缀,表示这是 Helm Chart 发布\n- `{component}`: 组件名称,如 `opensandbox-controller`\n- `{version}`: 版本号,如 `0.1.0`\n\n这将自动触发 workflow:\n1. 解析 tag 获取 component 和 version\n2. 更新对应 Chart.yaml 中的版本号\n3. 打包 Helm Chart\n4. 创建 GitHub Release\n5. 发布 .tgz 包到 Release\n\n#### 方式二: 手动触发\n\n1. 访问 GitHub Actions 页面\n2. 选择 \"Publish Helm Chart\" workflow\n3. 点击 \"Run workflow\"\n4. 选择 component (如: opensandbox-controller)\n5. 输入 chart_version (如: 0.1.0) 和 app_version (如: 0.0.1)\n6. 点击运行\n\n### 发布后的 URL 格式\n\n发布后,用户可以通过以下 URL 访问 Helm Chart:\n\n```\nhttps://github.com/alibaba/OpenSandbox/releases/download/helm/{COMPONENT}/{VERSION}/{COMPONENT}-{VERSION}.tgz\n```\n\n例如:\n```\nhttps://github.com/alibaba/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz\n```\n\n### 添加新的 Helm Chart 组件\n\n如果需要为新组件添加 Helm Chart 发布支持:\n\n1. 在 `charts/` 目录下创建新组件的 chart 目录\n2. 更新 `.github/workflows/publish-helm-chart.yml`:\n   - 在 `workflow_dispatch.inputs.component.options` 中添加新组件\n   - 在 \"Set chart path\" step 中添加组件路径映射\n\n示例:\n```yaml\n# 在 workflow_dispatch inputs 中添加\noptions:\n  - opensandbox-controller\n  - new-component  # 新增\n\n# 在 Set chart path step 中添加\nif [ \"$COMPONENT\" == \"opensandbox-controller\" ]; then\n  CHART_PATH=\"kubernetes/charts/opensandbox-controller\"\nelif [ \"$COMPONENT\" == \"new-component\" ]; then\n  CHART_PATH=\"path/to/new-component/chart\"\nfi\n```\n\n### 本地测试发布流程\n\n在发布前,建议本地测试:\n\n```bash\n# 打包 Chart\nmake helm-package\n\n# 验证打包的 Chart\nhelm lint opensandbox-controller-*.tgz\n\n# 测试安装\nhelm install test-release opensandbox-controller-*.tgz \\\n  --namespace test \\\n  --create-namespace \\\n  --dry-run\n```\n\n## 参考资料\n\n- [Helm Chart README](charts/opensandbox-controller/README.md) - 完整的参数列表\n- [OpenSandbox 文档](README.md) - 项目主文档\n- [配置示例](config/samples/) - 资源配置示例\n"
  },
  {
    "path": "kubernetes/docs/logging.md",
    "content": "# 日志配置说明\n\n## 功能特性\n\nOpenSandbox Kubernetes Controller 支持灵活的日志配置，包括：\n\n- ✅ **日志输出到控制台**（默认启用）\n- ✅ **日志输出到文件**（可选）\n- ✅ **自动日志轮转**（按文件大小）\n- ✅ **自动压缩旧日志**（gzip）\n- ✅ **自动清理过期日志**（按时间或数量）\n- ✅ **支持 zap 所有标准选项**（日志级别、格式等）\n\n## 命令行参数\n\n### 日志文件相关参数\n\n| 参数 | 类型 | 默认值 | 说明 |\n|------|------|--------|------|\n| `--enable-file-log` | bool | false | 是否启用日志输出到文件 |\n| `--log-file-path` | string | `/var/log/sandbox-controller/controller.log` | 日志文件路径 |\n| `--log-max-size` | int | 100 | 日志文件最大大小（MB），超过后自动轮转 |\n| `--log-max-backups` | int | 10 | 保留的旧日志文件最大数量 |\n| `--log-max-age` | int | 30 | 保留旧日志文件的最大天数 |\n| `--log-compress` | bool | true | 是否压缩轮转后的日志文件（gzip） |\n\n### zap 标准参数（继承自 controller-runtime）\n\n| 参数 | 说明 |\n|------|------|\n| `--zap-devel` | 启用开发模式（彩色输出、更详细的堆栈跟踪） |\n| `--zap-encoder` | 日志编码格式：json 或 console |\n| `--zap-log-level` | 日志级别：debug, info, error 等 |\n| `--zap-stacktrace-level` | 打印堆栈跟踪的最低级别 |\n| `--zap-time-encoding` | 时间编码格式：iso8601, millis, nano 等 |\n\n## 使用示例\n\n### 1. 仅输出到控制台（默认）\n\n```bash\n./controller\n```\n\n### 2. 同时输出到控制台和文件\n\n```bash\n./controller \\\n  --enable-file-log=true \\\n  --log-file-path=/var/log/sandbox-controller/controller.log\n```\n\n### 3. 自定义日志轮转配置\n\n```bash\n./controller \\\n  --enable-file-log=true \\\n  --log-file-path=/var/log/sandbox-controller/controller.log \\\n  --log-max-size=50 \\\n  --log-max-backups=5 \\\n  --log-max-age=7 \\\n  --log-compress=true\n```\n\n这将：\n- 每个日志文件最大 50MB\n- 最多保留 5 个旧日志文件\n- 日志文件最多保留 7 天\n- 压缩旧日志文件\n\n### 4. 开发模式 + 文件输出\n\n```bash\n./controller \\\n  --zap-devel=true \\\n  --enable-file-log=true \\\n  --log-file-path=/tmp/controller-dev.log\n```\n\n### 5. JSON 格式 + 文件输出\n\n```bash\n./controller \\\n  --zap-encoder=json \\\n  --enable-file-log=true \\\n  --log-file-path=/var/log/sandbox-controller/controller.log\n```\n\n### 6. 调试级别 + 文件输出\n\n```bash\n./controller \\\n  --zap-log-level=debug \\\n  --enable-file-log=true \\\n  --log-file-path=/var/log/sandbox-controller/debug.log\n```\n\n## Kubernetes 部署配置\n\n在 Kubernetes 中部署时，可以通过 Deployment 的 `args` 配置日志选项：\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: sandbox-controller\nspec:\n  template:\n    spec:\n      containers:\n      - name: controller\n        image: sandbox-controller:latest\n        args:\n        - --enable-file-log=true\n        - --log-file-path=/var/log/controller/controller.log\n        - --log-max-size=100\n        - --log-max-backups=10\n        - --log-max-age=30\n        - --log-compress=true\n        - --zap-encoder=json\n        volumeMounts:\n        - name: log-volume\n          mountPath: /var/log/controller\n      volumes:\n      - name: log-volume\n        emptyDir: {}\n        # 或使用 PersistentVolumeClaim\n        # persistentVolumeClaim:\n        #   claimName: controller-logs\n```\n\n## 日志文件格式\n\n### 开发模式（--zap-devel=true）\n\n```\n2026-02-12T10:30:45.123+0800\tINFO\tsetup\tstarting manager\n2026-02-12T10:30:45.456+0800\tINFO\tcontroller\tReconciling\t{\"namespace\": \"default\", \"name\": \"example\"}\n```\n\n### 生产模式（JSON）\n\n```json\n{\"level\":\"info\",\"ts\":\"2026-02-12T10:30:45.123+0800\",\"logger\":\"setup\",\"msg\":\"starting manager\"}\n{\"level\":\"info\",\"ts\":\"2026-02-12T10:30:45.456+0800\",\"logger\":\"controller\",\"msg\":\"Reconciling\",\"namespace\":\"default\",\"name\":\"example\"}\n```\n\n## 日志轮转机制\n\n日志轮转由 [lumberjack](https://github.com/natefinch/lumberjack) 实现，支持：\n\n1. **按大小轮转**：当日志文件达到 `--log-max-size` 指定的大小时，自动创建新文件\n2. **文件命名**：轮转后的文件名格式为 `controller.log.2026-02-12T10-30-45.123`\n3. **自动压缩**：如果启用 `--log-compress`，旧日志文件会被压缩为 `.gz` 格式\n4. **自动清理**：\n   - 根据 `--log-max-backups` 保留最新的 N 个文件\n   - 根据 `--log-max-age` 删除超过指定天数的文件\n\n## 目录权限\n\n确保日志目录存在且有写入权限：\n\n```bash\n# 创建日志目录\nmkdir -p /var/log/sandbox-controller\n\n# 设置权限（根据实际运行用户调整）\nchown controller:controller /var/log/sandbox-controller\nchmod 755 /var/log/sandbox-controller\n```\n\n在 Kubernetes 中，可以使用 `initContainer` 或 `securityContext` 确保权限正确：\n\n```yaml\nspec:\n  initContainers:\n  - name: setup-log-dir\n    image: busybox\n    command: ['sh', '-c', 'mkdir -p /var/log/controller && chmod 755 /var/log/controller']\n    volumeMounts:\n    - name: log-volume\n      mountPath: /var/log/controller\n  containers:\n  - name: controller\n    securityContext:\n      runAsUser: 1000\n      runAsGroup: 1000\n```\n\n## 监控和查看日志\n\n### 查看当前日志\n\n```bash\ntail -f /var/log/sandbox-controller/controller.log\n```\n\n### 查看压缩的日志\n\n```bash\nzcat /var/log/sandbox-controller/controller.log.2026-02-12T10-30-45.123.gz | less\n```\n\n### 搜索日志\n\n```bash\n# 搜索错误日志\ngrep -i error /var/log/sandbox-controller/controller.log\n\n# 在所有日志文件中搜索（包括压缩文件）\nzgrep -i error /var/log/sandbox-controller/*.log*\n```\n\n## 最佳实践\n\n1. **生产环境建议**：\n   ```bash\n   --enable-file-log=true\n   --log-file-path=/var/log/sandbox-controller/controller.log\n   --log-max-size=100\n   --log-max-backups=10\n   --log-max-age=30\n   --log-compress=true\n   --zap-encoder=json\n   ```\n\n2. **开发环境建议**：\n   ```bash\n   --zap-devel=true\n   --enable-file-log=true\n   --log-file-path=/tmp/controller-dev.log\n   --log-compress=false\n   ```\n\n3. **调试问题时**：\n   ```bash\n   --zap-log-level=debug\n   --enable-file-log=true\n   --log-max-size=500\n   --log-compress=false\n   ```\n\n4. **磁盘空间有限时**：\n   ```bash\n   --enable-file-log=true\n   --log-max-size=50\n   --log-max-backups=3\n   --log-max-age=7\n   --log-compress=true\n   ```\n\n## 故障排查\n\n### 日志文件未创建\n\n1. 检查目录是否存在：`ls -la /var/log/sandbox-controller/`\n2. 检查权限：`ls -ld /var/log/sandbox-controller/`\n3. 检查进程是否有写入权限\n4. 查看 controller 启动日志中是否有错误\n\n### 日志文件不轮转\n\n1. 确认 `--enable-file-log=true` 已设置\n2. 检查文件大小是否达到 `--log-max-size` 限制\n3. 确认 lumberjack 库已正确安装：`go list -m gopkg.in/natefinch/lumberjack.v2`\n\n### 磁盘空间占用过大\n\n1. 减小 `--log-max-size` 的值\n2. 减少 `--log-max-backups` 的数量\n3. 减小 `--log-max-age` 的天数\n4. 确保 `--log-compress=true` 已启用\n"
  },
  {
    "path": "kubernetes/examples/controller/README-ZH.md",
    "content": "# Controller 示例\n\n这个示例演示了如何使用生成的 clientset、informer 和 lister 来操作 BatchSandbox 和 Pool 自定义资源。\n\n## 功能介绍\n\n### 1. Clientset (客户端集)\n用于直接与 Kubernetes API Server 交互,执行 CRUD 操作:\n- **Create**: 创建新的资源\n- **Get**: 获取特定资源\n- **List**: 列出所有资源\n- **Update**: 更新现有资源\n- **Delete**: 删除资源\n\n### 2. Informer (通知器)\n用于监听资源变化并维护本地缓存:\n- 自动监听 API Server 的资源变化\n- 触发事件处理器 (Add/Update/Delete)\n- 维护资源的本地缓存,减少 API Server 压力\n\n### 3. Lister (列表器)\n用于从 Informer 的本地缓存中读取资源:\n- 高性能的本地缓存读取\n- 避免频繁访问 API Server\n- 支持按命名空间和标签过滤\n\n## 运行示例\n\n### 前提条件\n1. 已安装 CRD 定义到 Kubernetes 集群\n2. 有访问集群的 kubeconfig 文件\n\n### 安装 CRD\n```bash\n# 从项目根目录运行\nkubectl apply -f config/crd/bases/\n```\n\n### 运行示例程序\n```bash\n# 使用默认 kubeconfig (~/.kube/config)\ngo run examples/controller/main.go\n\n# 或指定 kubeconfig 路径\ngo run examples/controller/main.go -kubeconfig=/path/to/kubeconfig\n```\n\n## 示例输出\n\n程序将执行以下操作:\n\n1. **创建 Pool 资源**\n   ```\n   Successfully created Pool: example-pool\n   ```\n\n2. **获取 Pool 资源**\n   ```\n   Successfully retrieved Pool: example-pool, PoolMin: 2, PoolMax: 10\n   ```\n\n3. **列出所有 Pool 资源**\n   ```\n   Found 1 Pool(s):\n     - example-pool (PoolMin: 2, PoolMax: 10)\n   ```\n\n4. **更新 Pool 资源**\n   ```\n   Successfully updated Pool: example-pool, new PoolMax: 20\n   ```\n\n5. **创建 BatchSandbox 资源**\n   ```\n   Successfully created BatchSandbox: example-batchsandbox, Replicas: 3\n   ```\n\n6. **获取和更新 BatchSandbox**\n   ```\n   Successfully updated BatchSandbox: example-batchsandbox, new Replicas: 5\n   ```\n\n7. **使用 Lister 从缓存读取**\n   ```\n   Retrieved Pool from cache: example-pool, PoolMax: 20\n   Found 1 BatchSandbox(es) from cache\n   ```\n\n8. **清理资源**\n   ```\n   Successfully deleted BatchSandbox: example-batchsandbox\n   Successfully deleted Pool: example-pool\n   ```\n\n## 代码结构\n\n```\nmain.go\n├── Controller struct          # 控制器结构\n├── NewController()           # 创建控制器并注册事件处理器\n├── DemonstrateClientsetUsage() # 演示 Clientset CRUD 操作\n└── DemonstrateListerUsage()   # 演示 Lister 缓存读取\n```\n\n## 关键概念\n\n### Clientset vs Lister\n\n**何时使用 Clientset:**\n- 需要创建、更新或删除资源\n- 需要获取资源的最新状态\n- 执行写操作\n\n**何时使用 Lister:**\n- 只需要读取资源\n- 可以接受轻微的数据延迟\n- 需要高性能的批量读取\n- 减少 API Server 负载\n\n### Informer 事件处理\n\nInformer 会在资源变化时触发相应的事件处理器:\n```go\nAddFunc: func(obj interface{}) {\n    // 资源被创建时调用\n}\nUpdateFunc: func(old, new interface{}) {\n    // 资源被更新时调用\n}\nDeleteFunc: func(obj interface{}) {\n    // 资源被删除时调用\n}\n```\n\n## 生产环境建议\n\n1. **使用 Lister 而不是频繁调用 Clientset.Get()**\n   - Lister 从本地缓存读取,性能更好\n   - 减少对 API Server 的压力\n\n2. **正确处理 Informer 重新同步**\n   - 设置合理的 resync 周期 (如 30 秒)\n   - 在事件处理器中使用幂等操作\n\n3. **使用 Workqueue 处理事件**\n   - 避免在事件处理器中执行耗时操作\n   - 使用 workqueue 实现重试机制\n\n4. **处理资源版本冲突**\n   - Update 操作时使用 optimistic locking\n   - 捕获 Conflict 错误并重试\n\n## 扩展阅读\n\n- [Kubernetes Client-go 文档](https://github.com/kubernetes/client-go)\n- [编写 Kubernetes 控制器](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/)\n- [Sample Controller](https://github.com/kubernetes/sample-controller)\n"
  },
  {
    "path": "kubernetes/examples/controller/README.md",
    "content": "# Controller Example\n\nThis example demonstrates how to use the generated clientset, informer, and lister to operate BatchSandbox and Pool custom resources.\n\n## Features\n\n### 1. Clientset (Client Set)\nUsed to interact directly with the Kubernetes API Server for CRUD operations:\n- **Create**: Create new resources\n- **Get**: Retrieve specific resources\n- **List**: List all resources\n- **Update**: Update existing resources\n- **Delete**: Delete resources\n\n### 2. Informer (Informer)\nUsed to watch resource changes and maintain local cache:\n- Automatically watches resource changes from the API Server\n- Triggers event handlers (Add/Update/Delete)\n- Maintains a local cache of resources to reduce API Server load\n\n### 3. Lister (Lister)\nUsed to read resources from the Informer's local cache:\n- High-performance local cache reads\n- Avoids frequent API Server access\n- Supports filtering by namespace and labels\n\n## Running the Example\n\n### Prerequisites\n1. CRDs are installed in the Kubernetes cluster\n2. Have a kubeconfig file to access the cluster\n\n### Install CRDs\n```bash\n# Run from project root directory\nkubectl apply -f config/crd/bases/\n```\n\n### Run the Example Program\n```bash\n# Use default kubeconfig (~/.kube/config)\ngo run examples/controller/main.go\n\n# Or specify kubeconfig path\ngo run examples/controller/main.go -kubeconfig=/path/to/kubeconfig\n```\n\n## Example Output\n\nThe program will perform the following operations:\n\n1. **Create Pool resource**\n   ```\n   Successfully created Pool: example-pool\n   ```\n\n2. **Get Pool resource**\n   ```\n   Successfully retrieved Pool: example-pool, PoolMin: 2, PoolMax: 10\n   ```\n\n3. **List all Pool resources**\n   ```\n   Found 1 Pool(s):\n     - example-pool (PoolMin: 2, PoolMax: 10)\n   ```\n\n4. **Update Pool resource**\n   ```\n   Successfully updated Pool: example-pool, new PoolMax: 20\n   ```\n\n5. **Create BatchSandbox resource**\n   ```\n   Successfully created BatchSandbox: example-batchsandbox, Replicas: 3\n   ```\n\n6. **Get and update BatchSandbox**\n   ```\n   Successfully updated BatchSandbox: example-batchsandbox, new Replicas: 5\n   ```\n\n7. **Use Lister to read from cache**\n   ```\n   Retrieved Pool from cache: example-pool, PoolMax: 20\n   Found 1 BatchSandbox(es) from cache\n   ```\n\n8. **Cleanup resources**\n   ```\n   Successfully deleted BatchSandbox: example-batchsandbox\n   Successfully deleted Pool: example-pool\n   ```\n\n## Code Structure\n\n```\nmain.go\n├── Controller struct          # Controller structure\n├── NewController()           # Create controller and register event handlers\n├── DemonstrateClientsetUsage() # Demonstrate Clientset CRUD operations\n└── DemonstrateListerUsage()   # Demonstrate Lister cache reads\n```\n\n## Key Concepts\n\n### Clientset vs Lister\n\n**When to use Clientset:**\n- Need to create, update, or delete resources\n- Need to get the latest state of resources\n- Performing write operations\n\n**When to use Lister:**\n- Only need to read resources\n- Can tolerate slight data staleness\n- Need high-performance batch reads\n- Want to reduce API Server load\n\n### Informer Event Handling\n\nInformer triggers corresponding event handlers when resources change:\n```go\nAddFunc: func(obj interface{}) {\n    // Called when resource is created\n}\nUpdateFunc: func(old, new interface{}) {\n    // Called when resource is updated\n}\nDeleteFunc: func(obj interface{}) {\n    // Called when resource is deleted\n}\n```\n\n## Production Recommendations\n\n1. **Use Lister instead of frequent Clientset.Get() calls**\n   - Lister reads from local cache with better performance\n   - Reduces pressure on the API Server\n\n2. **Properly handle Informer resync**\n   - Set a reasonable resync period (e.g., 30 seconds)\n   - Use idempotent operations in event handlers\n\n3. **Use Workqueue to process events**\n   - Avoid time-consuming operations in event handlers\n   - Use workqueue to implement retry mechanisms\n\n4. **Handle resource version conflicts**\n   - Use optimistic locking during Update operations\n   - Catch Conflict errors and retry\n\n## Further Reading\n\n- [Kubernetes Client-go Documentation](https://github.com/kubernetes/client-go)\n- [Writing Kubernetes Controllers](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/)\n- [Sample Controller](https://github.com/kubernetes/sample-controller)\n"
  },
  {
    "path": "kubernetes/examples/controller/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\t\"k8s.io/client-go/tools/cache\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\t\"k8s.io/client-go/util/workqueue\"\n\t\"k8s.io/klog/v2\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tclientset \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tinformers \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions\"\n\tlisters \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1\"\n)\n\n// Controller demonstrates how to use the generated clientset, informer, and lister\ntype Controller struct {\n\t// clientset is used to directly manipulate API objects\n\tclientset clientset.Interface\n\n\t// listers are used to read objects from local cache, avoiding frequent API Server access\n\tbatchSandboxLister listers.BatchSandboxLister\n\tpoolLister         listers.PoolLister\n\n\t// informer cache is used to check if objects are synced\n\tbatchSandboxSynced cache.InformerSynced\n\tpoolSynced         cache.InformerSynced\n\n\t// workqueue is used to process events\n\tworkqueue workqueue.RateLimitingInterface\n}\n\nfunc NewController(\n\tclientset clientset.Interface,\n\tinformerFactory informers.SharedInformerFactory,\n) *Controller {\n\t// Get BatchSandbox and Pool informers\n\tbatchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes()\n\tpoolInformer := informerFactory.Sandbox().V1alpha1().Pools()\n\n\tcontroller := &Controller{\n\t\tclientset:          clientset,\n\t\tbatchSandboxLister: batchSandboxInformer.Lister(),\n\t\tpoolLister:         poolInformer.Lister(),\n\t\tbatchSandboxSynced: batchSandboxInformer.Informer().HasSynced,\n\t\tpoolSynced:         poolInformer.Informer().HasSynced,\n\t\tworkqueue:          workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), \"Example\"),\n\t}\n\n\t// Register event handlers\n\tbatchSandboxInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{\n\t\tAddFunc: func(obj interface{}) {\n\t\t\tbs := obj.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tklog.Infof(\"BatchSandbox added: %s/%s\", bs.Namespace, bs.Name)\n\t\t},\n\t\tUpdateFunc: func(old, new interface{}) {\n\t\t\tbs := new.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tklog.Infof(\"BatchSandbox updated: %s/%s\", bs.Namespace, bs.Name)\n\t\t},\n\t\tDeleteFunc: func(obj interface{}) {\n\t\t\tbs := obj.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tklog.Infof(\"BatchSandbox deleted: %s/%s\", bs.Namespace, bs.Name)\n\t\t},\n\t})\n\n\tpoolInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{\n\t\tAddFunc: func(obj interface{}) {\n\t\t\tpool := obj.(*sandboxv1alpha1.Pool)\n\t\t\tklog.Infof(\"Pool added: %s/%s\", pool.Namespace, pool.Name)\n\t\t},\n\t\tUpdateFunc: func(old, new interface{}) {\n\t\t\tpool := new.(*sandboxv1alpha1.Pool)\n\t\t\tklog.Infof(\"Pool updated: %s/%s\", pool.Namespace, pool.Name)\n\t\t},\n\t\tDeleteFunc: func(obj interface{}) {\n\t\t\tpool := obj.(*sandboxv1alpha1.Pool)\n\t\t\tklog.Infof(\"Pool deleted: %s/%s\", pool.Namespace, pool.Name)\n\t\t},\n\t})\n\n\treturn controller\n}\n\nfunc (c *Controller) Run(ctx context.Context, workers int) error {\n\tdefer c.workqueue.ShutDown()\n\n\tklog.Info(\"Waiting for cache sync...\")\n\tif ok := cache.WaitForCacheSync(ctx.Done(), c.batchSandboxSynced, c.poolSynced); !ok {\n\t\treturn fmt.Errorf(\"failed to sync cache\")\n\t}\n\n\tklog.Info(\"Cache synced, starting controller\")\n\n\t// Start worker goroutines\n\tfor i := 0; i < workers; i++ {\n\t\tgo wait.UntilWithContext(ctx, c.runWorker, time.Second)\n\t}\n\n\t<-ctx.Done()\n\tklog.Info(\"Stopping controller\")\n\treturn nil\n}\n\nfunc (c *Controller) runWorker(ctx context.Context) {\n\tfor c.processNextWorkItem(ctx) {\n\t}\n}\n\nfunc (c *Controller) processNextWorkItem(ctx context.Context) bool {\n\tobj, shutdown := c.workqueue.Get()\n\tif shutdown {\n\t\treturn false\n\t}\n\n\tdefer c.workqueue.Done(obj)\n\t// Process actual business logic here\n\treturn true\n}\n\n// DemonstrateClientsetUsage demonstrates how to use clientset for CRUD operations\nfunc DemonstrateClientsetUsage(ctx context.Context, client clientset.Interface) {\n\tnamespace := \"default\"\n\n\tklog.Info(\"========================================\")\n\tklog.Info(\"Demonstrating Clientset Usage\")\n\tklog.Info(\"========================================\")\n\n\t// 1. Create Pool\n\tklog.Info(\"\\n1. Creating Pool resource\")\n\tpool := &sandboxv1alpha1.Pool{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"example-pool\",\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\tTemplate: &corev1.PodTemplateSpec{\n\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\tContainers: []corev1.Container{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"nginx\",\n\t\t\t\t\t\t\tImage: \"nginx:latest\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\tPoolMin:   2,\n\t\t\t\tPoolMax:   10,\n\t\t\t\tBufferMin: 1,\n\t\t\t\tBufferMax: 5,\n\t\t\t},\n\t\t},\n\t}\n\n\tcreatedPool, err := client.SandboxV1alpha1().Pools(namespace).Create(ctx, pool, metav1.CreateOptions{})\n\tif err != nil {\n\t\tif errors.IsAlreadyExists(err) {\n\t\t\tklog.Infof(\"Pool already exists: %s\", pool.Name)\n\t\t} else {\n\t\t\tklog.Errorf(\"Failed to create Pool: %v\", err)\n\t\t}\n\t} else {\n\t\tklog.Infof(\"Successfully created Pool: %s\", createdPool.Name)\n\t}\n\n\t// 2. Get Pool\n\tklog.Info(\"\\n2. Getting Pool resource\")\n\tgetPool, err := client.SandboxV1alpha1().Pools(namespace).Get(ctx, \"example-pool\", metav1.GetOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to get Pool: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Successfully retrieved Pool: %s, PoolMin: %d, PoolMax: %d\",\n\t\t\tgetPool.Name, getPool.Spec.CapacitySpec.PoolMin, getPool.Spec.CapacitySpec.PoolMax)\n\t}\n\n\t// 3. List all Pools\n\tklog.Info(\"\\n3. Listing all Pool resources\")\n\tpoolList, err := client.SandboxV1alpha1().Pools(namespace).List(ctx, metav1.ListOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to list Pools: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Found %d Pool(s):\", len(poolList.Items))\n\t\tfor _, p := range poolList.Items {\n\t\t\tklog.Infof(\"  - %s (PoolMin: %d, PoolMax: %d)\",\n\t\t\t\tp.Name, p.Spec.CapacitySpec.PoolMin, p.Spec.CapacitySpec.PoolMax)\n\t\t}\n\t}\n\n\t// 4. Update Pool\n\tklog.Info(\"\\n4. Updating Pool resource\")\n\tif getPool != nil {\n\t\tgetPool.Spec.CapacitySpec.PoolMax = 20\n\t\tupdatedPool, err := client.SandboxV1alpha1().Pools(namespace).Update(ctx, getPool, metav1.UpdateOptions{})\n\t\tif err != nil {\n\t\t\tklog.Errorf(\"Failed to update Pool: %v\", err)\n\t\t} else {\n\t\t\tklog.Infof(\"Successfully updated Pool: %s, new PoolMax: %d\", updatedPool.Name, updatedPool.Spec.CapacitySpec.PoolMax)\n\t\t}\n\t}\n\n\t// 5. Create BatchSandbox\n\tklog.Info(\"\\n5. Creating BatchSandbox resource\")\n\treplicas := int32(3)\n\tbatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"example-batchsandbox\",\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\tReplicas: &replicas,\n\t\t\tPoolRef:  \"example-pool\",\n\t\t},\n\t}\n\n\tcreatedBS, err := client.SandboxV1alpha1().BatchSandboxes(namespace).Create(ctx, batchSandbox, metav1.CreateOptions{})\n\tif err != nil {\n\t\tif errors.IsAlreadyExists(err) {\n\t\t\tklog.Infof(\"BatchSandbox already exists: %s\", batchSandbox.Name)\n\t\t} else {\n\t\t\tklog.Errorf(\"Failed to create BatchSandbox: %v\", err)\n\t\t}\n\t} else {\n\t\tklog.Infof(\"Successfully created BatchSandbox: %s, Replicas: %d\", createdBS.Name, *createdBS.Spec.Replicas)\n\t}\n\n\t// 6. Get BatchSandbox\n\tklog.Info(\"\\n6. Getting BatchSandbox resource\")\n\tgetBS, err := client.SandboxV1alpha1().BatchSandboxes(namespace).Get(ctx, \"example-batchsandbox\", metav1.GetOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to get BatchSandbox: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Successfully retrieved BatchSandbox: %s, Replicas: %d, PoolRef: %s\",\n\t\t\tgetBS.Name, *getBS.Spec.Replicas, getBS.Spec.PoolRef)\n\t}\n\n\t// 7. Update BatchSandbox\n\tklog.Info(\"\\n7. Updating BatchSandbox resource\")\n\tif getBS != nil {\n\t\tnewReplicas := int32(5)\n\t\tgetBS.Spec.Replicas = &newReplicas\n\t\tupdatedBS, err := client.SandboxV1alpha1().BatchSandboxes(namespace).Update(ctx, getBS, metav1.UpdateOptions{})\n\t\tif err != nil {\n\t\t\tklog.Errorf(\"Failed to update BatchSandbox: %v\", err)\n\t\t} else {\n\t\t\tklog.Infof(\"Successfully updated BatchSandbox: %s, new Replicas: %d\", updatedBS.Name, *updatedBS.Spec.Replicas)\n\t\t}\n\t}\n\n\t// 8. List all BatchSandboxes\n\tklog.Info(\"\\n8. Listing all BatchSandbox resources\")\n\tbsList, err := client.SandboxV1alpha1().BatchSandboxes(namespace).List(ctx, metav1.ListOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to list BatchSandboxes: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Found %d BatchSandbox(es):\", len(bsList.Items))\n\t\tfor _, bs := range bsList.Items {\n\t\t\tklog.Infof(\"  - %s (Replicas: %d, PoolRef: %s)\",\n\t\t\t\tbs.Name, *bs.Spec.Replicas, bs.Spec.PoolRef)\n\t\t}\n\t}\n\n\t// Wait for informer to process events\n\tklog.Info(\"\\nWaiting 3 seconds for informer to process events...\")\n\ttime.Sleep(3 * time.Second)\n\n\t// 9. Delete BatchSandbox\n\tklog.Info(\"\\n9. Deleting BatchSandbox resource\")\n\terr = client.SandboxV1alpha1().BatchSandboxes(namespace).Delete(ctx, \"example-batchsandbox\", metav1.DeleteOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to delete BatchSandbox: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Successfully deleted BatchSandbox: example-batchsandbox\")\n\t}\n\n\t// 10. Delete Pool\n\tklog.Info(\"\\n10. Deleting Pool resource\")\n\terr = client.SandboxV1alpha1().Pools(namespace).Delete(ctx, \"example-pool\", metav1.DeleteOptions{})\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to delete Pool: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Successfully deleted Pool: example-pool\")\n\t}\n}\n\n// DemonstrateListerUsage demonstrates how to use lister to read objects from cache\nfunc DemonstrateListerUsage(\n\tbatchSandboxLister listers.BatchSandboxLister,\n\tpoolLister listers.PoolLister,\n) {\n\tklog.Info(\"\\n========================================\")\n\tklog.Info(\"Demonstrating Lister Usage (reading from local cache)\")\n\tklog.Info(\"========================================\")\n\n\tnamespace := \"default\"\n\n\t// 1. Use lister to get a specific Pool\n\tklog.Info(\"\\n1. Using Lister to get Pool\")\n\tpool, err := poolLister.Pools(namespace).Get(\"example-pool\")\n\tif err != nil {\n\t\tif errors.IsNotFound(err) {\n\t\t\tklog.Info(\"Pool not found (may have been deleted)\")\n\t\t} else {\n\t\t\tklog.Errorf(\"Lister failed to get Pool: %v\", err)\n\t\t}\n\t} else {\n\t\tklog.Infof(\"Retrieved Pool from cache: %s, PoolMax: %d\", pool.Name, pool.Spec.CapacitySpec.PoolMax)\n\t}\n\n\t// 2. Use lister to list all Pools\n\tklog.Info(\"\\n2. Using Lister to list all Pools\")\n\tpools, err := poolLister.Pools(namespace).List(labels.Everything())\n\tif err != nil {\n\t\tklog.Errorf(\"Lister failed to list Pools: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Found %d Pool(s) from cache:\", len(pools))\n\t\tfor _, p := range pools {\n\t\t\tklog.Infof(\"  - %s\", p.Name)\n\t\t}\n\t}\n\n\t// 3. Use lister to get a specific BatchSandbox\n\tklog.Info(\"\\n3. Using Lister to get BatchSandbox\")\n\tbs, err := batchSandboxLister.BatchSandboxes(namespace).Get(\"example-batchsandbox\")\n\tif err != nil {\n\t\tif errors.IsNotFound(err) {\n\t\t\tklog.Info(\"BatchSandbox not found (may have been deleted)\")\n\t\t} else {\n\t\t\tklog.Errorf(\"Lister failed to get BatchSandbox: %v\", err)\n\t\t}\n\t} else {\n\t\tklog.Infof(\"Retrieved BatchSandbox from cache: %s, Replicas: %d\", bs.Name, *bs.Spec.Replicas)\n\t}\n\n\t// 4. Use lister to list all BatchSandboxes\n\tklog.Info(\"\\n4. Using Lister to list all BatchSandboxes\")\n\tbatchSandboxes, err := batchSandboxLister.BatchSandboxes(namespace).List(labels.Everything())\n\tif err != nil {\n\t\tklog.Errorf(\"Lister failed to list BatchSandboxes: %v\", err)\n\t} else {\n\t\tklog.Infof(\"Found %d BatchSandbox(es) from cache:\", len(batchSandboxes))\n\t\tfor _, bs := range batchSandboxes {\n\t\t\tklog.Infof(\"  - %s (Replicas: %d)\", bs.Name, *bs.Spec.Replicas)\n\t\t}\n\t}\n}\n\nfunc main() {\n\tvar kubeconfig string\n\tflag.StringVar(&kubeconfig, \"kubeconfig\", \"\", \"Path to a kubeconfig file\")\n\tflag.Parse()\n\n\t// Build configuration\n\tconfig, err := clientcmd.BuildConfigFromFlags(\"\", kubeconfig)\n\tif err != nil {\n\t\tklog.Fatalf(\"Failed to build config: %v\", err)\n\t}\n\n\t// Create clientset\n\tclient, err := clientset.NewForConfig(config)\n\tif err != nil {\n\t\tklog.Fatalf(\"Failed to create clientset: %v\", err)\n\t}\n\n\t// Create informer factory\n\tinformerFactory := informers.NewSharedInformerFactory(client, time.Second*30)\n\n\t// Create controller\n\tcontroller := NewController(client, informerFactory)\n\n\t// Start informers\n\tctx := context.Background()\n\tinformerFactory.Start(ctx.Done())\n\n\t// Wait for cache sync\n\tklog.Info(\"Waiting for informer cache sync...\")\n\tif ok := cache.WaitForCacheSync(ctx.Done(), controller.batchSandboxSynced, controller.poolSynced); !ok {\n\t\tklog.Fatal(\"Failed to sync cache\")\n\t}\n\tklog.Info(\"Informer cache synced successfully\")\n\n\t// Demonstrate clientset usage\n\tDemonstrateClientsetUsage(ctx, client)\n\n\t// Demonstrate lister usage\n\tDemonstrateListerUsage(controller.batchSandboxLister, controller.poolLister)\n\n\tklog.Info(\"\\n========================================\")\n\tklog.Info(\"Demonstration completed!\")\n\tklog.Info(\"========================================\")\n}\n"
  },
  {
    "path": "kubernetes/examples/task-executor/README.md",
    "content": "# Task Executor Usage Guide\n\n## Introduction\n\nThe `task-executor` is a lightweight component designed to run and manage short-lived tasks (processes or containers) within a Kubernetes Pod context. It acts as a local agent, receiving task specifications from a Kubernetes Controller (e.g., `BatchSandboxController`) and executing them on the node where it runs. It exposes a simple HTTP API for task creation, status inquiry, and management.\n\n## Running the Task Executor\n\nThe `task-executor` can be started using the `cmd/task-executor/main.go` entry point. It supports various command-line flags and environment variables for configuration.\n\n**Basic Startup:**\n\n```bash\n/path/to/cmd/task-executor/main --data-dir=/var/lib/sandbox/tasks --listen-addr=0.0.0.0:5758\n```\n\n**Key Configuration Parameters:**\n\n| Flag / Environment Variable | Description                                                                                                                                                                                                                                                                                              | Default Value                 |\n| :-------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------- |\n| `--data-dir` (DATA_DIR)     | Directory for persisting task state and logs.                                                                                                                                                                                                                                                            | `/var/lib/sandbox/tasks`      |\n| `--listen-addr` (LISTEN_ADDR)| Address and port for the HTTP API server.                                                                                                                                                                                                                                                                | `0.0.0.0:5758`                |\n| `--enable-sidecar-mode` (ENABLE_SIDECAR_MODE) | If `true`, enables sidecar mode execution, where tasks are run within the PID namespace of a specified main container. Requires `nsenter` and appropriate privileges.                                                                                                                                                            | `false`                       |\n| `--main-container-name` (MAIN_CONTAINER_NAME)| When `enable-sidecar-mode` is `true`, specifies the name of the main container whose PID namespace should be used.                                                                                                                                                                       | `main`                        |\n| `--enable-container-mode` (ENABLE_CONTAINER_MODE) | If `true`, enables container mode execution using the CRI runtime. (Note: Current implementation may be a placeholder).                                                                                                                                                                | `false`                       |\n| `--cri-socket` (CRI_SOCKET) | Path to the CRI socket (e.g., `containerd.sock`) when `enable-container-mode` is `true`.                                                                                                                                                                                                                | `/var/run/containerd/containerd.sock` |\n| `--reconcile-interval`      | The interval at which the internal task manager reconciles task states.                                                                                                                                                                                                                                  | `500ms`                       |\n\n## HTTP API Endpoints\n\nThe `task-executor` exposes a RESTful HTTP API. All API calls expect JSON request bodies (where applicable) and return JSON responses.\n\n### 1. `POST /tasks` - Create a new task\n\nCreates and starts a single task.\n\n*   **Method:** `POST`\n*   **Path:** `/tasks`\n*   **Request Body (application/json):** An object representing the desired task.\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      }\n    }\n    ```\n\n*   **Response Body (application/json):** The created task object with its initial status.\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      },\n      \"status\": {\n        \"state\": {\n          \"waiting\": {\n            \"reason\": \"Initialized\"\n          }\n        }\n      }\n    }\n    ```\n\n**Example (using `curl`):**\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" -d '{\n  \"name\": \"my-first-task\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"sh\", \"-c\"],\n      \"args\": [\"echo \\\"Hello from my task!\\\" && sleep 5 && echo \\\"Task finished.\\\"\"]\n    }\n  }\n}' http://localhost:5758/tasks\n```\n\n### 2. `GET /tasks/{id}` - Get task status\n\nRetrieves the current status of a specific task by its name.\n\n*   **Method:** `GET`\n*   **Path:** `/tasks/{taskName}`\n*   **Response Body (application/json):** The task object, including its current status.\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      },\n      \"status\": {\n        \"state\": {\n          \"running\": {\n            \"startedAt\": \"2025-12-17T10:00:00Z\"\n          }\n        }\n      }\n    }\n    ```\n\n**Example (using `curl`):**\n\n```bash\ncurl http://localhost:5758/tasks/my-first-task\n```\n\n### 3. `DELETE /tasks/{id}` - Delete a task\n\nMarks a task for deletion. The `task-executor` will attempt to gracefully stop the task and then remove its state.\n\n*   **Method:** `DELETE`\n*   **Path:** `/tasks/{taskName}`\n*   **Response:** `204 No Content` on successful marking for deletion.\n\n**Example (using `curl`):**\n\n```bash\ncurl -X DELETE http://localhost:5758/tasks/my-first-task\n```\n\n### 4. `POST /setTasks` - Synchronize tasks\n\nThis endpoint is typically used by controllers to synchronize a desired set of tasks. Tasks not present in the desired list will be marked for deletion; new tasks will be created.\n\n*   **Method:** `POST`\n*   **Path:** `/setTasks`\n*   **Request Body (application/json):** An array of task objects representing the desired state.\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        }\n      }\n    ]\n    ```\n\n*   **Response Body (application/json):** The current list of tasks managed by the executor after synchronization.\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"waiting\": {\n              \"reason\": \"Initialized\"\n            }\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"waiting\": {\n              \"reason\": \"Initialized\"\n            }\n          }\n        }\n      }\n    ]\n    ```\n\n**Example (using `curl`):**\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" -d \\\n'[\n  {\n    \"name\": \"task-alpha\",\n    \"spec\": { \"process\": { \"command\": [\"sleep\", \"10\"] } }\n  },\n  {\n    \"name\": \"task-beta\",\n    \"spec\": { \"process\": { \"command\": [\"ls\", \"-l\", \"/tmp\"] } }\n  }\n]' http://localhost:5758/setTasks\n```\n\n### 5. `GET /getTasks` - List all tasks\n\nRetrieves a list of all tasks currently managed by the `task-executor`.\n\n*   **Method:** `GET`\n*   **Path:** `/getTasks`\n*   **Response Body (application/json):** An array of task objects.\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"running\": {\n              \"startedAt\": \"2025-12-17T10:05:00Z\"\n            }\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"terminated\": {\n              \"exitCode\": 0,\n              \"reason\": \"Succeeded\",\n              \"startedAt\": \"2025-12-17T10:06:00Z\",\n              \"finishedAt\": \"2025-12-17T10:06:01Z\"\n            }\n          }\n        }\n      }\n    ]\n    ```\n\n**Example (using `curl`):**\n\n```bash\ncurl http://localhost:5758/getTasks\n```\n\n### 6. `GET /health` - Health check\n\nReturns the health status of the `task-executor`.\n\n*   **Method:** `GET`\n*   **Path:** `/health`\n*   **Response Body (application/json):**\n\n    ```json\n    {\n      \"status\": \"healthy\"\n    }\n    ```\n\n**Example (using `curl`):**\n\n```bash\ncurl http://localhost:5758/health\n```\n\n## Task Specification (`TaskSpec`) Structure\n\nThe `spec` field within a task object (`api/v1alpha1.TaskSpec`) defines how the task should be executed. It currently supports `process` and `container` execution modes.\n\n### Process Task Example\n\nThis mode executes a command directly as a process.\n\n```json\n{\n  \"name\": \"my-process-task\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"python3\", \"my_script.py\"],\n      \"args\": [\"--config\", \"/etc/app/config.yaml\"],\n      \"env\": [\n        { \"name\": \"DEBUG_MODE\", \"value\": \"true\" }\n      ],\n      \"workingDir\": \"/app\"\n    }\n  }\n}\n```\n\n### Container Task Example (Placeholder/Future Feature)\n\nThis mode is intended for executing tasks within containers managed by the CRI runtime. Note that as per `internal/task-executor/runtime/container.go`, this mode might still be a placeholder.\n\n```json\n{\n  \"name\": \"my-container-task\",\n  \"spec\": {\n    \"container\": {\n      \"image\": \"ubuntu:latest\",\n      \"command\": [\"/bin/bash\", \"-c\"],\n      \"args\": [\"apt update && apt install -y curl\"],\n      \"env\": [\n        { \"name\": \"http_proxy\", \"value\": \"http://myproxy.com:5758\" }\n      ],\n      \"volumeMounts\": [\n        {\n          \"name\": \"data-volume\",\n          \"mountPath\": \"/data\"\n        }\n      ]\n    }\n  }\n}\n```\n\n## Task Status (`TaskStatus`) Structure\n\nThe `status` field within a task object (`internal/task-executor/types/Status` mapped to `api/v1alpha1.TaskStatus` for external API) provides details about the task's current execution state.\n\n```json\n{\n  \"name\": \"my-task\",\n  \"spec\": { ... },\n  \"status\": {\n    \"state\": {\n      \"waiting\": {\n        \"reason\": \"Initialized\"\n      }\n    },\n    // or\n    \"state\": {\n      \"running\": {\n        \"startedAt\": \"2025-12-17T10:00:00Z\"\n      }\n    },\n    // or\n    \"state\": {\n      \"terminated\": {\n        \"exitCode\": 0,\n        \"reason\": \"Succeeded\",\n        \"message\": \"Task completed successfully\",\n        \"startedAt\": \"2025-12-17T10:00:00Z\",\n        \"finishedAt\": \"2025-12-17T10:00:05Z\"\n      }\n    }\n  }\n}\n```\n\n**State Types:**\n\n*   `waiting`: Task is pending execution.\n*   `running`: Task is currently executing.\n*   `terminated`: Task has finished (succeeded or failed).\n\n## Example Scenario: Running a Sidecar Task\n\nIf `task-executor` is configured with `--enable-sidecar-mode=true` and `--main-container-name=my-main-app`, it can execute tasks within the PID namespace of `my-main-app`.\n\n```bash\n# Assume task-executor is running in sidecar mode on a pod with 'my-main-app'\n# This task will execute 'ls /proc/self/ns' from within the main container's namespace\ncurl -X POST -H \"Content-Type: application/json\" -d '{\n  \"name\": \"sidecar-namespace-check\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"ls\", \"/proc/self/ns\"]\n    }\n  }\n}' http://localhost:5758/tasks\n```\n\n"
  },
  {
    "path": "kubernetes/examples/task-executor/README_zh-CN.md",
    "content": "# Task Executor 使用指南\n\n## 简介\n\n`task-executor` 是一个轻量级组件，旨在 Kubernetes Pod 环境中运行和管理短期任务（进程或容器）。它充当本地代理，从 Kubernetes 控制器（例如 `BatchSandboxController`）接收任务规范，并在其运行的节点上执行这些任务。它暴露了一个简单的 HTTP API 用于任务创建、状态查询和管理。\n\n## 运行 Task Executor\n\n可以使用 `cmd/task-executor/main.go` 入口点启动 `task-executor`。它支持各种命令行标志和环境变量进行配置。\n\n**基本启动：**\n\n```bash\n/path/to/cmd/task-executor/main --data-dir=/var/lib/sandbox/tasks --listen-addr=0.0.0.0:5758\n```\n\n**关键配置参数：**\n\n| 标志 / 环境变量 | 描述 | 默认值 |\n| :--- | :--- | :--- |\n| `--data-dir` (DATA_DIR) | 用于持久化任务状态和日志的目录。 | `/var/lib/sandbox/tasks` |\n| `--listen-addr` (LISTEN_ADDR) | HTTP API 服务器的地址和端口。 | `0.0.0.0:5758` |\n| `--enable-sidecar-mode` (ENABLE_SIDECAR_MODE) | 如果为 `true`，则启用 sidecar 模式执行，任务将在指定主容器的 PID 命名空间内运行。需要 `nsenter` 和适当的权限。 | `false` |\n| `--main-container-name` (MAIN_CONTAINER_NAME) | 当 `enable-sidecar-mode` 为 `true` 时，指定应使用其 PID 命名空间的主容器的名称。 | `main` |\n| `--enable-container-mode` (ENABLE_CONTAINER_MODE) | 如果为 `true`，则启用使用 CRI 运行时的容器模式执行。（注意：当前实现可能只是占位符）。 | `false` |\n| `--cri-socket` (CRI_SOCKET) | 当 `enable-container-mode` 为 `true` 时，CRI 套接字的路径（例如 `containerd.sock`）。 | `/var/run/containerd/containerd.sock` |\n| `--reconcile-interval` | 内部任务管理器协调任务状态的间隔。 | `500ms` |\n\n## HTTP API 端点\n\n`task-executor` 暴露了一个 RESTful HTTP API。所有 API 调用都期望 JSON 请求体（如适用）并返回 JSON 响应。\n\n### 1. `POST /tasks` - 创建新任务\n\n创建并启动单个任务。\n\n*   **方法：** `POST`\n*   **路径：** `/tasks`\n*   **请求体 (application/json)：** 代表所需任务的对象。\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      }\n    }\n    ```\n\n*   **响应体 (application/json)：** 创建的任务对象及其初始状态。\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      },\n      \"status\": {\n        \"state\": {\n          \"waiting\": {\n            \"reason\": \"Initialized\"\n          }\n        }\n      }\n    }\n    ```\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" -d \n'{\n  \"name\": \"my-first-task\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"sh\", \"-c\"],\n      \"args\": [\"echo \\\"Hello from my task!\\\" && sleep 5 && echo \\\"Task finished.\\\"\"]\n    }\n  }\n}' http://localhost:5758/tasks\n```\n\n### 2. `GET /tasks/{id}` - 获取任务状态\n\n通过名称检索特定任务的当前状态。\n\n*   **方法：** `GET`\n*   **路径：** `/tasks/{taskName}`\n*   **响应体 (application/json)：** 任务对象，包括其当前状态。\n\n    ```json\n    {\n      \"name\": \"my-first-task\",\n      \"spec\": {\n        \"process\": {\n          \"command\": [\"sh\", \"-c\"],\n          \"args\": [\"echo 'Hello from my task!' && sleep 5 && echo 'Task finished.'\"]\n        }\n      },\n      \"status\": {\n        \"state\": {\n          \"running\": {\n            \"startedAt\": \"2025-12-17T10:00:00Z\"\n          }\n        }\n      }\n    }\n    ```\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl http://localhost:5758/tasks/my-first-task\n```\n\n### 3. `DELETE /tasks/{id}` - 删除任务\n\n标记要删除的任务。`task-executor` 将尝试优雅地停止任务，然后删除其状态。\n\n*   **方法：** `DELETE`\n*   **路径：** `/tasks/{taskName}`\n*   **响应：** 成功标记删除时返回 `204 No Content`。\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl -X DELETE http://localhost:5758/tasks/my-first-task\n```\n\n### 4. `POST /setTasks` - 同步任务\n\n此端点通常由控制器用于同步所需的任务集。不在所需列表中的任务将被标记为删除；新任务将被创建。\n\n*   **方法：** `POST`\n*   **路径：** `/setTasks`\n*   **请求体 (application/json)：** 代表所需状态的任务对象数组。\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        }\n      }\n    ]\n    ```\n\n*   **响应体 (application/json)：** 同步后执行器管理的当前任务列表。\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"waiting\": {\n              \"reason\": \"Initialized\"\n            }\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"waiting\": {\n              \"reason\": \"Initialized\"\n            }\n          }\n        }\n      }\n    ]\n    ```\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl -X POST -H \"Content-Type: application/json\" -d \\\n'[\n  {\n    \"name\": \"task-alpha\",\n    \"spec\": { \"process\": { \"command\": [\"sleep\", \"10\"] } }\n  },\n  {\n    \"name\": \"task-beta\",\n    \"spec\": { \"process\": { \"command\": [\"ls\", \"-l\", \"/tmp\"] } }\n  }\n]' http://localhost:5758/setTasks\n```\n\n### 5. `GET /getTasks` - 列出所有任务\n\n检索 `task-executor` 当前管理的所有任务的列表。\n\n*   **方法：** `GET`\n*   **路径：** `/getTasks`\n*   **响应体 (application/json)：** 任务对象数组。\n\n    ```json\n    [\n      {\n        \"name\": \"task-alpha\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"sleep\", \"10\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"running\": {\n              \"startedAt\": \"2025-12-17T10:05:00Z\"\n            }\n          }\n        }\n      },\n      {\n        \"name\": \"task-beta\",\n        \"spec\": {\n          \"process\": {\n            \"command\": [\"ls\", \"-l\", \"/tmp\"]\n          }\n        },\n        \"status\": {\n          \"state\": {\n            \"terminated\": {\n              \"exitCode\": 0,\n              \"reason\": \"Succeeded\",\n              \"startedAt\": \"2025-12-17T10:06:00Z\",\n              \"finishedAt\": \"2025-12-17T10:06:01Z\"\n            }\n          }\n        }\n      }\n    ]\n    ```\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl http://localhost:5758/getTasks\n```\n\n### 6. `GET /health` - 健康检查\n\n返回 `task-executor` 的健康状态。\n\n*   **方法：** `GET`\n*   **路径：** `/health`\n*   **响应体 (application/json)：**\n\n    ```json\n    {\n      \"status\": \"healthy\"\n    }\n    ```\n\n**示例 (使用 `curl`)：**\n\n```bash\ncurl http://localhost:5758/health\n```\n\n## 任务规范 (`TaskSpec`) 结构\n\n任务对象中的 `spec` 字段 (`api/v1alpha1.TaskSpec`) 定义了应如何执行任务。它目前支持 `process` 和 `container` 执行模式。\n\n### 进程任务示例\n\n此模式直接作为进程执行命令。\n\n```json\n{\n  \"name\": \"my-process-task\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"python3\", \"my_script.py\"],\n      \"args\": [\"--config\", \"/etc/app/config.yaml\"],\n      \"env\": [\n        { \"name\": \"DEBUG_MODE\", \"value\": \"true\" }\n      ],\n      \"workingDir\": \"/app\"\n    }\n  }\n}\n```\n\n### 容器任务示例（占位符/未来特性）\n\n此模式旨在执行由 CRI 运行时管理的容器中的任务。请注意，根据 `internal/task-executor/runtime/container.go`，此模式可能仍是一个占位符。\n\n```json\n{\n  \"name\": \"my-container-task\",\n  \"spec\": {\n    \"container\": {\n      \"image\": \"ubuntu:latest\",\n      \"command\": [\"/bin/bash\", \"-c\"],\n      \"args\": [\"apt update && apt install -y curl\"],\n      \"env\": [\n        { \"name\": \"http_proxy\", \"value\": \"http://myproxy.com:5758\" }\n      ],\n      \"volumeMounts\": [\n        {\n          \"name\": \"data-volume\",\n          \"mountPath\": \"/data\"\n        }\n      ]\n    }\n  }\n}\n```\n\n## 任务状态 (`TaskStatus`) 结构\n\n任务对象中的 `status` 字段 (`internal/task-executor/types/Status` 映射到 `api/v1alpha1.TaskStatus` 用于外部 API) 提供了有关任务当前执行状态的详细信息。\n\n```json\n{\n  \"name\": \"my-task\",\n  \"spec\": { ... },\n  \"status\": {\n    \"state\": {\n      \"waiting\": {\n        \"reason\": \"Initialized\"\n      }\n    },\n    // 或者\n    \"state\": {\n      \"running\": {\n        \"startedAt\": \"2025-12-17T10:00:00Z\"\n      }\n    },\n    // 或者\n    \"state\": {\n      \"terminated\": {\n        \"exitCode\": 0,\n        \"reason\": \"Succeeded\",\n        \"message\": \"Task completed successfully\",\n        \"startedAt\": \"2025-12-17T10:00:00Z\",\n        \"finishedAt\": \"2025-12-17T10:00:05Z\"\n      }\n    }\n  }\n}\n```\n\n**状态类型：**\n\n*   `waiting`：任务正在等待执行。\n*   `running`：任务当前正在执行。\n*   `terminated`：任务已完成（成功或失败）。\n\n## 示例场景：运行 Sidecar 任务\n\n如果 `task-executor` 配置了 `--enable-sidecar-mode=true` 和 `--main-container-name=my-main-app`，它可以在 `my-main-app` 的 PID 命名空间内执行任务。\n\n```bash\n# 假设 task-executor 在 sidecar 模式下运行在一个包含 'my-main-app' 的 pod 上\n# 此任务将从主容器的命名空间内执行 'ls /proc/self/ns'\ncurl -X POST -H \"Content-Type: application/json\" -d \n'{\n  \"name\": \"sidecar-namespace-check\",\n  \"spec\": {\n    \"process\": {\n      \"command\": [\"ls\", \"/proc/self/ns\"]\n    }\n  }\n}' http://localhost:5758/tasks\n```\n"
  },
  {
    "path": "kubernetes/examples/task-executor/main.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"time\"\n\n\ttaskexecutor \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc main() {\n\tbaseURL := \"http://localhost:5758\"\n\tclient := taskexecutor.NewClient(baseURL)\n\tctx := context.Background()\n\n\tfmt.Printf(\"Connecting to Task Executor at %s...\\n\", baseURL)\n\n\ttaskName := \"example-task\"\n\tnewTask := &taskexecutor.Task{\n\t\tName: taskName,\n\t\tProcess: &taskexecutor.Process{\n\t\t\tCommand: []string{\"sh\", \"-c\"},\n\t\t\tArgs:    []string{\"echo 'Hello from SDK example!' && sleep 2 && echo 'Task done.'\"},\n\t\t},\n\t}\n\n\tfmt.Printf(\"Submitting task '%s'...\\n\", taskName)\n\tcreatedTask, err := client.Set(ctx, newTask)\n\tif err != nil {\n\t\tlog.Fatalf(\"Failed to set task: %v\", err)\n\t}\n\tfmt.Printf(\"Task submitted successfully. Initial state: %v\\n\", getTaskState(createdTask))\n\n\tfmt.Println(\"Polling task status...\")\n\tfor i := 0; i < 10; i++ {\n\t\tcurrentTask, err := client.Get(ctx)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Error getting task: %v\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tif currentTask == nil {\n\t\t\tfmt.Println(\"No task found.\")\n\t\t\tbreak\n\t\t}\n\n\t\tstate := getTaskState(currentTask)\n\t\tfmt.Printf(\"Current state: %s\\n\", state)\n\n\t\t// Check if task is finished\n\t\tif currentTask.ProcessStatus.Terminated != nil {\n\t\t\tfmt.Printf(\"Task finished with exit code: %d\\n\", currentTask.ProcessStatus.Terminated.ExitCode)\n\t\t\tbreak\n\t\t}\n\n\t\ttime.Sleep(500 * time.Millisecond)\n\t}\n\n\t// Clean up (pass nil to clear tasks)\n\tfmt.Println(\"Cleaning up...\")\n\t_, err = client.Set(ctx, nil)\n\tif err != nil {\n\t\tlog.Printf(\"Failed to clear tasks: %v\", err)\n\t} else {\n\t\tfmt.Println(\"Tasks cleared.\")\n\t}\n}\n\n// getTaskState returns a string representation of the task state\nfunc getTaskState(task *taskexecutor.Task) string {\n\tif task == nil {\n\t\treturn \"Unknown\"\n\t}\n\tif task.ProcessStatus.Running != nil {\n\t\treturn \"Running\"\n\t}\n\tif task.ProcessStatus.Terminated != nil {\n\t\treturn \"Terminated\"\n\t}\n\tif task.ProcessStatus.Waiting != nil {\n\t\treturn fmt.Sprintf(\"Waiting (%s)\", task.ProcessStatus.Waiting.Reason)\n\t}\n\treturn \"Pending\"\n}\n"
  },
  {
    "path": "kubernetes/go.mod",
    "content": "module github.com/alibaba/OpenSandbox/sandbox-k8s\n\ngo 1.24.0\n\nrequire (\n\tgithub.com/golang/mock v1.6.0\n\tgithub.com/onsi/ginkgo/v2 v2.22.0\n\tgithub.com/onsi/gomega v1.36.1\n\tgithub.com/stretchr/testify v1.11.1\n\tk8s.io/api v0.33.0\n\tk8s.io/apimachinery v0.33.0\n\tk8s.io/client-go v0.33.0\n\tk8s.io/klog/v2 v2.130.1\n\tk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738\n\tsigs.k8s.io/controller-runtime v0.21.0\n)\n\nrequire (\n\tcel.dev/expr v0.19.1 // indirect\n\tgithub.com/antlr4-go/antlr/v4 v4.13.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/blang/semver/v4 v4.0.0 // indirect\n\tgithub.com/cenkalti/backoff/v4 v4.3.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.11.0 // indirect\n\tgithub.com/evanphx/json-patch v4.12.0+incompatible // indirect\n\tgithub.com/evanphx/json-patch/v5 v5.9.11 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fsnotify/fsnotify v1.7.0 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.7.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-logr/zapr v1.3.0 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.20.2 // indirect\n\tgithub.com/go-openapi/swag v0.23.0 // indirect\n\tgithub.com/go-task/slim-sprig/v3 v3.0.0 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/google/btree v1.1.3 // indirect\n\tgithub.com/google/cel-go v0.23.2 // indirect\n\tgithub.com/google/gnostic-models v0.6.9 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/prometheus/client_golang v1.22.0 // indirect\n\tgithub.com/prometheus/client_model v0.6.1 // indirect\n\tgithub.com/prometheus/common v0.62.0 // indirect\n\tgithub.com/prometheus/procfs v0.15.1 // indirect\n\tgithub.com/spf13/cobra v1.8.1 // indirect\n\tgithub.com/spf13/pflag v1.0.5 // indirect\n\tgithub.com/stoewer/go-strcase v1.3.0 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect\n\tgo.opentelemetry.io/otel v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/sdk v1.40.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.40.0 // indirect\n\tgo.opentelemetry.io/proto/otlp v1.4.0 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgo.uber.org/zap v1.27.0\n\tgolang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect\n\tgolang.org/x/net v0.38.0 // indirect\n\tgolang.org/x/oauth2 v0.27.0 // indirect\n\tgolang.org/x/sync v0.12.0 // indirect\n\tgolang.org/x/sys v0.40.0 // indirect\n\tgolang.org/x/term v0.30.0 // indirect\n\tgolang.org/x/text v0.23.0 // indirect\n\tgolang.org/x/time v0.9.0 // indirect\n\tgolang.org/x/tools v0.26.0 // indirect\n\tgomodules.xyz/jsonpatch/v2 v2.4.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect\n\tgoogle.golang.org/grpc v1.68.1 // indirect\n\tgoogle.golang.org/protobuf v1.36.5 // indirect\n\tgopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/apiextensions-apiserver v0.33.0 // indirect\n\tk8s.io/apiserver v0.33.0 // indirect\n\tk8s.io/component-base v0.33.0 // indirect\n\tk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect\n\tsigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect\n\tsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect\n\tsigs.k8s.io/yaml v1.4.0 // indirect\n)\n"
  },
  {
    "path": "kubernetes/go.sum",
    "content": "cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=\ncel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=\ngithub.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=\ngithub.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=\ngithub.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=\ngithub.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=\ngithub.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=\ngithub.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=\ngithub.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=\ngithub.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=\ngithub.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=\ngithub.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=\ngithub.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=\ngithub.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=\ngithub.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=\ngithub.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=\ngithub.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=\ngithub.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=\ngithub.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=\ngithub.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=\ngithub.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=\ngithub.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=\ngithub.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=\ngithub.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4=\ngithub.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo=\ngithub.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=\ngithub.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=\ngithub.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=\ngithub.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=\ngithub.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=\ngithub.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=\ngithub.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=\ngithub.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=\ngithub.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=\ngithub.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=\ngithub.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=\ngithub.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=\ngithub.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=\ngithub.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=\ngithub.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=\ngo.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=\ngo.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=\ngo.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=\ngo.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=\ngo.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=\ngo.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=\ngo.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=\ngo.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=\ngo.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=\ngo.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=\ngo.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=\ngo.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=\ngolang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=\ngolang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=\ngolang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=\ngolang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=\ngolang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=\ngolang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=\ngolang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=\ngolang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=\ngolang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=\ngolang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=\ngomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=\ngoogle.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=\ngoogle.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=\ngoogle.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=\ngoogle.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=\ngopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=\nk8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=\nk8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs=\nk8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc=\nk8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=\nk8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=\nk8s.io/apiserver v0.33.0 h1:QqcM6c+qEEjkOODHppFXRiw/cE2zP85704YrQ9YaBbc=\nk8s.io/apiserver v0.33.0/go.mod h1:EixYOit0YTxt8zrO2kBU7ixAtxFce9gKGq367nFmqI8=\nk8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=\nk8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg=\nk8s.io/component-base v0.33.0 h1:Ot4PyJI+0JAD9covDhwLp9UNkUja209OzsJ4FzScBNk=\nk8s.io/component-base v0.33.0/go.mod h1:aXYZLbw3kihdkOPMDhWbjGCO6sg+luw554KP51t8qCU=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=\nk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=\nk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=\nk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nsigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=\nsigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=\nsigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=\nsigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=\nsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=\nsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=\nsigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=\nsigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=\nsigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=\nsigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=\n"
  },
  {
    "path": "kubernetes/hack/boilerplate.go.txt",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License."
  },
  {
    "path": "kubernetes/hack/debug-task.sh",
    "content": "#!/bin/bash\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -e\n\necho \"Stopping any running debug containers...\"\ndocker stop task-executor-debug > /dev/null || true\necho \"Building debug docker image (dev environment)...\"\ndocker build -t task-executor-debug -f Dockerfile.debug .\n\necho \"Starting debug container with Auto-Sync and Hot-Reload...\"\necho \"---------------------------------------------------------\"\necho \"  App URL:      http://localhost:8080\"\necho \"  Debugger:     localhost:2345\"\necho \"  Source Code:  Mounted from $(pwd)\"\necho \"---------------------------------------------------------\"\necho \"Usage:\"\necho \"  1. Connect GoLand to localhost:2345\"\necho \"  2. Edit code locally -> Container auto-recompiles (watch the logs)\"\necho \"  3. Re-connect Debugger in GoLand\"\necho \"---------------------------------------------------------\"\n\n# Create docker volumes for cache if they don't exist\ndocker volume create sandbox-k8s-gomod > /dev/null\ndocker volume create sandbox-k8s-gocache > /dev/null\n\n# Run the container\n# --rm: remove container after exit\n# -v $(pwd):/workspace: Mount local code\n# -v ...: Mount caches for speed\n# reflex command:\n#   -r '\\.go$': Watch all .go files recursively\n#   -s: Service mode (kill old process before starting new one)\n#   --: Delimiter\n#   dlv debug: Compile and run ./cmd/task\n#     --headless: No terminal UI\n#     --listen=:2345: Debugger port\n#     --api-version=2: API v2\n#     --accept-multiclient: Allow multiple connections\n#     --continue: Start running immediately (Optional, remove if you want to hit 'Resume' first)\n#     --output /tmp/debug_bin: Put binary in tmp to avoid clutter/loops\n\ndocker run --rm -it \\\n  --privileged \\\n  -p 5758:5758 \\\n  -p 2345:2345 \\\n  --security-opt seccomp=unconfined \\\n  --cap-add=SYS_PTRACE \\\n  -v \"$(pwd):/workspace\" \\\n  -v sandbox-k8s-gomod:/go/pkg/mod \\\n  -v sandbox-k8s-gocache:/go/.cache/go-build \\\n  --name task-executor-debug \\\n  -e SANDBOX_MAIN_CONTAINER=task-executor \\\n  task-executor-debug \\\n  reflex -r '\\.go$' -s -- \\\n    dlv debug ./cmd/task-executor \\\n    --headless \\\n    --listen=:2345 \\\n    --api-version=2 \\\n    --accept-multiclient \\\n    --output /tmp/debug_bin \\\n    -- \\\n    -enable-sidecar-mode=true -main-container-name=task-executor"
  },
  {
    "path": "kubernetes/hack/pool-perf.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http:#www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport asyncio\nimport time\nimport uuid\nimport sys\nimport argparse\nfrom kubernetes import client, config\nfrom kubernetes.client.rest import ApiException\n\n# CRD configurations\nGROUP = \"sandbox.opensandbox.io\"\nVERSION = \"v1alpha1\"\nPOOL_PLURAL = \"pools\"\nBSB_PLURAL = \"batchsandboxes\"\nNAMESPACE = \"default\"\n\nclass PoolPerformanceTester:\n    def __init__(self, pool_name, pool_size, replicas_per_bsb, total_bsb_count, timeout, poll_interval=0.00001):\n        try:\n            config.load_kube_config()\n        except Exception:\n            # Fall back to in-cluster config if kube config is not available\n            config.load_incluster_config()\n        self.custom_api = client.CustomObjectsApi()\n        self.pool_name = pool_name\n        self.pool_size = pool_size\n        self.replicas_per_bsb = replicas_per_bsb\n        self.total_bsb_count = total_bsb_count\n        self.timeout = timeout\n        self.poll_interval = poll_interval\n        self.bsb_names = []\n        self.results = {}\n\n    def create_pool_manifest(self, size):\n        return {\n            \"apiVersion\": f\"{GROUP}/{VERSION}\",\n            \"kind\": \"Pool\",\n            \"metadata\": {\"name\": self.pool_name},\n            \"spec\": {\n                \"template\": {\n                    \"spec\": {\n                        \"containers\": [{\"name\": \"nginx\", \"image\": \"nginx:alpine\"}]\n                    }\n                },\n                \"capacitySpec\": {\n                    \"bufferMin\": 5,\n                    \"bufferMax\": 10,\n                    \"poolMin\": size,\n                    \"poolMax\": size + 20\n                }\n            }\n        }\n\n    def create_bsb_manifest(self, name):\n        return {\n            \"apiVersion\": f\"{GROUP}/{VERSION}\",\n            \"kind\": \"BatchSandbox\",\n            \"metadata\": {\"name\": name},\n            \"spec\": {\n                \"replicas\": self.replicas_per_bsb,\n                \"poolRef\": self.pool_name\n            }\n        }\n\n    async def setup_pool(self):\n        \"\"\"Create and wait for the resource pool to be ready\"\"\"\n        print(f\"🚀 Setting up Pool: {self.pool_name} with size {self.pool_size}...\")\n        try:\n            self.custom_api.delete_namespaced_custom_object(GROUP, VERSION, NAMESPACE, POOL_PLURAL, self.pool_name)\n            await asyncio.sleep(5)\n        except ApiException as e:\n            if e.status != 404:\n                print(f\"⚠️  Failed to delete existing Pool: {e}\")\n        except Exception as e:\n            print(f\"⚠️  Error during Pool deletion: {e}\")\n\n        body = self.create_pool_manifest(self.pool_size)\n        self.custom_api.create_namespaced_custom_object(GROUP, VERSION, NAMESPACE, POOL_PLURAL, body)\n        \n        # Wait for Available count to reach target\n        while True:\n            try:\n                pool = self.custom_api.get_namespaced_custom_object(GROUP, VERSION, NAMESPACE, POOL_PLURAL, self.pool_name)\n                available = pool.get(\"status\", {}).get(\"available\", 0)\n                if available >= self.pool_size:\n                    print(f\"✅ Pool is Ready. Available: {available}\")\n                    break\n                print(f\"Waiting for Pool Ready... Available: {available}\")\n            except Exception as e:\n                print(f\"Waiting for Pool to be created... {e}\")\n            await asyncio.sleep(2)\n\n    async def create_bsb(self, index):\n        \"\"\"Create BatchSandboxes concurrently\"\"\"\n        name = f\"perf-test-{uuid.uuid4().hex[:8]}\"\n        self.bsb_names.append(name)\n        body = self.create_bsb_manifest(name)\n        \n        start_time = time.time()\n        try:\n            self.custom_api.create_namespaced_custom_object(GROUP, VERSION, NAMESPACE, BSB_PLURAL, body)\n            self.results[name] = {\"create_time\": time.time() - start_time, \"allocated_time\": None}\n        except ApiException as e:\n            print(f\"❌ Failed to create {name}: {e}\")\n\n    async def wait_for_allocation(self, name):\n        \"\"\"Poll for allocation completion\"\"\"\n        start_polling = time.time()\n        while True:\n            try:\n                bsb = self.custom_api.get_namespaced_custom_object(GROUP, VERSION, NAMESPACE, BSB_PLURAL, name)\n                status = bsb.get(\"status\", {})\n                allocated = status.get(\"allocated\", 0)\n                \n                if allocated >= self.replicas_per_bsb:\n                    print(\"{0}, endpoint {1}\".format(name, bsb.get(\"metadata\", {}).get(\"annotations\", {}).get(\"sandbox.opensandbox.io/endpoints\", \"\")))\n                    self.results[name][\"allocated_time\"] = time.time() - start_polling\n                    break\n            except Exception as e:\n                pass\n            \n            await asyncio.sleep(self.poll_interval)\n            if time.time() - start_polling > self.timeout:\n                print(f\"⏰ Timeout waiting for {name}\")\n                break\n\n    async def run(self):\n        await self.setup_pool()\n        \n        print(f\"🔥 Starting concurrent allocation test: {self.total_bsb_count} BatchSandboxes...\")\n        start_all = time.time()\n        \n        # Concurrent creation\n        await asyncio.gather(*(self.create_bsb(i) for i in range(self.total_bsb_count)))\n        \n        # Concurrent wait for allocation\n        await asyncio.gather(*(self.wait_for_allocation(name) for name in self.bsb_names))\n        \n        total_duration = time.time() - start_all\n        self.print_report(total_duration)\n\n    def print_report(self, total_duration):\n        print(\"\\n\" + \"=\"*40)\n        print(\"📊 PERFORMANCE REPORT\")\n        print(\"=\"*40)\n        durations = [r[\"allocated_time\"] for r in self.results.values() if r.get(\"allocated_time\") is not None]\n        \n        if durations:\n            avg_lat = sum(durations) / len(durations)\n            max_lat = max(durations)\n            p95 = sorted(durations)[int(len(durations) * 0.95)]\n            \n            print(f\"Total BSB:      {self.total_bsb_count}\")\n            print(f\"Total Duration: {total_duration:.2f}s\")\n            print(f\"Throughput:     {len(durations)/total_duration:.2f} sandbox/s\")\n            print(f\"Avg Latency:    {avg_lat:.2f}s\")\n            print(f\"Max Latency:    {max_lat:.2f}s\")\n            print(f\"P95 Latency:    {p95:.2f}s\")\n            print(f\"Success Rate:   {len(durations)/self.total_bsb_count*100:.1f}%\")\n        else:\n            print(\"No successful allocations recorded.\")\n        print(\"=\"*40)\n\n    def cleanup(self):\n        print(\"🧹 Cleaning up...\")\n        for name in self.bsb_names:\n            try:\n                self.custom_api.delete_namespaced_custom_object(GROUP, VERSION, NAMESPACE, BSB_PLURAL, name)\n            except Exception as e:\n                # Silently ignore deletion errors during cleanup\n                pass\n        try:\n            self.custom_api.delete_namespaced_custom_object(GROUP, VERSION, NAMESPACE, POOL_PLURAL, self.pool_name)\n        except Exception as e:\n            # Silently ignore deletion errors during cleanup\n            pass\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description=\"Pool Performance Tester\")\n    parser.add_argument(\"--pool-name\", type=str, default=\"perf-pool\", help=\"Pool name (default: perf-pool)\")\n    parser.add_argument(\"--pool-size\", type=int, default=50, help=\"Pool size (default: 50)\")\n    parser.add_argument(\"--replicas\", type=int, default=1, help=\"Replicas per BatchSandbox (default: 1)\")\n    parser.add_argument(\"--bsb-count\", type=int, default=50, help=\"Number of BatchSandboxes to create concurrently (default: 50)\")\n    parser.add_argument(\"--namespace\", type=str, default=\"default\", help=\"Kubernetes namespace (default: default)\")\n    parser.add_argument(\"--timeout\", type=int, default=120, help=\"Timeout in seconds for each BatchSandbox allocation (default: 120)\")\n    parser.add_argument(\"--poll-interval\", type=float, default=0.00001, help=\"Poll interval in seconds for checking BatchSandbox status (default: 0.00001)\")\n    \n    args = parser.parse_args()\n    \n    # Update global namespace\n    NAMESPACE = args.namespace\n    \n    print(f\"🔧 Test Configuration:\")\n    print(f\"   Pool Name:    {args.pool_name}\")\n    print(f\"   Pool Size:    {args.pool_size}\")\n    print(f\"   Replicas:     {args.replicas}\")\n    print(f\"   BSB Count:    {args.bsb_count}\")\n    print(f\"   Namespace:    {args.namespace}\")\n    print(f\"   Timeout:      {args.timeout}s\")\n    print(f\"   Poll Interval: {args.poll_interval}s\")\n    print()\n    \n    tester = PoolPerformanceTester(\n        pool_name=args.pool_name,\n        pool_size=args.pool_size,\n        replicas_per_bsb=args.replicas,\n        total_bsb_count=args.bsb_count,\n        timeout=args.timeout,\n        poll_interval=args.poll_interval\n    )\n    try:\n        asyncio.run(tester.run())\n    except KeyboardInterrupt:\n        print(\"\\nInterrupted by user\")\n    finally:\n        tester.cleanup()"
  },
  {
    "path": "kubernetes/hack/update-codegen.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nSCRIPT_ROOT=$(dirname \"${BASH_SOURCE[0]}\")/..\nCODEGEN_PKG=${CODEGEN_PKG:-$(cd \"${SCRIPT_ROOT}\"; go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.33.0}\n\nif [ ! -d \"${CODEGEN_PKG}\" ]; then\n    echo \"code-generator not found at ${CODEGEN_PKG}\"\n    echo \"Installing k8s.io/code-generator@v0.33.0...\"\n    go install k8s.io/code-generator/cmd/client-gen@v0.33.0\n    go install k8s.io/code-generator/cmd/lister-gen@v0.33.0\n    go install k8s.io/code-generator/cmd/informer-gen@v0.33.0\nfi\n\nsource \"${CODEGEN_PKG}/kube_codegen.sh\"\n\nkube::codegen::gen_client \\\n    --with-watch \\\n    --output-dir \"${SCRIPT_ROOT}/pkg/client\" \\\n    --output-pkg \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client\" \\\n    --boilerplate \"${SCRIPT_ROOT}/hack/boilerplate.go.txt\" \\\n    \"${SCRIPT_ROOT}/apis\"\n\necho \"Code generation completed successfully!\"\n"
  },
  {
    "path": "kubernetes/internal/controller/allocator.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\tgerrors \"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\tlogf \"sigs.k8s.io/controller-runtime/pkg/log\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations\"\n)\n\nvar (\n\tpoolResExpectations = expectations.NewResourceVersionExpectation()\n)\n\ntype AllocationStore interface {\n\tGetAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (*PoolAllocation, error)\n\tSetAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, allocation *PoolAllocation) error\n}\n\ntype annoAllocationStore struct {\n\tclient client.Client\n}\n\nfunc NewAnnoAllocationStore(client client.Client) AllocationStore {\n\treturn &annoAllocationStore{\n\t\tclient: client,\n\t}\n}\n\nfunc (store *annoAllocationStore) GetAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (*PoolAllocation, error) {\n\talloc := &PoolAllocation{\n\t\tPodAllocation: make(map[string]string),\n\t}\n\tpoolResExpectations.Observe(pool)\n\tanno := pool.GetAnnotations()\n\tif anno == nil {\n\t\treturn alloc, nil\n\t}\n\tjs, ok := anno[AnnoPoolAllocStatusKey]\n\tif !ok {\n\t\treturn alloc, nil\n\t}\n\terr := json.Unmarshal([]byte(js), alloc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn alloc, nil\n}\n\nfunc (store *annoAllocationStore) SetAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, alloc *PoolAllocation) error {\n\tif satisfied, unsatisfiedDuration := poolResExpectations.IsSatisfied(pool); !satisfied {\n\t\treturn fmt.Errorf(\"pool allocation is not ready, unsatisfiedDuration:%v\", unsatisfiedDuration)\n\t}\n\tjs, err := json.Marshal(alloc)\n\tif err != nil {\n\t\treturn err\n\t}\n\told := pool.DeepCopy()\n\toldGen := int64(0)\n\tanno := pool.GetAnnotations()\n\tif anno == nil {\n\t\tanno = map[string]string{}\n\t}\n\tstr, ok := anno[AnnoPoolAllocGenerationKey]\n\tif ok {\n\t\toldGen, err = strconv.ParseInt(str, 10, 64)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tgen := strconv.FormatInt(oldGen+1, 10)\n\tanno[AnnoPoolAllocStatusKey] = string(js)\n\tanno[AnnoPoolAllocGenerationKey] = gen\n\tpool.SetAnnotations(anno)\n\tpatch := client.MergeFrom(old)\n\tif err := store.client.Patch(ctx, pool, patch); err != nil {\n\t\treturn err\n\t}\n\tpoolResExpectations.Expect(pool)\n\treturn nil\n}\n\ntype AllocationSyncer interface {\n\tSetAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox, allocation *SandboxAllocation) error\n\tGetAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox) (*SandboxAllocation, error)\n\tGetRelease(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox) (*AllocationRelease, error)\n}\ntype annoAllocationSyncer struct {\n\tclient client.Client\n}\n\nfunc NewAnnoAllocationSyncer(client client.Client) AllocationSyncer {\n\treturn &annoAllocationSyncer{\n\t\tclient: client,\n\t}\n}\n\nfunc (syncer *annoAllocationSyncer) SetAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox, allocation *SandboxAllocation) error {\n\told, ok := sandbox.DeepCopyObject().(*sandboxv1alpha1.BatchSandbox)\n\tif !ok {\n\t\treturn fmt.Errorf(\"invalid object\")\n\t}\n\tanno := sandbox.GetAnnotations()\n\tif anno == nil {\n\t\tanno = make(map[string]string)\n\t}\n\tjs, err := json.Marshal(allocation)\n\tif err != nil {\n\t\treturn err\n\t}\n\tanno[AnnoAllocStatusKey] = string(js)\n\tsandbox.SetAnnotations(anno)\n\tpatch := client.MergeFrom(old)\n\treturn syncer.client.Patch(ctx, sandbox, patch)\n}\n\nfunc (syncer *annoAllocationSyncer) GetAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox) (*SandboxAllocation, error) {\n\tallocation := &SandboxAllocation{\n\t\tPods: make([]string, 0),\n\t}\n\tanno := sandbox.GetAnnotations()\n\tif anno == nil {\n\t\treturn allocation, nil\n\t}\n\tif raw := anno[AnnoAllocStatusKey]; raw != \"\" {\n\t\terr := json.Unmarshal([]byte(raw), allocation)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn allocation, nil\n}\n\nfunc (syncer *annoAllocationSyncer) GetRelease(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox) (*AllocationRelease, error) {\n\trelease := &AllocationRelease{\n\t\tPods: make([]string, 0),\n\t}\n\tanno := sandbox.GetAnnotations()\n\tif anno == nil {\n\t\treturn release, nil\n\t}\n\tif raw := anno[AnnoAllocReleaseKey]; raw != \"\" {\n\t\terr := json.Unmarshal([]byte(raw), release)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn release, nil\n}\n\ntype AllocSpec struct {\n\t// sandboxes need to allocate\n\tSandboxes []*sandboxv1alpha1.BatchSandbox\n\t// pool\n\tPool *sandboxv1alpha1.Pool\n\t// all pods of pool\n\tPods []*corev1.Pod\n}\n\ntype AllocStatus struct {\n\t// pod allocated to sandbox\n\tPodAllocation map[string]string\n\t// pod request count\n\tPodSupplement int32\n}\n\n// Allocator is responsible for managing pod allocation from Pool to BatchSandboxes.\n// It performs allocation calculations and persists the allocation state.\ntype Allocator interface {\n\t// Schedule computes the allocation of pods to BatchSandboxes based on the current pool state.\n\t// It returns:\n\t//   - AllocStatus: the computed allocation state (pod-to-sandbox mapping and required supplement count)\n\t//   - poolDirty: indicates whether the Pool's allocation state has changed and needs persistence\n\t//   - error: any error during the scheduling process\n\t// This method only performs calculation and does not modify the Pool CR directly.\n\tSchedule(ctx context.Context, spec *AllocSpec) (*AllocStatus, bool, error)\n\n\t// PersistPoolAllocation persists the allocation status.\n\t// This method should be called after Schedule() when poolDirty is true.\n\tPersistPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, status *AllocStatus) error\n}\n\ntype defaultAllocator struct {\n\tstore  AllocationStore\n\tsyncer AllocationSyncer\n}\n\nfunc NewDefaultAllocator(client client.Client) Allocator {\n\treturn &defaultAllocator{\n\t\tstore:  NewAnnoAllocationStore(client),\n\t\tsyncer: NewAnnoAllocationSyncer(client),\n\t}\n}\n\nfunc (allocator *defaultAllocator) Schedule(ctx context.Context, spec *AllocSpec) (*AllocStatus, bool, error) {\n\tlog := logf.FromContext(ctx)\n\tstatus, err := allocator.initAllocation(ctx, spec)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tavailablePods := make([]string, 0)\n\tfor _, pod := range spec.Pods {\n\t\tif _, ok := status.PodAllocation[pod.Name]; ok { // allocated\n\t\t\tcontinue\n\t\t}\n\t\tif pod.Status.Phase != corev1.PodRunning { // not running\n\t\t\tcontinue\n\t\t}\n\t\tavailablePods = append(availablePods, pod.Name)\n\t}\n\tsandboxToPods := make(map[string][]string)\n\tfor podName, sandboxName := range status.PodAllocation {\n\t\tsandboxToPods[sandboxName] = append(sandboxToPods[sandboxName], podName)\n\t}\n\tsandboxAlloc, dirtySandboxes, poolAllocate, err := allocator.allocate(ctx, status, sandboxToPods, availablePods, spec.Sandboxes, spec.Pods)\n\tif err != nil {\n\t\tlog.Error(err, \"allocate failed\")\n\t}\n\tpoolDeallocate, err := allocator.deallocate(ctx, status, sandboxToPods, spec.Sandboxes)\n\tif err != nil {\n\t\tlog.Error(err, \"deallocate failed\")\n\t}\n\n\tpoolDirty := poolDeallocate || poolAllocate\n\n\tif err := allocator.syncAllocResult(ctx, dirtySandboxes, sandboxAlloc, spec.Sandboxes); err != nil {\n\t\tlog.Error(err, \"sync alloc result failed\")\n\t}\n\treturn status, poolDirty, nil // Do not return the error of sandboxes witch will block pool schedule.\n}\n\nfunc (allocator *defaultAllocator) initAllocation(ctx context.Context, spec *AllocSpec) (*AllocStatus, error) {\n\tvar err error\n\tstatus := &AllocStatus{\n\t\tPodAllocation: make(map[string]string),\n\t}\n\tstatus.PodAllocation, err = allocator.getPodAllocation(ctx, spec.Pool)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn status, nil\n}\n\nfunc (allocator *defaultAllocator) allocate(ctx context.Context, status *AllocStatus, sandboxToPods map[string][]string, availablePods []string, sandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (map[string][]string, []string, bool, error) {\n\terrs := make([]error, 0)\n\tsandboxAlloc := make(map[string][]string)\n\tdirtySandboxes := make([]string, 0)\n\tpoolDirty := false\n\tfor _, sbx := range sandboxes {\n\t\talloc, remainAvailablePods, sandboxDirty, poolAllocate, err := allocator.doAllocate(ctx, status, sandboxToPods, availablePods, sbx, *sbx.Spec.Replicas)\n\t\tavailablePods = remainAvailablePods\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t} else {\n\t\t\tsandboxAlloc[sbx.Name] = alloc\n\t\t\tif sandboxDirty {\n\t\t\t\tdirtySandboxes = append(dirtySandboxes, sbx.Name)\n\t\t\t}\n\t\t\tif poolAllocate {\n\t\t\t\tpoolDirty = true\n\t\t\t}\n\t\t}\n\t}\n\treturn sandboxAlloc, dirtySandboxes, poolDirty, gerrors.Join(errs...)\n}\n\nfunc (allocator *defaultAllocator) doAllocate(ctx context.Context, status *AllocStatus, sandboxToPods map[string][]string, availablePods []string, sbx *sandboxv1alpha1.BatchSandbox, cnt int32) ([]string, []string, bool, bool, error) {\n\tsandboxDirty := false\n\tpoolAllocate := false\n\tsandboxAlloc := make([]string, 0)\n\tremainAvailablePods := availablePods\n\tif sbx.DeletionTimestamp != nil {\n\t\treturn sandboxAlloc, remainAvailablePods, false, false, nil\n\t}\n\tsbxAlloc, err := allocator.syncer.GetAllocation(ctx, sbx)\n\tif err != nil {\n\t\treturn nil, remainAvailablePods, false, false, err\n\t}\n\tremoteAlloc := sbxAlloc.Pods\n\tallocatedPod := make([]string, 0)\n\tallocatedPod = append(allocatedPod, remoteAlloc...)\n\tname := sbx.Name\n\tif localAlloc, ok := sandboxToPods[name]; ok {\n\t\tfor _, localPod := range localAlloc {\n\t\t\tif !slices.Contains(remoteAlloc, localPod) {\n\t\t\t\tsandboxDirty = true\n\t\t\t\tallocatedPod = append(allocatedPod, localPod)\n\t\t\t}\n\t\t}\n\t}\n\tsandboxAlloc = append(sandboxAlloc, allocatedPod...) // old allocation\n\tneedAllocateCnt := cnt - int32(len(allocatedPod))\n\tcanAllocateCnt := needAllocateCnt\n\tif int32(len(availablePods)) < canAllocateCnt {\n\t\tcanAllocateCnt = int32(len(availablePods))\n\t}\n\tpods := availablePods[:canAllocateCnt]\n\tremainAvailablePods = availablePods[canAllocateCnt:]\n\tsandboxToPods[name] = pods\n\tfor _, pod := range pods {\n\t\tsandboxDirty = true\n\t\tstatus.PodAllocation[pod] = name\n\t\tpoolAllocate = true\n\t\tsandboxAlloc = append(sandboxAlloc, pod) // new allocation\n\t}\n\tif canAllocateCnt < needAllocateCnt {\n\t\tstatus.PodSupplement += needAllocateCnt - canAllocateCnt\n\t}\n\treturn sandboxAlloc, remainAvailablePods, sandboxDirty, poolAllocate, nil\n}\n\nfunc (allocator *defaultAllocator) deallocate(ctx context.Context, status *AllocStatus, sandboxToPods map[string][]string, sandboxes []*sandboxv1alpha1.BatchSandbox) (bool, error) {\n\tpoolDeallocate := false\n\terrs := make([]error, 0)\n\tsbxMap := make(map[string]*sandboxv1alpha1.BatchSandbox)\n\tfor _, sandbox := range sandboxes {\n\t\tsbxMap[sandbox.Name] = sandbox\n\t\tdeallocate, err := allocator.doDeallocate(ctx, status, sandboxToPods, sandbox)\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t} else {\n\t\t\tif deallocate {\n\t\t\t\tpoolDeallocate = true\n\t\t\t}\n\t\t}\n\t}\n\t// gc deleted sandbox and  batch sandbox\n\tSandboxGC := make([]string, 0)\n\tfor name := range sandboxToPods {\n\t\tif _, ok := sbxMap[name]; !ok {\n\t\t\tSandboxGC = append(SandboxGC, name)\n\t\t}\n\t}\n\tfor _, name := range SandboxGC {\n\t\tpods := sandboxToPods[name]\n\t\tfor _, pod := range pods {\n\t\t\tdelete(status.PodAllocation, pod)\n\t\t\tpoolDeallocate = true\n\t\t}\n\t\tdelete(sandboxToPods, name)\n\t}\n\treturn poolDeallocate, gerrors.Join(errs...)\n}\n\nfunc (allocator *defaultAllocator) doDeallocate(ctx context.Context, status *AllocStatus, sandboxToPods map[string][]string, sbx *sandboxv1alpha1.BatchSandbox) (bool, error) {\n\tdeallocate := false\n\tname := sbx.Name\n\tallocatedPods, ok := sandboxToPods[name]\n\tif !ok { // pods is already release to pool\n\t\treturn false, nil\n\t}\n\ttoRelease, err := allocator.syncer.GetRelease(ctx, sbx)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tfor _, pod := range toRelease.Pods {\n\t\tdelete(status.PodAllocation, pod)\n\t\tdeallocate = true\n\t}\n\tpods := make([]string, 0)\n\tfor _, pod := range allocatedPods {\n\t\tif slices.Contains(toRelease.Pods, pod) {\n\t\t\tcontinue\n\t\t}\n\t\tpods = append(pods, pod)\n\t}\n\tsandboxToPods[name] = pods\n\treturn deallocate, nil\n}\n\nfunc (allocator *defaultAllocator) getPodAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool) (map[string]string, error) {\n\talloc, err := allocator.store.GetAllocation(ctx, pool)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif alloc == nil {\n\t\treturn map[string]string{}, nil\n\t}\n\treturn alloc.PodAllocation, nil\n}\n\nfunc (allocator *defaultAllocator) PersistPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, status *AllocStatus) error {\n\talloc := &PoolAllocation{}\n\talloc.PodAllocation = status.PodAllocation\n\treturn allocator.store.SetAllocation(ctx, pool, alloc)\n}\n\nfunc (allocator *defaultAllocator) syncAllocResult(ctx context.Context, dirtySandboxes []string, sandboxAlloc map[string][]string, sandboxes []*sandboxv1alpha1.BatchSandbox) error {\n\tif len(dirtySandboxes) == 0 {\n\t\treturn nil\n\t}\n\terrs := make([]error, 0)\n\tsbxMap := make(map[string]*sandboxv1alpha1.BatchSandbox)\n\tfor _, sbx := range sandboxes {\n\t\tsbxMap[sbx.Name] = sbx\n\t}\n\tfor _, name := range dirtySandboxes {\n\t\terr := allocator.doSyncAllocResult(ctx, sandboxAlloc[name], sbxMap[name])\n\t\tif err != nil {\n\t\t\terrs = append(errs, err)\n\t\t}\n\t}\n\treturn gerrors.Join(errs...)\n}\n\nfunc (allocator *defaultAllocator) doSyncAllocResult(ctx context.Context, allocatedPods []string, sbx *sandboxv1alpha1.BatchSandbox) error {\n\tallocation := &SandboxAllocation{}\n\tallocation.Pods = allocatedPods\n\treturn allocator.syncer.SetAllocation(ctx, sbx, allocation)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/allocator_mock.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: allocator.go\n\n// Package controller is a generated GoMock package.\npackage controller\n\nimport (\n\tcontext \"context\"\n\treflect \"reflect\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\n// MockAllocationStore is a mock of AllocationStore interface.\ntype MockAllocationStore struct {\n\tctrl     *gomock.Controller\n\trecorder *MockAllocationStoreMockRecorder\n}\n\n// MockAllocationStoreMockRecorder is the mock recorder for MockAllocationStore.\ntype MockAllocationStoreMockRecorder struct {\n\tmock *MockAllocationStore\n}\n\n// NewMockAllocationStore creates a new mock instance.\nfunc NewMockAllocationStore(ctrl *gomock.Controller) *MockAllocationStore {\n\tmock := &MockAllocationStore{ctrl: ctrl}\n\tmock.recorder = &MockAllocationStoreMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockAllocationStore) EXPECT() *MockAllocationStoreMockRecorder {\n\treturn m.recorder\n}\n\n// GetAllocation mocks base method.\nfunc (m *MockAllocationStore) GetAllocation(ctx context.Context, pool *v1alpha1.Pool) (*PoolAllocation, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAllocation\", ctx, pool)\n\tret0, _ := ret[0].(*PoolAllocation)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetAllocation indicates an expected call of GetAllocation.\nfunc (mr *MockAllocationStoreMockRecorder) GetAllocation(ctx, pool interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAllocation\", reflect.TypeOf((*MockAllocationStore)(nil).GetAllocation), ctx, pool)\n}\n\n// SetAllocation mocks base method.\nfunc (m *MockAllocationStore) SetAllocation(ctx context.Context, pool *v1alpha1.Pool, allocation *PoolAllocation) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"SetAllocation\", ctx, pool, allocation)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// SetAllocation indicates an expected call of SetAllocation.\nfunc (mr *MockAllocationStoreMockRecorder) SetAllocation(ctx, pool, allocation interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SetAllocation\", reflect.TypeOf((*MockAllocationStore)(nil).SetAllocation), ctx, pool, allocation)\n}\n\n// MockAllocationSyncer is a mock of AllocationSyncer interface.\ntype MockAllocationSyncer struct {\n\tctrl     *gomock.Controller\n\trecorder *MockAllocationSyncerMockRecorder\n}\n\n// MockAllocationSyncerMockRecorder is the mock recorder for MockAllocationSyncer.\ntype MockAllocationSyncerMockRecorder struct {\n\tmock *MockAllocationSyncer\n}\n\n// NewMockAllocationSyncer creates a new mock instance.\nfunc NewMockAllocationSyncer(ctrl *gomock.Controller) *MockAllocationSyncer {\n\tmock := &MockAllocationSyncer{ctrl: ctrl}\n\tmock.recorder = &MockAllocationSyncerMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockAllocationSyncer) EXPECT() *MockAllocationSyncerMockRecorder {\n\treturn m.recorder\n}\n\n// GetAllocation mocks base method.\nfunc (m *MockAllocationSyncer) GetAllocation(ctx context.Context, sandbox *v1alpha1.BatchSandbox) (*SandboxAllocation, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetAllocation\", ctx, sandbox)\n\tret0, _ := ret[0].(*SandboxAllocation)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetAllocation indicates an expected call of GetAllocation.\nfunc (mr *MockAllocationSyncerMockRecorder) GetAllocation(ctx, sandbox interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetAllocation\", reflect.TypeOf((*MockAllocationSyncer)(nil).GetAllocation), ctx, sandbox)\n}\n\n// GetRelease mocks base method.\nfunc (m *MockAllocationSyncer) GetRelease(ctx context.Context, sandbox *v1alpha1.BatchSandbox) (*AllocationRelease, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetRelease\", ctx, sandbox)\n\tret0, _ := ret[0].(*AllocationRelease)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GetRelease indicates an expected call of GetRelease.\nfunc (mr *MockAllocationSyncerMockRecorder) GetRelease(ctx, sandbox interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetRelease\", reflect.TypeOf((*MockAllocationSyncer)(nil).GetRelease), ctx, sandbox)\n}\n\n// SetAllocation mocks base method.\nfunc (m *MockAllocationSyncer) SetAllocation(ctx context.Context, sandbox *v1alpha1.BatchSandbox, allocation *SandboxAllocation) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"SetAllocation\", ctx, sandbox, allocation)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// SetAllocation indicates an expected call of SetAllocation.\nfunc (mr *MockAllocationSyncerMockRecorder) SetAllocation(ctx, sandbox, allocation interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SetAllocation\", reflect.TypeOf((*MockAllocationSyncer)(nil).SetAllocation), ctx, sandbox, allocation)\n}\n\n// MockAllocator is a mock of Allocator interface.\ntype MockAllocator struct {\n\tctrl     *gomock.Controller\n\trecorder *MockAllocatorMockRecorder\n}\n\n// MockAllocatorMockRecorder is the mock recorder for MockAllocator.\ntype MockAllocatorMockRecorder struct {\n\tmock *MockAllocator\n}\n\n// NewMockAllocator creates a new mock instance.\nfunc NewMockAllocator(ctrl *gomock.Controller) *MockAllocator {\n\tmock := &MockAllocator{ctrl: ctrl}\n\tmock.recorder = &MockAllocatorMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockAllocator) EXPECT() *MockAllocatorMockRecorder {\n\treturn m.recorder\n}\n\n// Schedule mocks base method.\nfunc (m *MockAllocator) Schedule(ctx context.Context, spec *AllocSpec) (*AllocStatus, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Schedule\", ctx, spec)\n\tret0, _ := ret[0].(*AllocStatus)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Schedule indicates an expected call of Schedule.\nfunc (mr *MockAllocatorMockRecorder) Schedule(ctx, spec interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Schedule\", reflect.TypeOf((*MockAllocator)(nil).Schedule), ctx, spec)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/allocator_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"testing\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/golang/mock/gomock\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestAllocatorSchedule(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tstore := NewMockAllocationStore(ctrl)\n\tsyncer := NewMockAllocationSyncer(ctrl)\n\tallocator := &defaultAllocator{\n\t\tstore:  store,\n\t\tsyncer: syncer,\n\t}\n\treplica1 := int32(1)\n\treplica2 := int32(2)\n\ttype TestCase struct {\n\t\tname         string\n\t\tspec         *AllocSpec\n\t\tpoolAlloc    *PoolAllocation\n\t\tsandboxAlloc *SandboxAllocation\n\t\trelease      *AllocationRelease\n\t\twantStatus   *AllocStatus\n\t}\n\tcases := []TestCase{\n\t\t{\n\t\t\tname: \"normal\",\n\t\t\tspec: &AllocSpec{\n\t\t\t\tPods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tPool: &sandboxv1alpha1.Pool{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"pool1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSandboxes: []*sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpoolAlloc: &PoolAllocation{\n\t\t\t\tPodAllocation: map[string]string{},\n\t\t\t},\n\t\t\tsandboxAlloc: &SandboxAllocation{\n\t\t\t\tPods: []string{},\n\t\t\t},\n\t\t\trelease: &AllocationRelease{\n\t\t\t\tPods: []string{},\n\t\t\t},\n\t\t\twantStatus: &AllocStatus{\n\t\t\t\tPodAllocation: map[string]string{\n\t\t\t\t\t\"pod1\": \"sbx1\",\n\t\t\t\t\t\"pod2\": \"sbx2\",\n\t\t\t\t},\n\t\t\t\tPodSupplement: 0,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"pod not running\",\n\t\t\tspec: &AllocSpec{\n\t\t\t\tPods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodPending,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tPool: &sandboxv1alpha1.Pool{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"pool1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSandboxes: []*sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpoolAlloc: &PoolAllocation{\n\t\t\t\tPodAllocation: map[string]string{},\n\t\t\t},\n\t\t\tsandboxAlloc: &SandboxAllocation{\n\t\t\t\tPods: []string{},\n\t\t\t},\n\t\t\trelease: &AllocationRelease{\n\t\t\t\tPods: []string{},\n\t\t\t},\n\t\t\twantStatus: &AllocStatus{\n\t\t\t\tPodAllocation: map[string]string{\n\t\t\t\t\t\"pod1\": \"sbx1\",\n\t\t\t\t},\n\t\t\t\tPodSupplement: 1,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"already partial allocated\",\n\t\t\tspec: &AllocSpec{\n\t\t\t\tPods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tPool: &sandboxv1alpha1.Pool{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"pool1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSandboxes: []*sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica2,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpoolAlloc: &PoolAllocation{\n\t\t\t\tPodAllocation: map[string]string{\n\t\t\t\t\t\"pod1\": \"sbx1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsandboxAlloc: &SandboxAllocation{\n\t\t\t\tPods: []string{\n\t\t\t\t\t\"pod1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\trelease: &AllocationRelease{\n\t\t\t\tPods: []string{},\n\t\t\t},\n\t\t\twantStatus: &AllocStatus{\n\t\t\t\tPodAllocation: map[string]string{\n\t\t\t\t\t\"pod1\": \"sbx1\",\n\t\t\t\t\t\"pod2\": \"sbx1\",\n\t\t\t\t},\n\t\t\t\tPodSupplement: 0,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no need allocated with release\",\n\t\t\tspec: &AllocSpec{\n\t\t\t\tPods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"pod1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tPool: &sandboxv1alpha1.Pool{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"pool1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSandboxes: []*sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"sbx1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\t\tPoolRef:  \"pool1\",\n\t\t\t\t\t\t\tReplicas: &replica1,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpoolAlloc: &PoolAllocation{\n\t\t\t\tPodAllocation: map[string]string{},\n\t\t\t},\n\t\t\tsandboxAlloc: &SandboxAllocation{\n\t\t\t\tPods: []string{\n\t\t\t\t\t\"pod1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\trelease: &AllocationRelease{\n\t\t\t\tPods: []string{\n\t\t\t\t\t\"pod1\", \"sbx1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantStatus: &AllocStatus{\n\t\t\t\tPodAllocation: map[string]string{},\n\t\t\t\tPodSupplement: 0,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tstore.EXPECT().GetAllocation(gomock.Any(), gomock.Any()).Return(c.poolAlloc, nil).Times(1)\n\t\t\tstore.EXPECT().SetAllocation(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()\n\t\t\tsyncer.EXPECT().GetAllocation(gomock.Any(), gomock.Any()).Return(c.sandboxAlloc, nil).Times(len(c.spec.Sandboxes))\n\t\t\tsyncer.EXPECT().SetAllocation(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()\n\t\t\tsyncer.EXPECT().GetRelease(gomock.Any(), gomock.Any()).Return(c.release, nil).Times(len(c.spec.Sandboxes))\n\t\t\tstatus, _, err := allocator.Schedule(context.Background(), c.spec)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.True(t, reflect.DeepEqual(c.wantStatus, status))\n\t\t})\n\t}\n\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/apis.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"encoding/json\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tpkgutils \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils\"\n)\n\nconst (\n\tAnnoAllocStatusKey           = \"sandbox.opensandbox.io/alloc-status\"\n\tAnnoAllocReleaseKey          = \"sandbox.opensandbox.io/alloc-release\"\n\tLabelBatchSandboxPodIndexKey = \"batch-sandbox.sandbox.opensandbox.io/pod-index\"\n\n\tAnnoPoolAllocStatusKey     = \"pool.opensandbox.io/alloc-status\"\n\tAnnoPoolAllocGenerationKey = \"pool.opensandbox.io/alloc-generation\"\n\n\tFinalizerTaskCleanup = \"batch-sandbox.sandbox.opensandbox.io/task-cleanup\"\n)\n\n// AnnotationSandboxEndpoints Use the exported constant from pkg/utils\nvar AnnotationSandboxEndpoints = pkgutils.AnnotationEndpoints\n\ntype SandboxAllocation struct {\n\tPods []string `json:\"pods\"`\n}\n\ntype AllocationRelease struct {\n\tPods []string `json:\"pods\"`\n}\n\ntype PoolAllocation struct {\n\tPodAllocation map[string]string `json:\"podAllocation\"`\n}\n\nfunc parseSandboxAllocation(obj metav1.Object) (SandboxAllocation, error) {\n\tret := SandboxAllocation{}\n\tif raw := obj.GetAnnotations()[AnnoAllocStatusKey]; raw != \"\" {\n\t\tif err := json.Unmarshal([]byte(raw), &ret); err != nil {\n\t\t\treturn ret, err\n\t\t}\n\t}\n\treturn ret, nil\n}\n\nfunc setSandboxAllocation(obj metav1.Object, alloc SandboxAllocation) {\n\tif obj.GetAnnotations() == nil {\n\t\tobj.SetAnnotations(map[string]string{})\n\t}\n\tobj.GetAnnotations()[AnnoAllocStatusKey] = utils.DumpJSON(alloc)\n}\n\nfunc parseSandboxReleased(obj metav1.Object) (AllocationRelease, error) {\n\tret := AllocationRelease{}\n\tif raw := obj.GetAnnotations()[AnnoAllocReleaseKey]; raw != \"\" {\n\t\tif err := json.Unmarshal([]byte(raw), &ret); err != nil {\n\t\t\treturn ret, err\n\t\t}\n\t}\n\treturn ret, nil\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/batchsandbox_controller.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\tgerrors \"errors\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/fields\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/apimachinery/pkg/util/strategicpatch\"\n\t\"k8s.io/client-go/tools/record\"\n\t\"k8s.io/client-go/util/retry\"\n\tctrl \"sigs.k8s.io/controller-runtime\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\t\"sigs.k8s.io/controller-runtime/pkg/controller\"\n\t\"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil\"\n\tlogf \"sigs.k8s.io/controller-runtime/pkg/log\"\n\t\"sigs.k8s.io/controller-runtime/pkg/reconcile\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/strategy\"\n\ttaskscheduler \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tcontrollerutils \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/controller\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/requeueduration\"\n)\n\nvar (\n\tBatchSandboxScaleExpectations = expectations.NewScaleExpectations()\n\tDurationStore                 = requeueduration.DurationStore{}\n)\n\n// BatchSandboxReconciler reconciles a BatchSandbox object\ntype BatchSandboxReconciler struct {\n\tclient.Client\n\tScheme         *runtime.Scheme\n\tRecorder       record.EventRecorder\n\ttaskSchedulers sync.Map\n}\n\n// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete\n// +kubebuilder:rbac:groups=core,resources=events,verbs=get;list;watch;create;update;patch;delete\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=batchsandboxes,verbs=get;list;watch;create;update;patch;delete\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=batchsandboxes/status,verbs=get;update;patch\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=batchsandboxes/finalizers,verbs=update\n\n// Reconcile is part of the main kubernetes reconciliation loop which aims to\n// move the current state of the cluster closer to the desired state.\n// TODO(user): Modify the Reconcile function to compare the state specified by\n// the BatchSandbox object against the actual cluster state, and then\n// perform operations to make the cluster state reflect the state specified by\n// the user.\n//\n// For more details, check Reconcile and its Result here:\n// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile\nfunc (r *BatchSandboxReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {\n\tlog := logf.FromContext(ctx)\n\tvar aggErrors []error\n\tdefer func() {\n\t\t_ = DurationStore.Pop(req.String())\n\t}()\n\tbatchSbx := &sandboxv1alpha1.BatchSandbox{}\n\tif err := r.Get(ctx, client.ObjectKey{\n\t\tNamespace: req.Namespace,\n\t\tName:      req.Name,\n\t}, batchSbx); err != nil {\n\t\tif errors.IsNotFound(err) {\n\t\t\treturn ctrl.Result{}, nil\n\t\t}\n\t\treturn ctrl.Result{}, err\n\t}\n\t// handle expire\n\tif expireAt := batchSbx.Spec.ExpireTime; expireAt != nil {\n\t\tnow := time.Now()\n\t\tif expireAt.Time.Before(now) {\n\t\t\tif batchSbx.DeletionTimestamp == nil {\n\t\t\t\tlog.Info(\"batch sandbox expired, delete\", \"expireAt\", expireAt)\n\t\t\t\tif err := r.Delete(ctx, batchSbx); err != nil {\n\t\t\t\t\tif errors.IsNotFound(err) {\n\t\t\t\t\t\treturn ctrl.Result{}, nil\n\t\t\t\t\t}\n\t\t\t\t\treturn ctrl.Result{}, err\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tDurationStore.Push(types.NamespacedName{Namespace: batchSbx.Namespace, Name: batchSbx.Name}.String(), expireAt.Time.Sub(now))\n\t\t}\n\t}\n\n\t// task schedule\n\ttaskStrategy := strategy.NewTaskSchedulingStrategy(batchSbx)\n\n\t// pool strategy\n\tpoolStrategy := strategy.NewPoolStrategy(batchSbx)\n\n\t// handle finalizers\n\tif batchSbx.DeletionTimestamp == nil {\n\t\tif taskStrategy.NeedTaskScheduling() {\n\t\t\tif !controllerutil.ContainsFinalizer(batchSbx, FinalizerTaskCleanup) {\n\t\t\t\terr := utils.UpdateFinalizer(r.Client, batchSbx, utils.AddFinalizerOpType, FinalizerTaskCleanup)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error(err, \"failed to add finalizer\", \"finalizer\", FinalizerTaskCleanup)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Info(\"added finalizer\", \"finalizer\", FinalizerTaskCleanup)\n\t\t\t\t}\n\t\t\t\treturn ctrl.Result{}, err\n\t\t\t}\n\t\t}\n\t} else {\n\t\tif !taskStrategy.NeedTaskScheduling() {\n\t\t\treturn ctrl.Result{}, nil\n\t\t}\n\t}\n\n\tpods, err := r.listPods(ctx, poolStrategy, batchSbx)\n\tif err != nil {\n\t\treturn ctrl.Result{}, fmt.Errorf(\"failed to list pods %w\", err)\n\t}\n\tpodIndex, err := calPodIndex(poolStrategy, batchSbx, pods)\n\tif err != nil {\n\t\treturn ctrl.Result{}, fmt.Errorf(\"failed to cal pod index %w\", err)\n\t}\n\tslices.SortStableFunc(pods, utils.MultiPodSorter([]func(a, b *corev1.Pod) int{\n\t\tutils.WithPodIndexSorter(podIndex),\n\t\tutils.PodNameSorter,\n\t}).Sort)\n\t// Normal Mode need scale Pods\n\tif !poolStrategy.IsPooledMode() {\n\t\terr := r.scaleBatchSandbox(ctx, batchSbx, batchSbx.Spec.Template, pods)\n\t\tif err != nil {\n\t\t\treturn ctrl.Result{}, fmt.Errorf(\"failed to scale batch sandbox %w\", err)\n\t\t}\n\t}\n\n\t// TODO merge task status update\n\tnewStatus := batchSbx.Status.DeepCopy()\n\tnewStatus.ObservedGeneration = batchSbx.Generation\n\tnewStatus.Replicas = 0\n\tnewStatus.Allocated = 0\n\tnewStatus.Ready = 0\n\tipList := make([]string, len(pods))\n\tfor i, pod := range pods {\n\t\tnewStatus.Replicas++\n\t\tif utils.IsAssigned(pod) {\n\t\t\tnewStatus.Allocated++\n\t\t\tipList[i] = pod.Status.PodIP\n\t\t}\n\t\tif pod.Status.Phase == corev1.PodRunning && utils.IsPodReady(pod) {\n\t\t\tnewStatus.Ready++\n\t\t}\n\t}\n\traw, _ := json.Marshal(ipList)\n\tif batchSbx.Annotations[AnnotationSandboxEndpoints] != string(raw) {\n\t\tpatchData, _ := json.Marshal(map[string]any{\n\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\"annotations\": map[string]string{\n\t\t\t\t\tAnnotationSandboxEndpoints: string(raw),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tobj := &sandboxv1alpha1.BatchSandbox{ObjectMeta: metav1.ObjectMeta{Namespace: batchSbx.Namespace, Name: batchSbx.Name}}\n\t\tif err := r.Patch(ctx, obj, client.RawPatch(types.MergePatchType, patchData)); err != nil {\n\t\t\tlog.Error(err, \"failed to patch annotation\", \"annotation\", AnnotationSandboxEndpoints, \"body\", string(patchData))\n\t\t\taggErrors = append(aggErrors, err)\n\t\t}\n\t}\n\tif !reflect.DeepEqual(newStatus, batchSbx.Status) {\n\t\tlog.Info(\"To update BatchSandbox status\", \"replicas\", newStatus.Replicas, \"allocated\", newStatus.Allocated, \"ready\", newStatus.Ready)\n\t\tif err := r.updateStatus(batchSbx, newStatus); err != nil {\n\t\t\taggErrors = append(aggErrors, err)\n\t\t}\n\t}\n\n\tif taskStrategy.NeedTaskScheduling() {\n\t\t// Because tasks are in-memory and there is no event mechanism, periodic reconciliation is required.\n\t\tDurationStore.Push(types.NamespacedName{Namespace: batchSbx.Namespace, Name: batchSbx.Name}.String(), 3*time.Second)\n\t\tsch, err := r.getTaskScheduler(ctx, batchSbx, pods)\n\t\tif err != nil {\n\t\t\treturn ctrl.Result{}, err\n\t\t}\n\t\tif batchSbx.DeletionTimestamp != nil {\n\t\t\tstoppingTasks := sch.StopTask()\n\t\t\tif len(stoppingTasks) > 0 {\n\t\t\t\tlog.Info(\"stopping tasks\", \"count\", len(stoppingTasks))\n\t\t\t}\n\t\t}\n\t\tnow := time.Now()\n\t\tif err = r.scheduleTasks(ctx, sch, batchSbx); err != nil {\n\t\t\treturn ctrl.Result{}, fmt.Errorf(\"failed to schedule tasks, err %w\", err)\n\t\t} else {\n\t\t\tlog.Info(\"schedule tasks completed\", \"costMs\", time.Since(now).Milliseconds())\n\t\t}\n\t\t// check task cleanup is finished\n\t\tif batchSbx.DeletionTimestamp != nil {\n\t\t\tunfinishedTasks := r.getTasksCleanupUnfinished(batchSbx, sch)\n\t\t\tif len(unfinishedTasks) > 0 {\n\t\t\t\tlog.Info(\"tasks cleanup is unfinished\", \"unfinishedCount\", len(unfinishedTasks))\n\t\t\t} else {\n\t\t\t\tvar err error\n\t\t\t\tif controllerutil.ContainsFinalizer(batchSbx, FinalizerTaskCleanup) {\n\t\t\t\t\terr = utils.UpdateFinalizer(r.Client, batchSbx, utils.RemoveFinalizerOpType, FinalizerTaskCleanup)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tif errors.IsNotFound(err) {\n\t\t\t\t\t\t\terr = nil\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tlog.Error(err, \"failed to remove finalizer\", \"finalizer\", FinalizerTaskCleanup)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif err == nil {\n\t\t\t\t\tr.deleteTaskScheduler(ctx, batchSbx)\n\t\t\t\t\tlog.Info(\"task cleanup is finished, removed finalizer\", \"finalizer\", FinalizerTaskCleanup)\n\t\t\t\t}\n\t\t\t\treturn ctrl.Result{}, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn reconcile.Result{RequeueAfter: DurationStore.Pop(req.String())}, gerrors.Join(aggErrors...)\n}\n\nfunc calPodIndex(poolStrategy strategy.PoolStrategy, batchSbx *sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (map[string]int, error) {\n\tpodIndex := map[string]int{}\n\tif poolStrategy.IsPooledMode() {\n\t\t// cal index from pool alloc result while using pooling\n\t\talloc, err := parseSandboxAllocation(batchSbx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := range alloc.Pods {\n\t\t\tpodIndex[alloc.Pods[i]] = i\n\t\t}\n\t} else {\n\t\tfor i := range pods {\n\t\t\tpo := pods[i]\n\t\t\tidx, err := parseIndex(po)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"batchsandbox: failed to parse %s/%s index %w\", po.Namespace, po.Name, err)\n\t\t\t}\n\t\t\tpodIndex[po.Name] = idx\n\t\t}\n\t}\n\treturn podIndex, nil\n}\n\nfunc (r *BatchSandboxReconciler) listPods(ctx context.Context, poolStrategy strategy.PoolStrategy, batchSbx *sandboxv1alpha1.BatchSandbox) ([]*corev1.Pod, error) {\n\tvar ret []*corev1.Pod\n\tif poolStrategy.IsPooledMode() {\n\t\tvar (\n\t\t\tallocSet    = make(sets.Set[string])\n\t\t\treleasedSet = make(sets.Set[string])\n\t\t)\n\t\talloc, err := parseSandboxAllocation(batchSbx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tallocSet.Insert(alloc.Pods...)\n\n\t\treleased, err := parseSandboxReleased(batchSbx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treleasedSet.Insert(released.Pods...)\n\n\t\tactivePods := allocSet.Difference(releasedSet)\n\t\tfor name := range activePods {\n\t\t\tpod := &corev1.Pod{}\n\t\t\t// TODO maybe performance is problem\n\t\t\tif err := r.Client.Get(ctx, types.NamespacedName{Namespace: batchSbx.Namespace, Name: name}, pod); err != nil {\n\t\t\t\tif errors.IsNotFound(err) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tret = append(ret, pod)\n\t\t}\n\t} else {\n\t\tpodList := &corev1.PodList{}\n\t\tif err := r.Client.List(ctx, podList, &client.ListOptions{\n\t\t\tNamespace:     batchSbx.Namespace,\n\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(batchSbx.UID)}),\n\t\t}); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := range podList.Items {\n\t\t\tret = append(ret, &podList.Items[i])\n\t\t}\n\t}\n\treturn ret, nil\n}\n\nfunc (r *BatchSandboxReconciler) getTaskScheduler(ctx context.Context, batchSbx *sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (taskscheduler.TaskScheduler, error) {\n\tlog := logf.FromContext(ctx)\n\tvar tSch taskscheduler.TaskScheduler\n\tkey := types.NamespacedName{Namespace: batchSbx.Namespace, Name: batchSbx.Name}.String()\n\tval, ok := r.taskSchedulers.Load(key)\n\t// The reconciler guarantees that it will not concurrently reconcile the same BatchSandbox.\n\tif !ok {\n\t\tpolicy := sandboxv1alpha1.TaskResourcePolicyRetain\n\t\tif batchSbx.Spec.TaskResourcePolicyWhenCompleted != nil {\n\t\t\tpolicy = *batchSbx.Spec.TaskResourcePolicyWhenCompleted\n\t\t}\n\t\ttaskStrategy := strategy.NewTaskSchedulingStrategy(batchSbx)\n\t\ttaskSpecs, err := taskStrategy.GenerateTaskSpecs()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsc, err := taskscheduler.NewTaskScheduler(key, taskSpecs, pods, policy, log)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"new task scheduler err %w\", err)\n\t\t}\n\t\tlog.Info(\"successfully created task scheduler\")\n\t\ttSch = sc\n\t\tr.taskSchedulers.Store(key, sc)\n\t} else {\n\t\ttSch, ok = (val.(taskscheduler.TaskScheduler))\n\t\tif !ok {\n\t\t\treturn nil, gerrors.New(\"invalid scheduler type stored\")\n\t\t}\n\t\t// Update the pods list for this scheduler\n\t\ttSch.UpdatePods(pods)\n\t}\n\treturn tSch, nil\n}\n\nfunc (r *BatchSandboxReconciler) deleteTaskScheduler(ctx context.Context, batchSbx *sandboxv1alpha1.BatchSandbox) {\n\tlog := logf.FromContext(ctx)\n\tlog.Info(\"delete task scheduler\")\n\tkey := types.NamespacedName{Namespace: batchSbx.Namespace, Name: batchSbx.Name}.String()\n\tr.taskSchedulers.Delete(key)\n}\n\nfunc (r *BatchSandboxReconciler) scheduleTasks(ctx context.Context, tSch taskscheduler.TaskScheduler, batchSbx *sandboxv1alpha1.BatchSandbox) error {\n\tlog := logf.FromContext(ctx)\n\tif err := tSch.Schedule(); err != nil {\n\t\treturn err\n\t}\n\ttasks := tSch.ListTask()\n\ttoReleasedPods := []string{}\n\tvar (\n\t\trunning, failed, succeed, unknown int32\n\t\tpending                           int32\n\t)\n\tfor i := range len(tasks) {\n\t\ttask := tasks[i]\n\t\tif task.GetPodName() == \"\" {\n\t\t\tpending++\n\t\t} else {\n\t\t\tstate := task.GetState()\n\t\t\tif task.IsResourceReleased() {\n\t\t\t\ttoReleasedPods = append(toReleasedPods, task.GetPodName())\n\t\t\t}\n\t\t\tswitch state {\n\t\t\tcase taskscheduler.RunningTaskState:\n\t\t\t\trunning++\n\t\t\tcase taskscheduler.SucceedTaskState:\n\t\t\t\tsucceed++\n\t\t\tcase taskscheduler.FailedTaskState:\n\t\t\t\tfailed++\n\t\t\tcase taskscheduler.UnknownTaskState:\n\t\t\t\tunknown++\n\t\t\t}\n\t\t}\n\t}\n\tif len(toReleasedPods) > 0 {\n\t\tlog.Info(\"try to release Pods\", \"count\", len(toReleasedPods))\n\t\tif err := r.releasePods(ctx, batchSbx, toReleasedPods); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlog.Info(\"successfully released Pods\", \"count\", len(toReleasedPods))\n\t}\n\toldStatus := batchSbx.Status\n\tnewStatus := oldStatus.DeepCopy()\n\tnewStatus.ObservedGeneration = batchSbx.Generation\n\tnewStatus.TaskRunning = running\n\tnewStatus.TaskFailed = failed\n\tnewStatus.TaskSucceed = succeed\n\tnewStatus.TaskUnknown = unknown\n\tnewStatus.TaskPending = pending\n\tif !reflect.DeepEqual(newStatus, oldStatus) {\n\t\tlog.Info(\"To update BatchSandbox status\", \"replicas\", newStatus.Replicas, \"task_running\", newStatus.TaskRunning, \"task_succeed\", newStatus.TaskSucceed, \"task_failed\", newStatus.TaskFailed, \"task_unknown\", newStatus.TaskUnknown, \"task_pending\", newStatus.TaskPending)\n\t\tif err := r.updateStatus(batchSbx, newStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *BatchSandboxReconciler) getTasksCleanupUnfinished(batchSbx *sandboxv1alpha1.BatchSandbox, tSch taskscheduler.TaskScheduler) []taskscheduler.Task {\n\tvar notReleased []taskscheduler.Task\n\tfor _, task := range tSch.ListTask() {\n\t\tif !task.IsResourceReleased() {\n\t\t\tnotReleased = append(notReleased, task)\n\t\t}\n\t}\n\treturn notReleased\n}\n\nfunc (r *BatchSandboxReconciler) releasePods(ctx context.Context, batchSbx *sandboxv1alpha1.BatchSandbox, toReleasePods []string) error {\n\treleasedSet := make(sets.Set[string])\n\treleased, err := parseSandboxReleased(batchSbx)\n\tif err != nil {\n\t\treturn err\n\t}\n\treleasedSet.Insert(released.Pods...)\n\treleasedSet.Insert(toReleasePods...)\n\tnewRelease := AllocationRelease{\n\t\tPods: sets.List(releasedSet),\n\t}\n\traw, err := json.Marshal(newRelease)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"Failed to marshal released pod names: %v\", err)\n\t}\n\tbody := utils.DumpJSON(struct {\n\t\tMetaData metav1.ObjectMeta `json:\"metadata\"`\n\t}{\n\t\tMetaData: metav1.ObjectMeta{\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tAnnoAllocReleaseKey: string(raw),\n\t\t\t},\n\t\t},\n\t})\n\tb := &sandboxv1alpha1.BatchSandbox{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace: batchSbx.Namespace,\n\t\t\tName:      batchSbx.Name,\n\t\t},\n\t}\n\treturn r.Client.Patch(ctx, b, client.RawPatch(types.MergePatchType, []byte(body)))\n}\n\n// Normal Mode\nfunc (r *BatchSandboxReconciler) scaleBatchSandbox(ctx context.Context, batchSandbox *sandboxv1alpha1.BatchSandbox, podTemplateSpec *corev1.PodTemplateSpec, pods []*corev1.Pod) error {\n\tlog := logf.FromContext(ctx)\n\tindexedPodMap := map[int]*corev1.Pod{}\n\tfor i := range pods {\n\t\tpod := pods[i]\n\t\tBatchSandboxScaleExpectations.ObserveScale(controllerutils.GetControllerKey(batchSandbox), expectations.Create, pod.Name)\n\t\tpods = append(pods, pod)\n\t\tidx, err := parseIndex(pod)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse idx Pod %s, err %w\", pod.Name, err)\n\t\t}\n\t\tindexedPodMap[idx] = pod\n\t}\n\tif satisfied, unsatisfiedDuration, dirtyPods := BatchSandboxScaleExpectations.SatisfiedExpectations(controllerutils.GetControllerKey(batchSandbox)); !satisfied {\n\t\tlog.Info(\"scale expectation is not satisfied\", \"unsatisfiedDuration\", unsatisfiedDuration, \"dirtyPods\", dirtyPods)\n\t\tDurationStore.Push(types.NamespacedName{Namespace: batchSandbox.Namespace, Name: batchSandbox.Name}.String(), expectations.ExpectationTimeout-unsatisfiedDuration)\n\t\treturn nil\n\t}\n\t// TODO consider supply Pods if Pods is deleted unexpectedly\n\tvar needCreateIndex []int\n\t// TODO var needDeleteIndex []int\n\tfor i := 0; i < int(*batchSandbox.Spec.Replicas); i++ {\n\t\t_, ok := indexedPodMap[i]\n\t\tif !ok {\n\t\t\tneedCreateIndex = append(needCreateIndex, i)\n\t\t}\n\t}\n\t// scale\n\tif len(needCreateIndex) > 0 {\n\t\tlog.Info(\"try to create Pods\", \"count\", len(needCreateIndex), \"indexes\", needCreateIndex)\n\t}\n\tfor _, idx := range needCreateIndex {\n\t\tpod, err := utils.GetPodFromTemplate(podTemplateSpec, batchSandbox, metav1.NewControllerRef(batchSandbox, sandboxv1alpha1.SchemeBuilder.GroupVersion.WithKind(\"BatchSandbox\")))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// Apply shard patch if available for this index\n\t\tif len(batchSandbox.Spec.ShardPatches) > 0 && idx < len(batchSandbox.Spec.ShardPatches) {\n\t\t\tpodBytes, err := json.Marshal(pod)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to marshal pod: %w\", err)\n\t\t\t}\n\t\t\tpatch := batchSandbox.Spec.ShardPatches[idx]\n\t\t\tmodifiedPodBytes, err := strategicpatch.StrategicMergePatch(podBytes, patch.Raw, &corev1.Pod{})\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to apply shard patch for index %d: %w\", idx, err)\n\t\t\t}\n\t\t\tif err := json.Unmarshal(modifiedPodBytes, pod); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to unmarshal patched pod for index %d: %w\", idx, err)\n\t\t\t}\n\t\t}\n\t\tif err := ctrl.SetControllerReference(pod, batchSandbox, r.Scheme); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tpod.Labels[LabelBatchSandboxPodIndexKey] = strconv.Itoa(idx)\n\t\tpod.Namespace = batchSandbox.Namespace\n\t\tpod.Name = fmt.Sprintf(\"%s-%d\", batchSandbox.Name, idx)\n\t\tBatchSandboxScaleExpectations.ExpectScale(controllerutils.GetControllerKey(batchSandbox), expectations.Create, pod.Name)\n\t\tif err := r.Create(ctx, pod); err != nil {\n\t\t\tBatchSandboxScaleExpectations.ObserveScale(controllerutils.GetControllerKey(batchSandbox), expectations.Create, pod.Name)\n\t\t\tr.Recorder.Eventf(batchSandbox, corev1.EventTypeWarning, \"FailedCreate\", \"failed to create pod: %v, pod: %v\", err, utils.DumpJSON(pod))\n\t\t\treturn err\n\t\t}\n\t\tr.Recorder.Eventf(batchSandbox, corev1.EventTypeNormal, \"SuccessfulCreate\", \"succeed to create pod %s\", pod.Name)\n\t}\n\treturn nil\n}\n\nfunc parseIndex(pod *corev1.Pod) (int, error) {\n\tif v := pod.Labels[LabelBatchSandboxPodIndexKey]; v != \"\" {\n\t\treturn strconv.Atoi(v)\n\t}\n\tidx := strings.LastIndex(pod.Name, \"-\")\n\tif idx == -1 {\n\t\treturn -1, gerrors.New(\"batchsandbox: Invalid pod Name\")\n\t}\n\treturn strconv.Atoi(pod.Name[idx+1:])\n}\n\nfunc (r *BatchSandboxReconciler) updateStatus(batchSandbox *sandboxv1alpha1.BatchSandbox, newStatus *sandboxv1alpha1.BatchSandboxStatus) error {\n\treturn retry.RetryOnConflict(retry.DefaultBackoff, func() error {\n\t\tclone := &sandboxv1alpha1.BatchSandbox{}\n\t\tif err := r.Get(context.TODO(), types.NamespacedName{Namespace: batchSandbox.Namespace, Name: batchSandbox.Name}, clone); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tclone.Status = *newStatus\n\t\treturn r.Status().Update(context.TODO(), clone)\n\t})\n}\n\n// SetupWithManager sets up the controller with the Manager.\nfunc (r *BatchSandboxReconciler) SetupWithManager(mgr ctrl.Manager) error {\n\treturn ctrl.NewControllerManagedBy(mgr).\n\t\tFor(&sandboxv1alpha1.BatchSandbox{}).\n\t\tNamed(\"batchsandbox\").\n\t\tOwns(&corev1.Pod{}).\n\t\tWithOptions(controller.Options{MaxConcurrentReconciles: 32}).\n\t\tComplete(r)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/batchsandbox_controller_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\tgerrors \"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/golang/mock/gomock\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tv1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/fields\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tk8sruntime \"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/rand\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n\t\"k8s.io/client-go/tools/record\"\n\t\"k8s.io/client-go/util/retry\"\n\t\"k8s.io/utils/ptr\"\n\t\"k8s.io/utils/set\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client/fake\"\n\t\"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/strategy\"\n\ttaskscheduler \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler\"\n\tmock_scheduler \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler/mock\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n)\n\nfunc init() {\n\ttestscheme = k8sruntime.NewScheme()\n\tutilruntime.Must(corev1.AddToScheme(testscheme))\n\tutilruntime.Must(sandboxv1alpha1.AddToScheme(testscheme))\n}\n\nvar testscheme *k8sruntime.Scheme\n\nvar _ = Describe(\"BatchSandbox Controller\", func() {\n\tvar (\n\t\ttimeout  = 30 * time.Second\n\t\tinterval = 5 * time.Second\n\t)\n\t// None Pooling Mode\n\tContext(\"When create new batch sandbox, create pod base on pod template\", func() {\n\t\tconst resourceBaseName = \"test-batch-sandbox\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceBaseName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\ttypeNamespacedName.Name = fmt.Sprintf(\"%s-%s\", resourceBaseName, rand.String(5))\n\t\t\tBy(fmt.Sprintf(\"creating the custom resource %s for the Kind BatchSandbox\", typeNamespacedName))\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tReplicas: ptr.To(int32(3)),\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).Should(Succeed())\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tBy(fmt.Sprintf(\"wait the custom resource %s created\", typeNamespacedName))\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tBy(fmt.Sprintf(\"Cleanup the specific resource instance BatchSandbox %s\", typeNamespacedName))\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\t\tIt(\"should successfully create pod, update batch sandbox status, endpoints info\", func() {\n\t\t\twantIPSet := make(set.Set[string])\n\t\t\tpodIPMap := make(map[string]string)\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tallPods := &corev1.PodList{}\n\t\t\t\tg.Expect(k8sClient.List(ctx, allPods, &client.ListOptions{Namespace: bs.Namespace})).Should(Succeed())\n\t\t\t\tpods := []*corev1.Pod{}\n\t\t\t\tfor i := range allPods.Items {\n\t\t\t\t\tpo := &allPods.Items[i]\n\t\t\t\t\tif metav1.IsControlledBy(po, bs) {\n\t\t\t\t\t\tpods = append(pods, po)\n\t\t\t\t\t\tif po.Status.PodIP != \"\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif i%2 == 0 {\n\t\t\t\t\t\t\tmockIP := randomIPv4().String()\n\t\t\t\t\t\t\twantIPSet.Insert(mockIP)\n\t\t\t\t\t\t\tpodIPMap[po.Name] = mockIP\n\t\t\t\t\t\t\tpo.Status.PodIP = mockIP\n\t\t\t\t\t\t\tpo.Status.Phase = corev1.PodRunning\n\t\t\t\t\t\t\tpo.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}\n\t\t\t\t\t\t\tExpect(k8sClient.Status().Update(context.Background(), po)).To(Succeed())\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(len(pods)).To(Equal(int(*bs.Spec.Replicas)))\n\t\t\t\tg.Expect(bs.Status.ObservedGeneration).To(Equal(bs.Generation))\n\t\t\t\tg.Expect(bs.Status.Replicas).To(Equal(*bs.Spec.Replicas))\n\n\t\t\t\tgotIPs := []string{}\n\t\t\t\tif raw := bs.Annotations[AnnotationSandboxEndpoints]; raw != \"\" {\n\t\t\t\t\tjson.Unmarshal([]byte(raw), &gotIPs)\n\t\t\t\t}\n\n\t\t\t\tpodIndex, err := calPodIndex(strategy.NewPoolStrategy(bs), bs, pods)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\texpectedIPs := make([]string, len(pods))\n\t\t\t\tfor _, pod := range pods {\n\t\t\t\t\tidx, ok := podIndex[pod.Name]\n\t\t\t\t\tg.Expect(ok).To(BeTrue(), fmt.Sprintf(\"pod %s should have index\", pod.Name))\n\t\t\t\t\tif pod.Status.PodIP != \"\" {\n\t\t\t\t\t\texpectedIPs[idx] = pod.Status.PodIP\n\t\t\t\t\t} else {\n\t\t\t\t\t\texpectedIPs[idx] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(gotIPs).To(Equal(expectedIPs), \"endpoints should be ordered by pod index, unassigned pods should have empty string\")\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully correctly create new Pod and update batch sandbox status when user scale out\", func() {\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, bs)).Should(Succeed())\n\t\t\t*bs.Spec.Replicas = *bs.Spec.Replicas + 1 // scale out\n\t\t\tExpect(k8sClient.Update(ctx, bs)).Should(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbatchsandbox := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, batchsandbox); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(batchsandbox.Status.ObservedGeneration).To(Equal(batchsandbox.Generation))\n\t\t\t\tg.Expect(batchsandbox.Status.Replicas).To(Equal(*batchsandbox.Spec.Replicas))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpods := &v1.PodList{}\n\t\t\t\tg.Expect(k8sClient.List(ctx, pods, &client.ListOptions{\n\t\t\t\t\tNamespace:     bs.Namespace,\n\t\t\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(bs.UID)}),\n\t\t\t\t})).Should(Succeed())\n\t\t\t\tg.Expect(int32(len(pods.Items))).To(Equal(*bs.Spec.Replicas))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully correctly supply Pod when pod is deleted unexpectedly\", func() {\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(bs.Status.ObservedGeneration).To(Equal(bs.Generation))\n\t\t\t\tg.Expect(bs.Status.Replicas).To(Equal(*bs.Spec.Replicas))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, bs)).Should(Succeed())\n\t\t\tpods := &v1.PodList{}\n\t\t\tExpect(k8sClient.List(ctx, pods, &client.ListOptions{\n\t\t\t\tNamespace:     bs.Namespace,\n\t\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(bs.UID)}),\n\t\t\t})).Should(Succeed())\n\t\t\tExpect(int32(len(pods.Items))).To(Equal(*bs.Spec.Replicas))\n\t\t\t// delete first pod\n\t\t\toldPod := pods.Items[0]\n\t\t\tExpect(k8sClient.Delete(ctx, &oldPod)).Should(Succeed())\n\t\t\t// wait supply pod\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tnewPod := &corev1.Pod{}\n\t\t\t\tif err := k8sClient.Get(ctx, types.NamespacedName{\n\t\t\t\t\tNamespace: bs.Namespace,\n\t\t\t\t\tName:      oldPod.Name,\n\t\t\t\t}, newPod); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(newPod.CreationTimestamp).NotTo(Equal(oldPod.CreationTimestamp))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should delete batch sandbox and related Pods for expired batch sandbox\", func() {\n\t\t\tExpect(retry.RetryOnConflict(retry.DefaultRetry, func() error {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tbs.Spec.ExpireTime = &metav1.Time{Time: time.Now().Add(3 * time.Second)}\n\t\t\t\treturn k8sClient.Update(ctx, bs)\n\t\t\t})).Should(Succeed())\n\n\t\t\tEventually(\n\t\t\t\tfunc(g Gomega) {\n\t\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\t\tg.Expect(errors.IsNotFound(k8sClient.Get(ctx, typeNamespacedName, bs))).To(BeTrue())\n\t\t\t\t\tallPods := &corev1.PodList{}\n\t\t\t\t\tg.Expect(k8sClient.List(ctx, allPods, &client.ListOptions{Namespace: bs.Namespace})).Should(Succeed())\n\t\t\t\t\tpods := []*corev1.Pod{}\n\t\t\t\t\tfor i := range allPods.Items {\n\t\t\t\t\t\tpo := &allPods.Items[i]\n\t\t\t\t\t\tif metav1.IsControlledBy(po, bs) {\n\t\t\t\t\t\t\tpods = append(pods, po)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tg.Expect(len(pods)).To(BeZero())\n\t\t\t\t},\n\t\t\t\ttimeout, interval).Should(Succeed())\n\t\t})\n\t})\n\n\t// None Pooling Mode - Heterogeneous Pods\n\tContext(\"When create new batch sandbox with ShardPatches, create heterogeneous pods\", func() {\n\t\tconst resourceBaseName = \"test-batch-sandbox-shard\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceBaseName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\ttypeNamespacedName.Name = fmt.Sprintf(\"%s-%s\", resourceBaseName, rand.String(5))\n\t\t\tBy(fmt.Sprintf(\"creating the custom resource %s for the Kind BatchSandbox with ShardPatches\", typeNamespacedName))\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tReplicas: ptr.To(int32(3)),\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:    \"main\",\n\t\t\t\t\t\t\t\t\tImage:   \"example.com\",\n\t\t\t\t\t\t\t\t\tCommand: []string{\"default-command\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tShardPatches: []runtime.RawExtension{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRaw: []byte(`{\"spec\":{\"containers\":[{\"name\":\"main\",\"command\":[\"custom-command-0\"]}]}}`),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRaw: []byte(`{\"spec\":{\"containers\":[{\"name\":\"main\",\"command\":[\"custom-command-1\"]}]}}`),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRaw: []byte(`{\"spec\":{\"containers\":[{\"name\":\"main\",\"command\":[\"custom-command-2\"]}]}}`),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).Should(Succeed())\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tBy(fmt.Sprintf(\"wait the custom resource %s created\", typeNamespacedName))\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t} else {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tBy(fmt.Sprintf(\"Cleanup the specific resource instance BatchSandbox %s\", typeNamespacedName))\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully create heterogeneous pods with different commands\", func() {\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tallPods := &corev1.PodList{}\n\t\t\t\tg.Expect(k8sClient.List(ctx, allPods, &client.ListOptions{Namespace: bs.Namespace})).Should(Succeed())\n\t\t\t\tpods := []*corev1.Pod{}\n\t\t\t\tfor i := range allPods.Items {\n\t\t\t\t\tpo := &allPods.Items[i]\n\t\t\t\t\tif metav1.IsControlledBy(po, bs) {\n\t\t\t\t\t\tpods = append(pods, po)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(len(pods)).To(Equal(int(*bs.Spec.Replicas)))\n\n\t\t\t\t// Verify each pod has the correct patched command\n\t\t\t\tfor _, pod := range pods {\n\t\t\t\t\tindexLabel := pod.Labels[LabelBatchSandboxPodIndexKey]\n\t\t\t\t\tg.Expect(indexLabel).NotTo(BeEmpty())\n\t\t\t\t\tidx, err := strconv.Atoi(indexLabel)\n\t\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\t\tg.Expect(idx).To(BeNumerically(\">=\", 0))\n\t\t\t\t\tg.Expect(idx).To(BeNumerically(\"<\", int(*bs.Spec.Replicas)))\n\n\t\t\t\t\t// Verify the command was patched\n\t\t\t\t\tg.Expect(len(pod.Spec.Containers)).To(BeNumerically(\">\", 0))\n\t\t\t\t\tmainContainer := pod.Spec.Containers[0]\n\t\t\t\t\texpectedCommand := fmt.Sprintf(\"custom-command-%d\", idx)\n\t\t\t\t\tg.Expect(mainContainer.Command).To(Equal([]string{expectedCommand}))\n\t\t\t\t}\n\n\t\t\t\tg.Expect(bs.Status.ObservedGeneration).To(Equal(bs.Generation))\n\t\t\t\tg.Expect(bs.Status.Replicas).To(Equal(*bs.Spec.Replicas))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t})\n\n\t// Pooling Mode\n\tContext(\"When create new batch sandbox, get pod from pool\", func() {\n\t\tconst resourceBaseName = \"test-batch-sandbox-pooling-mode\"\n\t\tvar replicas int32 = 3\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceBaseName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\t\tBeforeEach(func() {\n\t\t\ttypeNamespacedName.Name = fmt.Sprintf(\"%s-%s\", resourceBaseName, rand.String(5))\n\t\t\tBy(fmt.Sprintf(\"creating the custom resource %s for the Kind BatchSandbox\", typeNamespacedName))\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tReplicas: ptr.To(replicas),\n\t\t\t\t\tPoolRef:  \"test-pool\",\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).Should(Succeed())\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tBy(fmt.Sprintf(\"wait the custom resource %s created\", typeNamespacedName))\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t}\n\t\t\tBy(fmt.Sprintf(\"Cleanup the specific resource instance BatchSandbox %s\", typeNamespacedName))\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully update batch sandbox status, sbx endpoints info when get pod from pool alloc\", func() {\n\t\t\t// mock pool allocation\n\t\t\tmockPods := []string{}\n\t\t\tfor i := range replicas {\n\t\t\t\tpo := &corev1.Pod{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t\t\tName:      fmt.Sprintf(\"test-pod-%d\", i),\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t{Name: \"main\", Image: \"test\", Command: []string{\"hello\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tmockPods = append(mockPods, po.Name)\n\t\t\t\tExpect(k8sClient.Create(context.Background(), po)).To(Succeed())\n\t\t\t\tif i%2 == 0 {\n\t\t\t\t\tpo.Spec.NodeName = \"node-1.2.3.4\"\n\t\t\t\t\tpo.Status.PodIP = fmt.Sprintf(\"1.2.3.%d\", i+1)\n\t\t\t\t\tpo.Status.Phase = corev1.PodRunning\n\t\t\t\t\tpo.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}\n\t\t\t\t}\n\t\t\t\tExpect(k8sClient.Status().Update(context.Background(), po)).To(Succeed())\n\t\t\t}\n\t\t\tExpect(retry.RetryOnConflict(retry.DefaultRetry, func() error {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tsetSandboxAllocation(bs, SandboxAllocation{Pods: mockPods})\n\t\t\t\treturn k8sClient.Update(ctx, bs)\n\t\t\t})).Should(Succeed())\n\t\t\tBy(fmt.Sprintf(\"Mock pool allocate Pod %v for BatchSandbox %s\", mockPods, typeNamespacedName))\n\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(bs.Status.ObservedGeneration).To(Equal(bs.Generation))\n\t\t\t\tg.Expect(bs.Status.Replicas).To(Equal(*bs.Spec.Replicas))\n\n\t\t\t\tgotIPs := []string{}\n\t\t\t\tif raw := bs.Annotations[AnnotationSandboxEndpoints]; raw != \"\" {\n\t\t\t\t\tjson.Unmarshal([]byte(raw), &gotIPs)\n\t\t\t\t}\n\n\t\t\t\talloc, err := parseSandboxAllocation(bs)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\texpectedIPs := make([]string, len(alloc.Pods))\n\t\t\t\tfor idx, podName := range alloc.Pods {\n\t\t\t\t\tpod := &corev1.Pod{}\n\t\t\t\t\terr := k8sClient.Get(ctx, types.NamespacedName{Namespace: bs.Namespace, Name: podName}, pod)\n\t\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\t\tif pod.Spec.NodeName != \"\" || pod.Status.PodIP != \"\" {\n\t\t\t\t\t\texpectedIPs[idx] = pod.Status.PodIP\n\t\t\t\t\t} else {\n\t\t\t\t\t\texpectedIPs[idx] = \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(gotIPs).To(Equal(expectedIPs), \"endpoints should be ordered by pool allocation order, unassigned pods should have empty string\")\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t})\n})\n\nfunc randomIPv4() net.IP {\n\trand.Seed(time.Now().UnixNano())\n\tip := make(net.IP, 4)\n\tfor i := range ip {\n\t\tip[i] = byte(rand.Intn(256))\n\t}\n\treturn ip\n}\n\nvar _ = Describe(\"BatchSandbox Task Scheduler\", func() {\n\tvar (\n\t\ttimeout  = 30 * time.Second\n\t\tinterval = 5 * time.Second\n\t)\n\t// None Pooling mode\n\tContext(\"When create new batch sandbox, create pod base on pod template\", func() {\n\t\tconst resourceBaseName = \"test-task-batch-sandbox\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceBaseName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\ttypeNamespacedName.Name = fmt.Sprintf(\"%s-%s\", resourceBaseName, rand.String(5))\n\t\t\tBy(fmt.Sprintf(\"creating the custom resource %s for the Kind BatchSandbox\", typeNamespacedName))\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tReplicas: ptr.To(int32(1)),\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{\n\t\t\t\t\t\tSpec: sandboxv1alpha1.TaskSpec{\n\t\t\t\t\t\t\tProcess: &sandboxv1alpha1.ProcessTask{\n\t\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).Should(Succeed())\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tBy(fmt.Sprintf(\"wait the custom resource %s created\", typeNamespacedName))\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.BatchSandbox{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t} else {\n\t\t\t\t// resource is already deleted\n\t\t\t\treturn\n\t\t\t}\n\t\t\tBy(fmt.Sprintf(\"Cleanup the specific resource instance BatchSandbox %s\", typeNamespacedName))\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully add task cleanup finalizer\", func() {\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(controllerutil.ContainsFinalizer(bs, FinalizerTaskCleanup)).To(BeTrue())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully update task status(task_pending=1), because all pods is unassigned\", func() {\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, bs); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tg.Expect(bs.Status.ObservedGeneration).To(Equal(bs.Generation))\n\t\t\t\tg.Expect(bs.Status.Replicas).To(Equal(*bs.Spec.Replicas))\n\n\t\t\t\tg.Expect(bs.Status.TaskPending).To(Equal(*bs.Spec.Replicas))\n\t\t\t\tg.Expect(bs.Status.TaskRunning).To(Equal(int32(0)))\n\t\t\t\tg.Expect(bs.Status.TaskSucceed).To(Equal(int32(0)))\n\t\t\t\tg.Expect(bs.Status.TaskFailed).To(Equal(int32(0)))\n\t\t\t\tg.Expect(bs.Status.TaskUnknown).To(Equal(int32(0)))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully delete BatchSandbox when all tasks(including pending task) cleanup is finished\", func() {\n\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, bs)).To(Succeed())\n\t\t\t\tg.Expect(controllerutil.ContainsFinalizer(bs, FinalizerTaskCleanup)).To(BeTrue())\n\t\t\t}, timeout, interval).Should(Succeed())\n\n\t\t\tBy(fmt.Sprintf(\"try to Delete BatchSandbox %s\", typeNamespacedName))\n\t\t\tExpect(k8sClient.Delete(ctx, bs)).To(Succeed())\n\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tbs := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, bs)\n\t\t\t\tg.Expect(errors.IsNotFound(err)).To(BeTrue())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t})\n})\n\nfunc TestBatchSandboxReconciler_scheduleTasks(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tdefer ctrl.Finish()\n\tvar (\n\t\tfakeBatchSandbox = &sandboxv1alpha1.BatchSandbox{\n\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\tAPIVersion: sandboxv1alpha1.GroupVersion.String(),\n\t\t\t\tKind:       \"BatchSandbox\",\n\t\t\t},\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"test-batch-sandbox\",\n\t\t\t},\n\t\t\tSpec:   sandboxv1alpha1.BatchSandboxSpec{},\n\t\t\tStatus: sandboxv1alpha1.BatchSandboxStatus{},\n\t\t}\n\t)\n\ttype fields struct {\n\t\tClient         client.Client\n\t\tScheme         *runtime.Scheme\n\t\tRecorder       record.EventRecorder\n\t\ttaskSchedulers sync.Map\n\t}\n\ttype args struct {\n\t\tctx      context.Context\n\t\ttSch     taskscheduler.TaskScheduler\n\t\tbatchSbx *sandboxv1alpha1.BatchSandbox\n\t}\n\ttests := []struct {\n\t\tname                string\n\t\tfields              fields\n\t\targs                args\n\t\twantErr             bool\n\t\tbatchSandboxChecker func(bsbx *sandboxv1alpha1.BatchSandbox) error\n\t}{\n\t\t{\n\t\t\tname: \"schedule err\",\n\t\t\targs: args{\n\t\t\t\ttSch: func() taskscheduler.TaskScheduler {\n\t\t\t\t\tmockSche := mock_scheduler.NewMockTaskScheduler(ctrl)\n\t\t\t\t\tmockSche.EXPECT().Schedule().Return(gerrors.New(\"err\")).Times(1)\n\t\t\t\t\treturn mockSche\n\t\t\t\t}(),\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"tasks, succeed=1; releasedPod=1\",\n\t\t\tfields: fields{\n\t\t\t\tClient: fake.NewClientBuilder().WithScheme(testscheme).WithObjects(fakeBatchSandbox).WithStatusSubresource(fakeBatchSandbox).Build(),\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\ttSch: func() taskscheduler.TaskScheduler {\n\t\t\t\t\tmockSche := mock_scheduler.NewMockTaskScheduler(ctrl)\n\t\t\t\t\tmockSche.EXPECT().Schedule().Return(nil).Times(1)\n\t\t\t\t\tmockTask := mock_scheduler.NewMockTask(ctrl)\n\t\t\t\t\tmockTask.EXPECT().GetState().Return(taskscheduler.SucceedTaskState).Times(1)\n\t\t\t\t\tmockTask.EXPECT().IsResourceReleased().Return(true).Times(1)\n\t\t\t\t\tmockTask.EXPECT().GetPodName().Return(\"pod-0\").AnyTimes()\n\t\t\t\t\tmockSche.EXPECT().ListTask().Return([]taskscheduler.Task{mockTask}).Times(1)\n\t\t\t\t\treturn mockSche\n\t\t\t\t}(),\n\t\t\t\tbatchSbx: fakeBatchSandbox.DeepCopy(),\n\t\t\t},\n\t\t\tbatchSandboxChecker: func(bsbx *sandboxv1alpha1.BatchSandbox) error {\n\t\t\t\trelease, err := parseSandboxReleased(bsbx)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif len(release.Pods) != 1 || release.Pods[0] != \"pod-0\" {\n\t\t\t\t\treturn fmt.Errorf(\"expect pod-0, actual %v\", release.Pods)\n\t\t\t\t}\n\t\t\t\t//  check status\n\t\t\t\tif bsbx.Status.TaskSucceed != 1 {\n\t\t\t\t\treturn fmt.Errorf(\"expect status.succeed=1, actual %d\", bsbx.Status.TaskRunning)\n\t\t\t\t}\n\t\t\t\tif bsbx.Status.TaskRunning != 0 || bsbx.Status.TaskFailed != 0 || bsbx.Status.TaskUnknown != 0 {\n\t\t\t\t\treturn fmt.Errorf(\"expect status.running=0,failed=0,unknown=0, actual %v\", bsbx.Status)\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t},\n\t\t},\n\t}\n\tfor i := range tests {\n\t\ttt := &tests[i]\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tr := &BatchSandboxReconciler{\n\t\t\t\tClient:   tt.fields.Client,\n\t\t\t\tScheme:   tt.fields.Scheme,\n\t\t\t\tRecorder: tt.fields.Recorder,\n\t\t\t}\n\t\t\tif err := r.scheduleTasks(tt.args.ctx, tt.args.tSch, tt.args.batchSbx); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"BatchSandboxReconciler.scheduleTasks() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.batchSandboxChecker != nil {\n\t\t\t\tbsbx := &sandboxv1alpha1.BatchSandbox{}\n\t\t\t\tif err := tt.fields.Client.Get(ctx, types.NamespacedName{Namespace: tt.args.batchSbx.Namespace, Name: tt.args.batchSbx.Name}, bsbx); err != nil {\n\t\t\t\t\tt.Errorf(\"BatchSandboxReconciler Get() error = %v, wantErr %v\", err, nil)\n\t\t\t\t}\n\t\t\t\tif err := tt.batchSandboxChecker(bsbx); err != nil {\n\t\t\t\t\tt.Errorf(\"BatchSandboxReconciler batchSandboxChecker() error = %v, wantErr %v\", err, nil)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_parseIndex(t *testing.T) {\n\ttype args struct {\n\t\tpod *corev1.Pod\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"from label\",\n\t\t\targs: args{\n\t\t\t\tpod: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{LabelBatchSandboxPodIndexKey: \"1\"},\n\t\t\t\t\tName: \"sbx-0\"}},\n\t\t\t},\n\t\t\twant: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"from name\",\n\t\t\targs: args{\n\t\t\t\tpod: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"sbx-0\"}},\n\t\t\t},\n\t\t\twant: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"invalid name\",\n\t\t\targs: args{\n\t\t\t\tpod: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"sbx\"}},\n\t\t\t},\n\t\t\twant:    -1,\n\t\t\twantErr: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseIndex(tt.args.pod)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"parseIndex() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseIndex() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_calPodIndex(t *testing.T) {\n\ttype args struct {\n\t\tbatchSbx *sandboxv1alpha1.BatchSandbox\n\t\tpods     []*corev1.Pod\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    map[string]int\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"pool mode - valid allocation\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tAnnoAllocStatusKey: `{\"pods\":[\"pod-0\",\"pod-1\",\"pod-2\"]}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tPoolRef: \"test-pool\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-0\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]int{\n\t\t\t\t\"pod-0\": 0,\n\t\t\t\t\"pod-1\": 1,\n\t\t\t\t\"pod-2\": 2,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"pool mode - allocation annotation missing\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tPoolRef: \"test-pool\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-0\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    map[string]int{},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"pool mode - invalid allocation json\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tAnnoAllocStatusKey: `invalid-json`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tPoolRef: \"test-pool\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{},\n\t\t\t},\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"pool mode - pods not in allocation list\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tAnnoAllocStatusKey: `{\"pods\":[\"pod-0\",\"pod-1\"]}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tPoolRef: \"test-pool\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-0\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]int{\n\t\t\t\t\"pod-0\": 0,\n\t\t\t\t\"pod-1\": 1,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-pool mode - parse from pod labels\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tReplicas: ptr.To(int32(3)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"test-batch-0\",\n\t\t\t\t\t\tLabels: map[string]string{LabelBatchSandboxPodIndexKey: \"0\"},\n\t\t\t\t\t}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"test-batch-1\",\n\t\t\t\t\t\tLabels: map[string]string{LabelBatchSandboxPodIndexKey: \"1\"},\n\t\t\t\t\t}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"test-batch-2\",\n\t\t\t\t\t\tLabels: map[string]string{LabelBatchSandboxPodIndexKey: \"2\"},\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]int{\n\t\t\t\t\"test-batch-0\": 0,\n\t\t\t\t\"test-batch-1\": 1,\n\t\t\t\t\"test-batch-2\": 2,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-pool mode - parse from pod names\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tReplicas: ptr.To(int32(3)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"test-batch-0\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"test-batch-1\"}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"test-batch-2\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]int{\n\t\t\t\t\"test-batch-0\": 0,\n\t\t\t\t\"test-batch-1\": 1,\n\t\t\t\t\"test-batch-2\": 2,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-pool mode - invalid pod name\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tReplicas: ptr.To(int32(1)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"invalid-name-no-index\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"non-pool mode - empty pods list\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tReplicas: ptr.To(int32(0)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{},\n\t\t\t},\n\t\t\twant:    map[string]int{},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-pool mode - mixed label and name parsing\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-batch\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tReplicas: ptr.To(int32(3)),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tpods: []*corev1.Pod{\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"test-batch-0\",\n\t\t\t\t\t\tLabels: map[string]string{LabelBatchSandboxPodIndexKey: \"5\"},\n\t\t\t\t\t}},\n\t\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"test-batch-1\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]int{\n\t\t\t\t\"test-batch-0\": 5,\n\t\t\t\t\"test-batch-1\": 1,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tpoolStrategy := strategy.NewPoolStrategy(tt.args.batchSbx)\n\t\t\tgot, err := calPodIndex(poolStrategy, tt.args.batchSbx, tt.args.pods)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"calPodIndex() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"calPodIndex() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/pool_controller.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\tgerrors \"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/equality\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/fields\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/json\"\n\t\"k8s.io/client-go/tools/record\"\n\t\"k8s.io/client-go/util/retry\"\n\tctrl \"sigs.k8s.io/controller-runtime\"\n\t\"sigs.k8s.io/controller-runtime/pkg/builder\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\t\"sigs.k8s.io/controller-runtime/pkg/event\"\n\t\"sigs.k8s.io/controller-runtime/pkg/handler\"\n\tlogf \"sigs.k8s.io/controller-runtime/pkg/log\"\n\t\"sigs.k8s.io/controller-runtime/pkg/predicate\"\n\t\"sigs.k8s.io/controller-runtime/pkg/reconcile\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tcontrollerutils \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/controller\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n)\n\nconst (\n\tdefaultRetryTime = 5 * time.Second\n)\n\nconst (\n\tLabelPoolName     = \"sandbox.opensandbox.io/pool-name\"\n\tLabelPoolRevision = \"sandbox.opensandbox.io/pool-revision\"\n)\n\nvar (\n\tPoolScaleExpectations = expectations.NewScaleExpectations()\n)\n\n// PoolReconciler reconciles a Pool object\ntype PoolReconciler struct {\n\tclient.Client\n\tScheme    *runtime.Scheme\n\tRecorder  record.EventRecorder\n\tAllocator Allocator\n}\n\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools,verbs=get;list;watch;create;update;patch;delete\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools/status,verbs=get;update;patch\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=pools/finalizers,verbs=update\n// +kubebuilder:rbac:groups=sandbox.opensandbox.io,resources=batchsandboxes,verbs=get;list;watch;patch\n// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete\n// +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch\n// +kubebuilder:rbac:groups=core,resources=events,verbs=get;list;watch;create;update;patch;delete\n\nfunc (r *PoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {\n\tlog := logf.FromContext(ctx)\n\t// Fetch the Pool instance\n\tpool := &sandboxv1alpha1.Pool{}\n\tif err := r.Get(ctx, req.NamespacedName, pool); err != nil {\n\t\tif errors.IsNotFound(err) {\n\t\t\t// Pool resource not found, could have been deleted\n\t\t\tcontrollerKey := req.NamespacedName.String()\n\t\t\tPoolScaleExpectations.DeleteExpectations(controllerKey)\n\t\t\tlog.Info(\"Pool resource not found, cleaned up scale expectations\", \"pool\", controllerKey)\n\t\t\treturn ctrl.Result{}, nil\n\t\t}\n\t\t// Error reading the object - requeue the request\n\t\tlog.Error(err, \"Failed to get Pool\")\n\t\treturn ctrl.Result{}, err\n\t}\n\tif !pool.DeletionTimestamp.IsZero() {\n\t\tcontrollerKey := controllerutils.GetControllerKey(pool)\n\t\tPoolScaleExpectations.DeleteExpectations(controllerKey)\n\t\tlog.Info(\"Pool resource is being deleted, cleaned up scale expectations\", \"pool\", controllerKey)\n\t\treturn ctrl.Result{}, nil\n\t}\n\n\t// List all pods of the pool\n\tpodList := &corev1.PodList{}\n\tif err := r.List(ctx, podList, &client.ListOptions{\n\t\tNamespace:     pool.Namespace,\n\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),\n\t}); err != nil {\n\t\tlog.Error(err, \"Failed to list pods\")\n\t\treturn reconcile.Result{}, err\n\t}\n\tpods := make([]*corev1.Pod, 0, len(podList.Items))\n\tfor i := range podList.Items {\n\t\tpod := podList.Items[i]\n\t\tPoolScaleExpectations.ObserveScale(controllerutils.GetControllerKey(pool), expectations.Create, pod.Name)\n\t\tif pod.DeletionTimestamp.IsZero() {\n\t\t\tpods = append(pods, &pod)\n\t\t}\n\t}\n\n\t// List all batch sandboxes  ref to the pool\n\tbatchSandboxList := &sandboxv1alpha1.BatchSandboxList{}\n\tif err := r.List(ctx, batchSandboxList, &client.ListOptions{\n\t\tNamespace:     pool.Namespace,\n\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForPoolRef: pool.Name}),\n\t}); err != nil {\n\t\tlog.Error(err, \"Failed to list batch sandboxes\")\n\t\treturn reconcile.Result{}, err\n\t}\n\tbatchSandboxes := make([]*sandboxv1alpha1.BatchSandbox, 0, len(batchSandboxList.Items))\n\tfor i := range batchSandboxList.Items {\n\t\tbatchSandbox := batchSandboxList.Items[i]\n\t\tif batchSandbox.Spec.Template != nil {\n\t\t\tcontinue\n\t\t}\n\t\tbatchSandboxes = append(batchSandboxes, &batchSandbox)\n\t} // Main reconciliation logic\n\treturn r.reconcilePool(ctx, pool, batchSandboxes, pods)\n}\n\n// reconcilePool contains the main reconciliation logic\nfunc (r *PoolReconciler) reconcilePool(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (ctrl.Result, error) {\n\tlog := logf.FromContext(ctx)\n\tvar result ctrl.Result\n\n\terr := retry.RetryOnConflict(retry.DefaultBackoff, func() error {\n\t\t// 1. Get latest Pool CR\n\t\tlatestPool := &sandboxv1alpha1.Pool{}\n\t\tif err := r.Get(ctx, client.ObjectKeyFromObject(pool), latestPool); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 2. Schedule and allocate\n\t\tpodAllocation, idlePods, supplySandbox, poolDirty, err := r.scheduleSandbox(ctx, latestPool, batchSandboxes, pods)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tneedReconcile := false\n\t\tdelay := time.Duration(0)\n\t\tif supplySandbox > 0 && len(idlePods) > 0 { // Some idle pods may be pending, retry schedule later.\n\t\t\tneedReconcile = true\n\t\t\tdelay = defaultRetryTime\n\t\t}\n\t\tif int32(len(idlePods)) >= supplySandbox { // Some pods may be pending, no need to create again.\n\t\t\tsupplySandbox = 0\n\t\t} else {\n\t\t\tsupplySandbox -= int32(len(idlePods))\n\t\t}\n\n\t\t// 3. Persist allocation if needed (Update Annotations)\n\t\tif poolDirty {\n\t\t\tif err := r.Allocator.PersistPoolAllocation(ctx, latestPool, &AllocStatus{PodAllocation: podAllocation}); err != nil {\n\t\t\t\tlog.Error(err, \"Failed to persist pool allocation\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// 4. Update revision and scale (Scaling involves Pod creation/deletion, not Pool CR update)\n\t\tlatestRevision, err := r.calculateRevision(latestPool)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlatestIdlePods, deleteOld, supplyNew := r.updatePool(latestRevision, pods, idlePods)\n\n\t\targs := &scaleArgs{\n\t\t\tlatestRevision: latestRevision,\n\t\t\tpool:           latestPool,\n\t\t\tpods:           pods,\n\t\t\tallocatedCnt:   int32(len(podAllocation)),\n\t\t\tidlePods:       latestIdlePods,\n\t\t\tredundantPods:  deleteOld,\n\t\t\tsupplyCnt:      supplySandbox + supplyNew,\n\t\t}\n\t\tif err := r.scalePool(ctx, args); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 5. Update Status (using latestPool which has updated ResourceVersion)\n\t\tif err := r.updatePoolStatus(ctx, latestRevision, latestPool, pods, podAllocation); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif needReconcile {\n\t\t\tresult = ctrl.Result{RequeueAfter: delay}\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn result, err\n}\n\nfunc (r *PoolReconciler) calculateRevision(pool *sandboxv1alpha1.Pool) (string, error) {\n\ttemplate, err := json.Marshal(pool.Spec.Template)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\trevision := sha256.Sum256(template)\n\treturn hex.EncodeToString(revision[:8]), nil\n}\n\n// SetupWithManager sets up the controller with the Manager.\n// Todo pod deletion expectations\nfunc (r *PoolReconciler) SetupWithManager(mgr ctrl.Manager) error {\n\tfilterBatchSandbox := predicate.Funcs{\n\t\tCreateFunc: func(e event.CreateEvent) bool {\n\t\t\tbsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tif !ok {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn bsb.Spec.PoolRef != \"\"\n\t\t},\n\t\tUpdateFunc: func(e event.UpdateEvent) bool {\n\t\t\toldObj, okOld := e.ObjectOld.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tnewObj, okNew := e.ObjectNew.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tif !okOld || !okNew {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tif newObj.Spec.PoolRef == \"\" {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\toldVal := oldObj.Annotations[AnnoAllocReleaseKey]\n\t\t\tnewVal := newObj.Annotations[AnnoAllocReleaseKey]\n\t\t\tif oldVal != newVal {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif oldObj.Spec.Replicas != newObj.Spec.Replicas {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t\tDeleteFunc: func(e event.DeleteEvent) bool {\n\t\t\tbsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tif !ok {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn bsb.Spec.PoolRef != \"\"\n\t\t},\n\t\tGenericFunc: func(e event.GenericEvent) bool {\n\t\t\tbsb, ok := e.Object.(*sandboxv1alpha1.BatchSandbox)\n\t\t\tif !ok {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn bsb.Spec.PoolRef != \"\"\n\t\t},\n\t}\n\n\tfindPoolForBatchSandbox := func(ctx context.Context, obj client.Object) []reconcile.Request {\n\t\tlog := logf.FromContext(ctx)\n\t\tbatchSandbox, ok := obj.(*sandboxv1alpha1.BatchSandbox)\n\t\tif !ok {\n\t\t\tlog.Error(nil, \"Invalid object type, expected BatchSandbox\")\n\t\t\treturn nil\n\t\t}\n\t\treturn []reconcile.Request{\n\t\t\t{\n\t\t\t\tNamespacedName: types.NamespacedName{\n\t\t\t\t\tNamespace: batchSandbox.Namespace,\n\t\t\t\t\tName:      batchSandbox.Spec.PoolRef,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\treturn ctrl.NewControllerManagedBy(mgr).\n\t\tFor(&sandboxv1alpha1.Pool{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).\n\t\tOwns(&corev1.Pod{}).\n\t\tWatches(\n\t\t\t&sandboxv1alpha1.BatchSandbox{},\n\t\t\thandler.EnqueueRequestsFromMapFunc(findPoolForBatchSandbox),\n\t\t\tbuilder.WithPredicates(filterBatchSandbox),\n\t\t).\n\t\tNamed(\"pool\").\n\t\tComplete(r)\n}\n\nfunc (r *PoolReconciler) scheduleSandbox(ctx context.Context, pool *sandboxv1alpha1.Pool, batchSandboxes []*sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod) (map[string]string, []string, int32, bool, error) {\n\tspec := &AllocSpec{\n\t\tSandboxes: batchSandboxes,\n\t\tPool:      pool,\n\t\tPods:      pods,\n\t}\n\tstatus, poolDirty, err := r.Allocator.Schedule(ctx, spec)\n\tif err != nil {\n\t\treturn nil, nil, 0, false, err\n\t}\n\tidlePods := make([]string, 0)\n\tfor _, pod := range pods {\n\t\tif _, ok := status.PodAllocation[pod.Name]; !ok {\n\t\t\tidlePods = append(idlePods, pod.Name)\n\t\t}\n\t}\n\treturn status.PodAllocation, idlePods, status.PodSupplement, poolDirty, nil\n}\n\nfunc (r *PoolReconciler) updatePool(latestRevision string, pods []*corev1.Pod, idlePods []string) ([]string, []string, int32) {\n\tpodMap := make(map[string]*corev1.Pod)\n\tfor _, pod := range pods {\n\t\tpodMap[pod.Name] = pod\n\t}\n\tlatestIdlePods := make([]string, 0)\n\tdeleteOld := make([]string, 0)\n\tsupplyNew := int32(0)\n\n\tfor _, name := range idlePods {\n\t\tpod, ok := podMap[name]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\trevision := pod.Labels[LabelPoolRevision]\n\t\tif revision == latestRevision {\n\t\t\tlatestIdlePods = append(latestIdlePods, name)\n\t\t} else {\n\t\t\t// Rolling: (1) delete old idle pods (2) create latest pods\n\t\t\tdeleteOld = append(deleteOld, name)\n\t\t\tsupplyNew++\n\t\t}\n\t}\n\treturn latestIdlePods, deleteOld, supplyNew\n}\n\ntype scaleArgs struct {\n\tlatestRevision string\n\tpool           *sandboxv1alpha1.Pool\n\tpods           []*corev1.Pod\n\tallocatedCnt   int32\n\tsupplyCnt      int32 // to create\n\tidlePods       []string\n\tredundantPods  []string\n}\n\nfunc (r *PoolReconciler) scalePool(ctx context.Context, args *scaleArgs) error {\n\tlog := logf.FromContext(ctx)\n\terrs := make([]error, 0)\n\tpool := args.pool\n\tpods := args.pods\n\tif satisfied, unsatisfiedDuration, dirtyPods := PoolScaleExpectations.SatisfiedExpectations(controllerutils.GetControllerKey(pool)); !satisfied {\n\t\tlog.Info(\"Pool scale is not ready, requeue\", \"unsatisfiedDuration\", unsatisfiedDuration, \"dirtyPods\", dirtyPods)\n\t\treturn fmt.Errorf(\"pool scale is not ready, %v\", pool.Name)\n\t}\n\ttotalCnt := int32(len(args.pods))\n\tallocatedCnt := args.allocatedCnt\n\tsupplyCnt := args.supplyCnt\n\tredundantPods := args.redundantPods\n\tbufferCnt := totalCnt - allocatedCnt\n\n\t// Calculate desired buffer cnt.\n\tdesiredBufferCnt := bufferCnt\n\tif bufferCnt < pool.Spec.CapacitySpec.BufferMin || bufferCnt > pool.Spec.CapacitySpec.BufferMax {\n\t\tdesiredBufferCnt = (pool.Spec.CapacitySpec.BufferMin + pool.Spec.CapacitySpec.BufferMax) / 2\n\t}\n\n\t// Calculate desired total cnt.\n\tdesiredTotalCnt := allocatedCnt + supplyCnt + desiredBufferCnt\n\tif desiredTotalCnt < pool.Spec.CapacitySpec.PoolMin {\n\t\tdesiredTotalCnt = pool.Spec.CapacitySpec.PoolMin\n\t} else if desiredTotalCnt > pool.Spec.CapacitySpec.PoolMax {\n\t\tdesiredTotalCnt = pool.Spec.CapacitySpec.PoolMax\n\t}\n\n\tif desiredTotalCnt > totalCnt { // Need to create pod\n\t\tcreateCnt := desiredTotalCnt - totalCnt\n\t\tfor i := int32(0); i < createCnt; i++ {\n\t\t\tif err := r.createPoolPod(ctx, pool, args.latestRevision); err != nil {\n\t\t\t\tlog.Error(err, \"Failed to create pool pod\")\n\t\t\t\terrs = append(errs, err)\n\t\t\t}\n\t\t}\n\t} else if desiredTotalCnt < totalCnt || len(redundantPods) > 0 { // Need to delete pod\n\t\tscaleIn := int32(0)\n\t\tif desiredTotalCnt < totalCnt {\n\t\t\tscaleIn = totalCnt - desiredTotalCnt\n\t\t}\n\t\tpodsToDelete := r.pickPodsToDelete(pods, args.idlePods, args.redundantPods, scaleIn)\n\t\tfor _, pod := range podsToDelete {\n\t\t\tif err := r.Delete(ctx, pod); err != nil {\n\t\t\t\tlog.Error(err, \"Failed to delete pool pod\")\n\t\t\t\terrs = append(errs, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn gerrors.Join(errs...)\n}\n\nfunc (r *PoolReconciler) updatePoolStatus(ctx context.Context, latestRevision string, pool *sandboxv1alpha1.Pool, pods []*corev1.Pod, podAllocation map[string]string) error {\n\toldStatus := pool.Status.DeepCopy()\n\tavailableCnt := int32(0)\n\tfor _, pod := range pods {\n\t\tif _, ok := podAllocation[pod.Name]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tif pod.Status.Phase != corev1.PodRunning {\n\t\t\tcontinue\n\t\t}\n\t\tavailableCnt++\n\t}\n\tpool.Status.ObservedGeneration = pool.Generation\n\tpool.Status.Total = int32(len(pods))\n\tpool.Status.Allocated = int32(len(podAllocation))\n\tpool.Status.Available = availableCnt\n\tpool.Status.Revision = latestRevision\n\tif equality.Semantic.DeepEqual(oldStatus, pool.Status) {\n\t\treturn nil\n\t}\n\tif err := r.Status().Update(ctx, pool); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (r *PoolReconciler) pickPodsToDelete(pods []*corev1.Pod, idlePodNames []string, redundantPodNames []string, scaleIn int32) []*corev1.Pod {\n\tvar idlePods []*corev1.Pod\n\tpodMap := make(map[string]*corev1.Pod)\n\tfor _, pod := range pods {\n\t\tpodMap[pod.Name] = pod\n\t}\n\tfor _, name := range idlePodNames {\n\t\tpod, ok := podMap[name]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tidlePods = append(idlePods, pod)\n\t}\n\n\tsort.Slice(idlePods, func(i, j int) bool {\n\t\treturn idlePods[i].CreationTimestamp.Before(&idlePods[j].CreationTimestamp)\n\t})\n\tvar podsToDelete []*corev1.Pod\n\tfor _, name := range redundantPodNames { // delete pod from pool update\n\t\tpod, ok := podMap[name]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tpodsToDelete = append(podsToDelete, pod)\n\t}\n\tfor _, pod := range idlePods { // delete pod from pool scale\n\t\tif scaleIn <= 0 {\n\t\t\tbreak\n\t\t}\n\t\tif pod.DeletionTimestamp == nil {\n\t\t\tpodsToDelete = append(podsToDelete, pod)\n\t\t}\n\t\tscaleIn -= 1\n\t}\n\treturn podsToDelete\n}\n\nfunc (r *PoolReconciler) createPoolPod(ctx context.Context, pool *sandboxv1alpha1.Pool, latestRevision string) error {\n\tpod, err := utils.GetPodFromTemplate(pool.Spec.Template, pool, metav1.NewControllerRef(pool, sandboxv1alpha1.SchemeBuilder.GroupVersion.WithKind(\"Pool\")))\n\tif err != nil {\n\t\treturn err\n\t}\n\tpod.Namespace = pool.Namespace\n\tpod.Name = \"\"\n\tpod.GenerateName = pool.Name + \"-\"\n\tpod.Labels[LabelPoolName] = pool.Name\n\tpod.Labels[LabelPoolRevision] = latestRevision\n\tif err := ctrl.SetControllerReference(pool, pod, r.Scheme); err != nil {\n\t\treturn err\n\t}\n\tif err := r.Create(ctx, pod); err != nil {\n\t\tr.Recorder.Eventf(pool, corev1.EventTypeWarning, \"FailedCreate\", \"Failed to create pool pod: %v\", err)\n\t\treturn err\n\t}\n\tPoolScaleExpectations.ExpectScale(controllerutils.GetControllerKey(pool), expectations.Create, pod.Name)\n\tr.Recorder.Eventf(pool, corev1.EventTypeNormal, \"SuccessfulCreate\", \"Created pool pod: %v\", pod.Name)\n\treturn nil\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/pool_controller_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"time\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/fields\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/rand\"\n\t\"k8s.io/client-go/util/retry\"\n\t\"k8s.io/utils/ptr\"\n\tkclient \"sigs.k8s.io/controller-runtime/pkg/client\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\nvar _ = Describe(\"Pool scale\", func() {\n\tvar (\n\t\ttimeout  = 10 * time.Second\n\t\tinterval = 1 * time.Second\n\t)\n\tContext(\"When reconciling a resource\", func() {\n\t\tconst resourceName = \"pool-scale-test\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\t\tBeforeEach(func() {\n\t\t\tBy(\"creating the custom resource for the Kind Pool\")\n\t\t\ttypeNamespacedName.Name = resourceName + \"-\" + rand.String(8)\n\t\t\tresource := &sandboxv1alpha1.Pool{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\t\t\tPoolMin:   0,\n\t\t\t\t\t\tPoolMax:   2,\n\t\t\t\t\t\tBufferMin: 1,\n\t\t\t\t\t\tBufferMax: 1,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\t} else {\n\t\t\t\t\tBy(\"The specific resource instance Pool already deleted\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tBy(\"Cleanup the specific resource instance Pool\")\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\t\tIt(\"should successfully update pool status\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully scale out pool buffer size\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\tpool.Spec.CapacitySpec.BufferMin = 2\n\t\t\tpool.Spec.CapacitySpec.BufferMax = 2\n\t\t\tExpect(k8sClient.Update(ctx, pool)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcnt := int32(2)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully scale out buffer limit by pool max\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\tpool.Spec.CapacitySpec.PoolMax = 2\n\t\t\tpool.Spec.CapacitySpec.BufferMin = 3\n\t\t\tpool.Spec.CapacitySpec.BufferMax = 3\n\t\t\tExpect(k8sClient.Update(ctx, pool)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcnt := int32(2)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully scale in pool buffer size\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\tpool.Spec.CapacitySpec.BufferMin = 0\n\t\t\tpool.Spec.CapacitySpec.BufferMax = 0\n\t\t\tExpect(k8sClient.Update(ctx, pool)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcnt := int32(0)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully scale in buffer limit by pool min\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\tpool.Spec.CapacitySpec.PoolMax = 1\n\t\t\tpool.Spec.CapacitySpec.PoolMin = 1\n\t\t\tpool.Spec.CapacitySpec.BufferMin = 0\n\t\t\tpool.Spec.CapacitySpec.BufferMax = 0\n\t\t\tExpect(k8sClient.Update(ctx, pool)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcnt := int32(1)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t})\n})\n\nvar _ = Describe(\"Pool update\", func() {\n\tvar (\n\t\ttimeout  = 10 * time.Second\n\t\tinterval = 1 * time.Second\n\t)\n\tContext(\"When reconciling a resource\", func() {\n\t\tconst resourceName = \"pool-update-test\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\tBy(\"creating the custom resource for the Kind Pool\")\n\t\t\ttypeNamespacedName.Name = resourceName + \"-\" + rand.String(8)\n\t\t\tresource := &sandboxv1alpha1.Pool{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\t\t\tPoolMin:   0,\n\t\t\t\t\t\tPoolMax:   2,\n\t\t\t\t\t\tBufferMin: 1,\n\t\t\t\t\t\tBufferMax: 1,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tpods := &v1.PodList{}\n\t\t\tExpect(k8sClient.List(ctx, pods, &kclient.ListOptions{\n\t\t\t\tNamespace:     typeNamespacedName.Namespace,\n\t\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),\n\t\t\t})).To(Succeed())\n\t\t\t// Mock pod running\n\t\t\tfor _, pod := range pods.Items {\n\t\t\t\tpod.Status.Phase = v1.PodRunning\n\t\t\t\tExpect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())\n\t\t\t}\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\t} else {\n\t\t\t\t\tBy(\"The specific resource instance Pool already deleted\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tBy(\"Cleanup the specific resource instance Pool\")\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\t\tIt(\"should successfully update pool revision\", func() {\n\t\t\tvar oldRevision string\n\t\t\tExpect(retry.RetryOnConflict(retry.DefaultRetry, func() error {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\tif err := k8sClient.Get(ctx, typeNamespacedName, pool); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif oldRevision == \"\" {\n\t\t\t\t\toldRevision = pool.Status.Revision\n\t\t\t\t}\n\t\t\t\tpool.Spec.Template.Labels = map[string]string{\n\t\t\t\t\t\"test.pool.update\": \"v1\",\n\t\t\t\t}\n\t\t\t\treturn k8sClient.Update(ctx, pool)\n\t\t\t})).Should(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\t\tcnt := int32(1)\n\t\t\t\tg.Expect(pool.Status.Revision).NotTo(Equal(oldRevision))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t\tIt(\"should successfully update pool with allocated pod\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tsbxNamespaceName := types.NamespacedName{\n\t\t\t\tName:      \"sandbox-test-\" + rand.String(8),\n\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t}\n\t\t\tsandbox := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      sbxNamespaceName.Name,\n\t\t\t\t\tNamespace: sbxNamespaceName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tPoolRef: typeNamespacedName.Name,\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, sandbox)).To(Succeed())\n\t\t\t// wait allocation\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, sbxNamespaceName, sandbox)).To(Succeed())\n\t\t\t\talloc, err := getSandboxAllocation(sandbox)\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(alloc.Pods).NotTo(BeEmpty())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tExpect(k8sClient.Get(ctx, sbxNamespaceName, sandbox)).To(Succeed())\n\t\t\tsbxAlloc, err := getSandboxAllocation(sandbox)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tExpect(len(sbxAlloc.Pods)).To(Equal(1))\n\t\t\t// check pool allocation\n\t\t\terr = k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tallocation, err := getPoolAllocation(pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tExpect(len(allocation.PodAllocation)).To(Equal(1))\n\t\t\tExpect(allocation.PodAllocation[sbxAlloc.Pods[0]]).To(Equal(sandbox.Name))\n\t\t\t// update pool\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\toldRevision := pool.Status.Revision\n\t\t\tpool.Spec.Template.Labels = map[string]string{\n\t\t\t\t\"test.pool.update\": \"v1\",\n\t\t\t}\n\t\t\tExpect(k8sClient.Update(ctx, pool)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\t\tcnt := int32(2)\n\t\t\t\tg.Expect(pool.Status.Revision).NotTo(Equal(oldRevision))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t\tpods := &v1.PodList{}\n\t\t\t\tExpect(k8sClient.List(ctx, pods, &kclient.ListOptions{\n\t\t\t\t\tNamespace:     typeNamespacedName.Namespace,\n\t\t\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),\n\t\t\t\t})).To(Succeed())\n\t\t\t\tfor _, pod := range pods.Items {\n\t\t\t\t\tif pod.Name == sbxAlloc.Pods[0] {\n\t\t\t\t\t\tg.Expect(pod.DeletionTimestamp).To(BeNil())\n\t\t\t\t\t\tg.Expect(pod.Labels[LabelPoolRevision]).To(Equal(oldRevision))\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif pod.DeletionTimestamp != nil {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tg.Expect(pod.Labels[LabelPoolRevision]).NotTo(Equal(oldRevision))\n\t\t\t\t}\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tExpect(k8sClient.Delete(ctx, sandbox)).To(Succeed())\n\t\t})\n\t})\n})\n\nvar _ = Describe(\"Pool allocate\", func() {\n\tvar (\n\t\ttimeout  = 10 * time.Second\n\t\tinterval = 1 * time.Second\n\t)\n\tContext(\"When reconciling a resource\", func() {\n\t\tconst resourceName = \"pool-allocate-test\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\tBy(\"creating the custom resource for the Kind Pool\")\n\t\t\ttypeNamespacedName.Name = resourceName + \"-\" + rand.String(8)\n\t\t\tresource := &sandboxv1alpha1.Pool{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\t\t\tPoolMin:   0,\n\t\t\t\t\t\tPoolMax:   2,\n\t\t\t\t\t\tBufferMin: 1,\n\t\t\t\t\t\tBufferMax: 1,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tpods := &v1.PodList{}\n\t\t\tExpect(k8sClient.List(ctx, pods, &kclient.ListOptions{\n\t\t\t\tNamespace:     typeNamespacedName.Namespace,\n\t\t\t\tFieldSelector: fields.SelectorFromSet(fields.Set{fieldindex.IndexNameForOwnerRefUID: string(pool.UID)}),\n\t\t\t})).To(Succeed())\n\t\t\t// Mock pod running\n\t\t\tfor _, pod := range pods.Items {\n\t\t\t\tpod.Status.Phase = v1.PodRunning\n\t\t\t\tExpect(k8sClient.Status().Update(ctx, &pod)).To(Succeed())\n\t\t\t}\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\t} else {\n\t\t\t\t\tBy(\"The specific resource instance Pool already deleted\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tBy(\"Cleanup the specific resource instance Pool\")\n\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t})\n\t\tIt(\"should successfully allocate pool pod to batch sandbox and release\", func() {\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tbsbxNamespaceName := types.NamespacedName{\n\t\t\t\tName:      \"batch-sandbox-test-\" + rand.String(8),\n\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t}\n\t\t\tbatchSandbox := &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      bsbxNamespaceName.Name,\n\t\t\t\t\tNamespace: bsbxNamespaceName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tReplicas: ptr.To(int32(1)),\n\t\t\t\t\tPoolRef:  typeNamespacedName.Name,\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, batchSandbox)).To(Succeed())\n\t\t\t// wait allocation\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tg.Expect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())\n\t\t\t\talloc, err := getSandboxAllocation(batchSandbox)\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(alloc.Pods).NotTo(BeEmpty())\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tExpect(k8sClient.Get(ctx, bsbxNamespaceName, batchSandbox)).To(Succeed())\n\t\t\tsbxAlloc, err := getSandboxAllocation(batchSandbox)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tExpect(len(sbxAlloc.Pods)).To(Equal(1))\n\t\t\t// check pool allocation\n\t\t\terr = k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tallocation, err := getPoolAllocation(pool)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tExpect(len(allocation.PodAllocation)).To(Equal(1))\n\t\t\tExpect(allocation.PodAllocation[sbxAlloc.Pods[0]]).To(Equal(batchSandbox.Name))\n\t\t\t// release\n\t\t\trelease := AllocationRelease{\n\t\t\t\tPods: sbxAlloc.Pods,\n\t\t\t}\n\t\t\tjs, err := json.Marshal(release)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tbatchSandbox.Annotations[AnnoAllocReleaseKey] = string(js)\n\t\t\terr = k8sClient.Update(ctx, batchSandbox)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t// wait release\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\terr = k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\tallocation, err = getPoolAllocation(pool)\n\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(len(allocation.PodAllocation)).To(Equal(0))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t\tExpect(k8sClient.Delete(ctx, batchSandbox)).To(Succeed())\n\t\t})\n\t})\n})\n\nfunc getSandboxAllocation(obj kclient.Object) (*SandboxAllocation, error) {\n\tallocation := &SandboxAllocation{}\n\tanno := obj.GetAnnotations()\n\tif anno == nil {\n\t\treturn allocation, nil\n\t}\n\tstr, ok := anno[AnnoAllocStatusKey]\n\tif !ok {\n\t\treturn allocation, nil\n\t}\n\terr := json.Unmarshal([]byte(str), allocation)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn allocation, nil\n}\n\nfunc getPoolAllocation(pool *sandboxv1alpha1.Pool) (*PoolAllocation, error) {\n\tallocation := &PoolAllocation{}\n\tanno := pool.GetAnnotations()\n\tif anno == nil {\n\t\treturn allocation, nil\n\t}\n\tstr, ok := anno[AnnoPoolAllocStatusKey]\n\tif !ok {\n\t\treturn allocation, nil\n\t}\n\terr := json.Unmarshal([]byte(str), allocation)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn allocation, nil\n}\n\nvar _ = Describe(\"Pool deletion and recreation\", func() {\n\tvar (\n\t\ttimeout  = 10 * time.Second\n\t\tinterval = 1 * time.Second\n\t)\n\tContext(\"When deleting and recreating a Pool with same name\", func() {\n\t\tconst resourceName = \"pool-recreate-test\"\n\n\t\tctx := context.Background()\n\n\t\ttypeNamespacedName := types.NamespacedName{\n\t\t\tName:      resourceName,\n\t\t\tNamespace: \"default\",\n\t\t}\n\n\t\tBeforeEach(func() {\n\t\t\tBy(\"creating the custom resource for the Kind Pool\")\n\t\t\ttypeNamespacedName.Name = resourceName + \"-\" + rand.String(8)\n\t\t\tresource := &sandboxv1alpha1.Pool{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\t\t\tPoolMin:   0,\n\t\t\t\t\t\tPoolMax:   2,\n\t\t\t\t\t\tBufferMin: 1,\n\t\t\t\t\t\tBufferMax: 1,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, resource)).To(Succeed())\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt))\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tresource := &sandboxv1alpha1.Pool{}\n\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, resource)\n\t\t\tif err != nil {\n\t\t\t\tif !errors.IsNotFound(err) {\n\t\t\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tBy(\"Cleanup the specific resource instance Pool\")\n\t\t\t\tExpect(k8sClient.Delete(ctx, resource)).To(Succeed())\n\t\t\t}\n\t\t})\n\n\t\tIt(\"should allow recreating a Pool with the same name after deletion\", func() {\n\t\t\tBy(\"deleting the existing Pool\")\n\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\tExpect(k8sClient.Get(ctx, typeNamespacedName, pool)).To(Succeed())\n\t\t\tExpect(k8sClient.Delete(ctx, pool)).To(Succeed())\n\n\t\t\tBy(\"waiting for the Pool to be fully deleted\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(errors.IsNotFound(err)).To(BeTrue(), \"Pool should be deleted\")\n\t\t\t}, timeout, interval).Should(Succeed())\n\n\t\t\tBy(\"recreating a Pool with the same name\")\n\t\t\tnewPool := &sandboxv1alpha1.Pool{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      typeNamespacedName.Name,\n\t\t\t\t\tNamespace: typeNamespacedName.Namespace,\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.PoolSpec{\n\t\t\t\t\tTemplate: &v1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\t\tImage: \"example.com\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tCapacitySpec: sandboxv1alpha1.CapacitySpec{\n\t\t\t\t\t\tPoolMin:   0,\n\t\t\t\t\t\tPoolMax:   2,\n\t\t\t\t\t\tBufferMin: 1,\n\t\t\t\t\t\tBufferMax: 1,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tExpect(k8sClient.Create(ctx, newPool)).To(Succeed())\n\n\t\t\tBy(\"verifying the new Pool is successfully reconciled and creates expected pods\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tpool := &sandboxv1alpha1.Pool{}\n\t\t\t\terr := k8sClient.Get(ctx, typeNamespacedName, pool)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tcnt := min(pool.Spec.CapacitySpec.PoolMax, pool.Spec.CapacitySpec.BufferMin)\n\t\t\t\tg.Expect(pool.Status.ObservedGeneration).To(Equal(pool.Generation))\n\t\t\t\tg.Expect(pool.Status.Total).To(Equal(cnt), \"new Pool should have correct total pod count\")\n\t\t\t}, timeout, interval).Should(Succeed())\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/pool_strategy.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\ntype PoolStrategy interface {\n\tIsPooledMode() bool\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/pool_strategy_default.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\ntype DefaultPoolStrategy struct {\n\t*sandboxv1alpha1.BatchSandbox\n}\n\nfunc NewDefaultPoolStrategy(batchSandbox *sandboxv1alpha1.BatchSandbox) *DefaultPoolStrategy {\n\treturn &DefaultPoolStrategy{\n\t\tBatchSandbox: batchSandbox,\n\t}\n}\n\nfunc (s *DefaultPoolStrategy) IsPooledMode() bool {\n\treturn s.Spec.PoolRef != \"\"\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/pool_strategy_factory.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\nfunc NewPoolStrategy(batchSbx *sandboxv1alpha1.BatchSandbox) PoolStrategy {\n\treturn NewDefaultPoolStrategy(batchSbx)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/pool_strategy_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\nfunc TestDefaultPoolStrategy_IsPooledMode(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tbatchSbx *sandboxv1alpha1.BatchSandbox\n\t\twant     bool\n\t}{\n\t\t{\n\t\t\tname: \"with template - not pooled\",\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tTemplate: &corev1.PodTemplateSpec{\n\t\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\t\tContainers: []corev1.Container{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tName:  \"test\",\n\t\t\t\t\t\t\t\t\tImage: \"nginx\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"without template - pooled\",\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tTemplate: nil,\n\t\t\t\t\tPoolRef:  \"test-pool\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstrategy := NewDefaultPoolStrategy(tt.batchSbx)\n\t\t\tif got := strategy.IsPooledMode(); got != tt.want {\n\t\t\t\tt.Errorf(\"DefaultPoolStrategy.IsPooledMode() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewPoolStrategy(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tbatchSbx     *sandboxv1alpha1.BatchSandbox\n\t\twantStrategy string\n\t}{\n\t\t{\n\t\t\tname: \"without resource-speedup label - returns DefaultPoolStrategy\",\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tLabels: map[string]string{},\n\t\t\t\t},\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tTemplate: nil,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantStrategy: \"*strategy.DefaultPoolStrategy\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := NewPoolStrategy(tt.batchSbx)\n\t\t\tgotType := getTypeName(got)\n\t\t\tif gotType != tt.wantStrategy {\n\t\t\t\tt.Errorf(\"NewPoolStrategy() = %v, want %v\", gotType, tt.wantStrategy)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc getTypeName(i interface{}) string {\n\treturn fmt.Sprintf(\"%T\", i)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/task_scheduling_strategy.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// TaskSchedulingStrategy defines the strategy interface for task scheduling.\n// Different implementations can provide custom logic for determining whether\n// task scheduling is needed and how to generate task specifications.\ntype TaskSchedulingStrategy interface {\n\t// NeedTaskScheduling determines whether the BatchSandbox requires task scheduling.\n\tNeedTaskScheduling() bool\n\n\t// GenerateTaskSpecs generates the complete list of task specifications for the BatchSandbox.\n\tGenerateTaskSpecs() ([]*api.Task, error)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/task_scheduling_strategy_default.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"k8s.io/apimachinery/pkg/util/strategicpatch\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// DefaultTaskSchedulingStrategy implements the default task scheduling strategy.\ntype DefaultTaskSchedulingStrategy struct {\n\t*sandboxv1alpha1.BatchSandbox\n}\n\n// NewDefaultTaskSchedulingStrategy creates a new default task scheduling strategy.\nfunc NewDefaultTaskSchedulingStrategy(batchSbx *sandboxv1alpha1.BatchSandbox) *DefaultTaskSchedulingStrategy {\n\treturn &DefaultTaskSchedulingStrategy{\n\t\tBatchSandbox: batchSbx,\n\t}\n}\n\n// NeedTaskScheduling determines whether task scheduling is needed based on TaskTemplate.\nfunc (s *DefaultTaskSchedulingStrategy) NeedTaskScheduling() bool {\n\treturn s.Spec.TaskTemplate != nil\n}\n\n// GenerateTaskSpecs generates task specifications for all replicas.\nfunc (s *DefaultTaskSchedulingStrategy) GenerateTaskSpecs() ([]*api.Task, error) {\n\tret := make([]*api.Task, *s.Spec.Replicas)\n\tfor idx := range int(*s.Spec.Replicas) {\n\t\ttask, err := s.getTaskSpec(idx)\n\t\tif err != nil {\n\t\t\treturn ret, err\n\t\t}\n\t\tret[idx] = task\n\t}\n\treturn ret, nil\n}\n\n// getTaskSpec generates a single task specification for the given index.\n// It applies ShardTaskPatches if available, otherwise uses the base TaskTemplate.\nfunc (s *DefaultTaskSchedulingStrategy) getTaskSpec(idx int) (*api.Task, error) {\n\ttask := &api.Task{\n\t\tName: fmt.Sprintf(\"%s-%d\", s.Name, idx),\n\t}\n\tif len(s.Spec.ShardTaskPatches) > 0 && idx < len(s.Spec.ShardTaskPatches) {\n\t\ttaskTemplate := s.Spec.TaskTemplate.DeepCopy()\n\t\tcloneBytes, _ := json.Marshal(taskTemplate)\n\t\tpatch := s.Spec.ShardTaskPatches[idx]\n\t\tmodified, err := strategicpatch.StrategicMergePatch(cloneBytes, patch.Raw, &sandboxv1alpha1.TaskTemplateSpec{})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"batchsandbox: failed to merge patch raw %s, idx %d, err %w\", patch.Raw, idx, err)\n\t\t}\n\t\tnewTaskTemplate := &sandboxv1alpha1.TaskTemplateSpec{}\n\t\tif err = json.Unmarshal(modified, newTaskTemplate); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"batchsandbox: failed to unmarshal %s to TaskTemplateSpec, idx %d, err %w\", modified, idx, err)\n\t\t}\n\t\ttask.Process = &api.Process{\n\t\t\tCommand:        newTaskTemplate.Spec.Process.Command,\n\t\t\tArgs:           newTaskTemplate.Spec.Process.Args,\n\t\t\tEnv:            newTaskTemplate.Spec.Process.Env,\n\t\t\tWorkingDir:     newTaskTemplate.Spec.Process.WorkingDir,\n\t\t\tTimeoutSeconds: s.Spec.TaskTemplate.Spec.TimeoutSeconds,\n\t\t}\n\t} else if s.Spec.TaskTemplate != nil && s.Spec.TaskTemplate.Spec.Process != nil {\n\t\ttask.Process = &api.Process{\n\t\t\tCommand:        s.Spec.TaskTemplate.Spec.Process.Command,\n\t\t\tArgs:           s.Spec.TaskTemplate.Spec.Process.Args,\n\t\t\tEnv:            s.Spec.TaskTemplate.Spec.Process.Env,\n\t\t\tWorkingDir:     s.Spec.TaskTemplate.Spec.Process.WorkingDir,\n\t\t\tTimeoutSeconds: s.Spec.TaskTemplate.Spec.TimeoutSeconds,\n\t\t}\n\t}\n\treturn task, nil\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/task_scheduling_strategy_default_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc TestDefaultTaskSchedulingStrategy_NeedTaskScheduling(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tbatchSbx *sandboxv1alpha1.BatchSandbox\n\t\twant     bool\n\t}{\n\t\t{\n\t\t\tname: \"with task template\",\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"without task template\",\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\tTaskTemplate: nil,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstrategy := NewDefaultTaskSchedulingStrategy(tt.batchSbx)\n\t\t\tif got := strategy.NeedTaskScheduling(); got != tt.want {\n\t\t\t\tt.Errorf(\"DefaultTaskSchedulingStrategy.NeedTaskScheduling() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDefaultTaskSchedulingStrategy_getTaskSpec(t *testing.T) {\n\ttype args struct {\n\t\tbatchSbx *sandboxv1alpha1.BatchSandbox\n\t\tidx      int\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    *api.Task\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"basic task spec without patches\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-bs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{\n\t\t\t\t\t\t\tSpec: sandboxv1alpha1.TaskSpec{\n\t\t\t\t\t\t\t\tProcess: &sandboxv1alpha1.ProcessTask{\n\t\t\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tidx: 0,\n\t\t\t},\n\t\t\twant: &api.Task{\n\t\t\t\tName: \"test-bs-0\",\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"task spec with shard patch\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-bs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{\n\t\t\t\t\t\t\tSpec: sandboxv1alpha1.TaskSpec{\n\t\t\t\t\t\t\t\tProcess: &sandboxv1alpha1.ProcessTask{\n\t\t\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tShardTaskPatches: []runtime.RawExtension{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tRaw: []byte(`{\"spec\":{\"process\":{\"command\":[\"echo\",\"world\"]}}}`),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tidx: 0,\n\t\t\t},\n\t\t\twant: &api.Task{\n\t\t\t\tName: \"test-bs-0\",\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"task spec with invalid patch\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-bs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{\n\t\t\t\t\t\t\tSpec: sandboxv1alpha1.TaskSpec{\n\t\t\t\t\t\t\t\tProcess: &sandboxv1alpha1.ProcessTask{\n\t\t\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tShardTaskPatches: []runtime.RawExtension{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tRaw: []byte(`{\"invalid json`),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tidx: 0,\n\t\t\t},\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"task spec with index out of range patch\",\n\t\t\targs: args{\n\t\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-bs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: sandboxv1alpha1.BatchSandboxSpec{\n\t\t\t\t\t\tTaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{\n\t\t\t\t\t\t\tSpec: sandboxv1alpha1.TaskSpec{\n\t\t\t\t\t\t\t\tProcess: &sandboxv1alpha1.ProcessTask{\n\t\t\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tShardTaskPatches: []runtime.RawExtension{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tRaw: []byte(`{\"spec\":{\"process\":{\"command\":[\"echo\",\"world\"]}}}`),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tidx: 1,\n\t\t\t},\n\t\t\twant: &api.Task{\n\t\t\t\tName: \"test-bs-1\",\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstrategy := NewDefaultTaskSchedulingStrategy(tt.args.batchSbx)\n\t\t\tgot, err := strategy.getTaskSpec(tt.args.idx)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"DefaultTaskSchedulingStrategy.getTaskSpec() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !tt.wantErr {\n\t\t\t\tif got.Name != tt.want.Name {\n\t\t\t\t\tt.Errorf(\"DefaultTaskSchedulingStrategy.getTaskSpec() name = %v, want %v\", got.Name, tt.want.Name)\n\t\t\t\t}\n\t\t\t\tif !reflect.DeepEqual(got.Process, tt.want.Process) {\n\t\t\t\t\tt.Errorf(\"DefaultTaskSchedulingStrategy.getTaskSpec() spec = %v, want %v\", got.Process, tt.want.Process)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/strategy/task_scheduling_strategy_factory.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage strategy\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\n// NewTaskSchedulingStrategy creates a task scheduling strategy based on BatchSandbox properties.\n// This function is designed to be easily customizable for different implementations:\nfunc NewTaskSchedulingStrategy(batchSbx *sandboxv1alpha1.BatchSandbox) TaskSchedulingStrategy {\n\treturn NewDefaultTaskSchedulingStrategy(batchSbx)\n}\n"
  },
  {
    "path": "kubernetes/internal/controller/suite_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\t\"k8s.io/client-go/rest\"\n\tctrl \"sigs.k8s.io/controller-runtime\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\t\"sigs.k8s.io/controller-runtime/pkg/envtest\"\n\tlogf \"sigs.k8s.io/controller-runtime/pkg/log\"\n\t\"sigs.k8s.io/controller-runtime/pkg/log/zap\"\n\t\"sigs.k8s.io/controller-runtime/pkg/manager\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex\"\n\t// +kubebuilder:scaffold:imports\n)\n\n// These tests use Ginkgo (BDD-style Go testing framework). Refer to\n// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.\n\nvar (\n\tctx        context.Context\n\tcancel     context.CancelFunc\n\ttestEnv    *envtest.Environment\n\tcfg        *rest.Config\n\tk8sClient  client.Client\n\tk8sManager ctrl.Manager\n\tmgrStopped *sync.WaitGroup\n)\n\nfunc TestControllers(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\n\tRunSpecs(t, \"Controller Suite\")\n}\n\nvar _ = BeforeSuite(func() {\n\tlogf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))\n\n\tctx, cancel = context.WithCancel(context.TODO())\n\n\tvar err error\n\terr = sandboxv1alpha1.AddToScheme(scheme.Scheme)\n\tExpect(err).NotTo(HaveOccurred())\n\n\t// +kubebuilder:scaffold:scheme\n\n\tBy(\"bootstrapping test environment\")\n\ttestEnv = &envtest.Environment{\n\t\tCRDDirectoryPaths:     []string{filepath.Join(\"..\", \"..\", \"config\", \"crd\", \"bases\")},\n\t\tErrorIfCRDPathMissing: true,\n\t}\n\n\t// Retrieve the first found binary directory to allow running tests from IDEs\n\tif getFirstFoundEnvTestBinaryDir() != \"\" {\n\t\ttestEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir()\n\t}\n\n\t// cfg is defined in this file globally.\n\tcfg, err = testEnv.Start()\n\tExpect(err).NotTo(HaveOccurred())\n\tExpect(cfg).NotTo(BeNil())\n\n\tk8sManager, err = ctrl.NewManager(cfg, ctrl.Options{\n\t\tScheme: scheme.Scheme,\n\t})\n\tExpect(err).ToNot(HaveOccurred())\n\tBy(\"register field index\")\n\tExpect(fieldindex.RegisterFieldIndexes(k8sManager.GetCache())).Should(Succeed(), \"failed to register fieldindex\")\n\n\tBy(\"setup reconciler\")\n\tExpect((&BatchSandboxReconciler{\n\t\tClient:   k8sManager.GetClient(),\n\t\tScheme:   k8sManager.GetScheme(),\n\t\tRecorder: k8sManager.GetEventRecorderFor(\"test-batch-sandbox-controller\"),\n\t}).SetupWithManager(k8sManager)).Should(Succeed())\n\tExpect((&PoolReconciler{\n\t\tClient:    k8sManager.GetClient(),\n\t\tScheme:    k8sManager.GetScheme(),\n\t\tRecorder:  k8sManager.GetEventRecorderFor(\"test-pool-controller\"),\n\t\tAllocator: NewDefaultAllocator(k8sManager.GetClient()),\n\t}).SetupWithManager(k8sManager)).Should(Succeed())\n\t// TODO more reconciler goes HERE\n\n\tBy(\"try to start manager\")\n\tmgrStopped = startTestManager(ctx, k8sManager)\n\n\tk8sManager.GetCache().WaitForCacheSync(ctx)\n\tBy(\"waiting for manager cache synced\")\n\n\tk8sClient = k8sManager.GetClient()\n\tExpect(k8sClient).NotTo(BeNil())\n})\n\nfunc startTestManager(ctx context.Context, mgr manager.Manager) *sync.WaitGroup {\n\twg := &sync.WaitGroup{}\n\twg.Add(1)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tExpect(mgr.Start(ctx)).Should(Succeed(), \"failed to start manager\")\n\t}()\n\treturn wg\n}\n\nvar _ = AfterSuite(func() {\n\tBy(\"tearing down the test environment\")\n\tcancel()\n\tif mgrStopped != nil {\n\t\tBy(\"waiting manager exit\")\n\t\tmgrStopped.Wait()\n\t}\n\terr := testEnv.Stop()\n\tExpect(err).NotTo(HaveOccurred())\n})\n\n// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path.\n// ENVTEST-based tests depend on specific binaries, usually located in paths set by\n// controller-runtime. When running tests directly (e.g., via an IDE) without using\n// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured.\n//\n// This function streamlines the process by finding the required binaries, similar to\n// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are\n// properly set up, run 'make setup-envtest' beforehand.\nfunc getFirstFoundEnvTestBinaryDir() string {\n\tbasePath := filepath.Join(\"..\", \"..\", \"bin\", \"k8s\")\n\tentries, err := os.ReadDir(basePath)\n\tif err != nil {\n\t\tlogf.Log.Error(err, \"Failed to read directory\", \"path\", basePath)\n\t\treturn \"\"\n\t}\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\treturn filepath.Join(basePath, entry.Name())\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/default_scheduler.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/go-logr/logr\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/utils/ptr\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nvar _ Task = &taskNode{}\n\nvar (\n\ttimeNow = func() time.Time {\n\t\treturn time.Now()\n\t}\n)\n\ntype taskSpec struct {\n\tProcess         *api.Process\n\tPodTemplateSpec *corev1.PodTemplateSpec\n}\n\ntype taskNode struct {\n\tmetav1.ObjectMeta\n\tSpec taskSpec\n\n\t// status\n\tStatus  *api.Task\n\tIP      string\n\tPodName string\n\n\t// collect from endpoints\n\ttState              TaskState\n\ttStateLastTransTime *time.Time\n\n\t// inner sch state\n\tsStateLastTransTime *time.Time\n\tsState              string\n}\n\nfunc (t *taskNode) GetPodName() string {\n\treturn t.PodName\n}\n\nfunc (t *taskNode) GetState() TaskState {\n\treturn t.tState\n}\n\nfunc (t *taskNode) IsResourceReleased() bool {\n\treturn t.sState == stateReleased\n}\n\nfunc (t *taskNode) isTaskCompleted() bool {\n\treturn t.tState == SucceedTaskState || t.tState == FailedTaskState\n}\n\nfunc (t *taskNode) isTaskDeleted() bool {\n\treturn t.Status == nil\n}\n\nfunc (t *taskNode) transSchState(to string, log logr.Logger) {\n\tif t.sState == to {\n\t\treturn\n\t}\n\tfrom := t.sState\n\tt.sState = to\n\tvar lat time.Duration\n\tnow := timeNow()\n\tif t.sStateLastTransTime != nil {\n\t\tlat = now.Sub(*t.sStateLastTransTime)\n\t}\n\tt.sStateLastTransTime = ptr.To[time.Time](now)\n\tlog.Info(\"task node trans sch state\", \"name\", t.Name, \"namespace\", t.Namespace, \"from\", from, \"to\", to, \"latencyMs\", lat.Milliseconds())\n}\n\nfunc (t *taskNode) transTaskState(to TaskState, log logr.Logger) {\n\tif t.tState == to {\n\t\treturn\n\t}\n\tfrom := t.tState\n\tt.tState = to\n\tvar lat time.Duration\n\tnow := timeNow()\n\tif t.tStateLastTransTime != nil {\n\t\tlat = now.Sub(*t.tStateLastTransTime)\n\t}\n\tt.tStateLastTransTime = ptr.To[time.Time](now)\n\tlog.Info(\"task node trans task state\", \"name\", t.Name, \"namespace\", t.Namespace, \"from\", from, \"to\", to, \"latencyMs\", lat.Milliseconds())\n}\n\nconst (\n\t// FSM: TaskNode Sch State Machine\n\t/*\n\t   $start --> pending\n\n\t   pending -- \"when task is assigned to Pod\" --> assigned\n\t   pending -- \"when BatchSandbox's deletion timestamp != 0\" --> released\n\n\t   assigned -- \"when BatchSandbox's deletion timestamp != 0\" --> releasing\n\t   assigned -- \"when task state is SUCCEED && policy is allowed\" --> releasing\n\t   assigned -- \"when task state is FAILED && policy is allowed\" --> releasing\n\t   assigned -- \"set Task\"\n\n\t   releasing -- \"when endpoint returns nil task or endpoint lost too many times  (e.g., force-deleted), endpoint is nil(unassigned)\" --> released\n\n\t   released --> $end\n\t*/\n\t//statePending   = \"pending\", endpoint is empty means pending, otherwise means assigned\n\t//stateAssigned  = \"assigned\"\n\tstateReleasing = \"releasing\"\n\tstateReleased  = \"released\"\n\tstateUnknown   = \"unknown\"\n)\n\ntype taskClient interface {\n\tSet(ctx context.Context, task *api.Task) (*api.Task, error)\n\tGet(ctx context.Context) (*api.Task, error)\n}\n\nconst (\n\tdefaultTimeout        time.Duration = 3 * time.Second\n\tdefaultTaskPort                     = \"5758\"\n\tdefaultSchConcurrency int           = 10\n)\n\nfunc newTaskClient(ip string) taskClient {\n\treturn api.NewClient(fmtEndpoint(ip))\n}\n\nfunc fmtEndpoint(podIP string) string {\n\treturn fmt.Sprintf(\"http://%s:%s\", podIP, defaultTaskPort)\n}\n\ntype defaultTaskScheduler struct {\n\tfreePods []*corev1.Pod\n\tallPods  []*corev1.Pod\n\n\ttaskNodes           []*taskNode\n\ttaskNodeByNameIndex map[string]*taskNode\n\n\tmaxConcurrency int\n\tonce           sync.Once\n\n\ttaskStatusCollector       taskStatusCollector\n\ttaskClientCreator         taskClientCreator\n\tresPolicyWhenTaskComplete sandboxv1alpha1.TaskResourcePolicy\n\tname                      string\n\tlogger                    logr.Logger\n}\n\nfunc newTaskScheduler(name string, tasks []*api.Task, pods []*corev1.Pod, resPolicyWhenTaskComplete sandboxv1alpha1.TaskResourcePolicy, logger logr.Logger) (*defaultTaskScheduler, error) {\n\tsch := &defaultTaskScheduler{\n\t\tallPods:                   pods,\n\t\tmaxConcurrency:            defaultSchConcurrency,\n\t\ttaskClientCreator:         newTaskClient,\n\t\ttaskStatusCollector:       newTaskStatusCollector(newTaskClient, logger),\n\t\tresPolicyWhenTaskComplete: resPolicyWhenTaskComplete,\n\t\tname:                      name,\n\t\tlogger:                    logger,\n\t}\n\ttaskNodes, err := initTaskNodes(tasks)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"scheduler: failed to init task node err %w\", err)\n\t}\n\tsch.taskNodes = taskNodes\n\tsch.taskNodeByNameIndex = indexByName(taskNodes)\n\tlogger.Info(\"successfully init task nodes\", \"scheduler\", name, \"size\", len(taskNodes))\n\t// TODO: Optimization – skip recovery for a brand-new scheduler.\n\t// Recovery is unnecessary in this case and incurs significant overhead.\n\tif err := sch.recover(); err != nil {\n\t\treturn nil, fmt.Errorf(\"scheduler: failed to recover, err %w\", err)\n\t}\n\tlogger.Info(\"successfully recover\", \"scheduler\", name)\n\treturn sch, nil\n}\n\nfunc indexByName(taskNodes []*taskNode) map[string]*taskNode {\n\tret := make(map[string]*taskNode, len(taskNodes))\n\tfor i := range taskNodes {\n\t\tret[taskNodes[i].Name] = taskNodes[i]\n\t}\n\treturn ret\n}\n\nfunc (sch *defaultTaskScheduler) Schedule() error {\n\tsch.refreshFreePods()\n\tsch.collectTaskStatus(sch.taskNodes)\n\treturn sch.scheduleTaskNodes()\n}\n\nfunc (sch *defaultTaskScheduler) UpdatePods(pods []*corev1.Pod) {\n\tsch.allPods = pods\n}\n\nfunc (sch *defaultTaskScheduler) ListTask() []Task {\n\tret := make([]Task, len(sch.taskNodes), len(sch.taskNodes))\n\tfor i := range sch.taskNodes {\n\t\tret[i] = sch.taskNodes[i]\n\t}\n\treturn ret\n}\n\nfunc (sch *defaultTaskScheduler) StopTask() []Task {\n\tdeletedTask := make([]Task, len(sch.taskNodes), len(sch.taskNodes))\n\tfor i := range sch.taskNodes {\n\t\tif sch.taskNodes[i].DeletionTimestamp != nil {\n\t\t\tcontinue\n\t\t}\n\t\tsch.taskNodes[i].DeletionTimestamp = &metav1.Time{Time: timeNow()}\n\t\tdeletedTask[i] = sch.taskNodes[i]\n\t}\n\treturn deletedTask\n}\n\nfunc initTaskNodes(tasks []*api.Task) ([]*taskNode, error) {\n\tsize := len(tasks)\n\ttaskNodes := make([]*taskNode, size)\n\tfor idx := 0; idx < size; idx++ {\n\t\ttask := tasks[idx]\n\t\ttNode := &taskNode{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: task.Name,\n\t\t\t},\n\t\t\tSpec: taskSpec{\n\t\t\t\tProcess:         task.Process,\n\t\t\t\tPodTemplateSpec: task.PodTemplateSpec,\n\t\t\t},\n\t\t}\n\t\ttaskNodes[idx] = tNode\n\t}\n\treturn taskNodes, nil\n}\n\n// collectTaskStatus from Pod via endpoint\nfunc (sch *defaultTaskScheduler) collectTaskStatus(taskNodes []*taskNode) {\n\tips := []string{}\n\tfor _, tNode := range taskNodes {\n\t\t// unassigned no need to collect task status\n\t\tif tNode.IP == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tips = append(ips, tNode.IP)\n\t}\n\tif len(ips) == 0 {\n\t\treturn\n\t}\n\ttasks := sch.taskStatusCollector.Collect(context.Background(), ips)\n\tfor _, tNode := range taskNodes {\n\t\ttask, ok := tasks[tNode.IP]\n\t\ttNode.Status = task\n\t\tif ok && task != nil {\n\t\t\ttNode.transTaskState(parseTaskState(task), sch.logger)\n\t\t}\n\t}\n}\n\nfunc parseTaskState(task *api.Task) TaskState {\n\tif task.ProcessStatus != nil {\n\t\treturn parseProcessTaskState(task.ProcessStatus)\n\t}\n\tif task.PodStatus != nil {\n\t\treturn parsePodTaskState(task.PodStatus)\n\t}\n\treturn UnknownTaskState\n}\n\nfunc parseProcessTaskState(status *api.ProcessStatus) TaskState {\n\tif status.Running != nil {\n\t\treturn RunningTaskState\n\t} else if status.Terminated != nil {\n\t\tif status.Terminated.ExitCode == 0 {\n\t\t\treturn SucceedTaskState\n\t\t} else {\n\t\t\treturn FailedTaskState\n\t\t}\n\t}\n\treturn UnknownTaskState\n}\n\nfunc parsePodTaskState(status *corev1.PodStatus) TaskState {\n\tswitch status.Phase {\n\tcase corev1.PodRunning:\n\t\tif utils.IsPodReadyConditionTrue(*status) {\n\t\t\treturn RunningTaskState\n\t\t}\n\tcase corev1.PodSucceeded:\n\t\treturn SucceedTaskState\n\tcase corev1.PodFailed:\n\t\treturn FailedTaskState\n\t}\n\treturn UnknownTaskState\n}\n\nfunc (sch *defaultTaskScheduler) scheduleTaskNodes() error {\n\tsch.freePods = assignTaskNodes(sch.taskNodes, sch.freePods, sch.logger)\n\tsemaphore := make(chan struct{}, sch.maxConcurrency)\n\tvar wg sync.WaitGroup\n\tfor idx := range sch.taskNodes {\n\t\ttNode := sch.taskNodes[idx]\n\t\tsemaphore <- struct{}{}\n\t\twg.Add(1)\n\t\tgo func(node *taskNode) {\n\t\t\tdefer func() {\n\t\t\t\t<-semaphore\n\t\t\t\twg.Done()\n\t\t\t}()\n\t\t\tscheduleSingleTaskNode(node, sch.taskClientCreator, sch.resPolicyWhenTaskComplete, sch.logger)\n\t\t}(tNode)\n\t}\n\twg.Wait()\n\treturn nil\n}\n\n// refreshFreePods updates the freePods slice based on allPods and currently assigned pods\n// This ensures that each pod is only assigned to one taskNode\n// Only pods with IP addresses are considered free for assignment\nfunc (sch *defaultTaskScheduler) refreshFreePods() {\n\t// Create a map of assigned pod names for quick lookup\n\tassignedPods := make(map[string]bool, len(sch.allPods)/2)\n\tfor _, tNode := range sch.taskNodes {\n\t\tif tNode.IP != \"\" && tNode.PodName != \"\" {\n\t\t\tassignedPods[tNode.PodName] = true\n\t\t}\n\t}\n\t// Rebuild freePods list with only unassigned pods that have IP addresses\n\tsch.freePods = make([]*corev1.Pod, 0, len(sch.allPods)/2)\n\tfor _, pod := range sch.allPods {\n\t\t// Only consider pods with IP addresses as free for assignment\n\t\tif !assignedPods[pod.Name] && pod.Status.PodIP != \"\" {\n\t\t\tsch.freePods = append(sch.freePods, pod)\n\t\t}\n\t}\n}\n\n// assignTaskNodes handles all unassigned tasks in batch\nfunc assignTaskNodes(taskNodes []*taskNode, freePods []*corev1.Pod, log logr.Logger) []*corev1.Pod {\n\tfor _, tNode := range taskNodes {\n\t\tif len(freePods) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tif tNode.IP != \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tpod := freePods[0]\n\t\tlog.Info(\"assign Pod to task node\", \"podName\", pod.Name, \"podNamespace\", pod.Namespace, \"podIP\", pod.Status.PodIP, \"taskName\", tNode.Name)\n\t\ttNode.IP = pod.Status.PodIP\n\t\ttNode.PodName = pod.Name\n\t\tfreePods = freePods[1:]\n\t}\n\treturn freePods\n}\n\nfunc needRelease(tNode *taskNode, policy sandboxv1alpha1.TaskResourcePolicy) bool {\n\tif tNode.DeletionTimestamp != nil {\n\t\treturn true\n\t}\n\tif policy == sandboxv1alpha1.TaskResourcePolicyRelease && tNode.isTaskCompleted() {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// scheduleSingleTaskNode handles scheduling for a single task node based on its state\nfunc scheduleSingleTaskNode(tNode *taskNode, taskClientCreator func(endpoint string) taskClient, resPolicyWhenTaskComplete sandboxv1alpha1.TaskResourcePolicy, log logr.Logger) {\n\t// pending\n\tif tNode.IP == \"\" {\n\t\tif tNode.DeletionTimestamp != nil {\n\t\t\ttNode.transSchState(stateReleased, log)\n\t\t}\n\t} else {\n\t\t// assigned\n\t\tif needRelease(tNode, resPolicyWhenTaskComplete) {\n\t\t\ttNode.transSchState(stateReleasing, log)\n\t\t} else {\n\t\t\t// no need to setTask if task is completed to avoid unnecessary network overhead\n\t\t\tif !tNode.isTaskCompleted() {\n\t\t\t\ttask := &api.Task{\n\t\t\t\t\tName:            tNode.Name,\n\t\t\t\t\tProcess:         tNode.Spec.Process,\n\t\t\t\t\tPodTemplateSpec: tNode.Spec.PodTemplateSpec,\n\t\t\t\t}\n\t\t\t\t_, err := setTask(taskClientCreator(tNode.IP), task, log)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error(err, \"Failed to set task\", \"taskName\", tNode.Name, \"endpoint\", tNode.IP)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif tNode.sState == stateReleasing {\n\t\tif tNode.isTaskDeleted() {\n\t\t\ttNode.transSchState(stateReleased, log)\n\t\t} else {\n\t\t\t_, err := setTask(taskClientCreator(tNode.IP), nil, log)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(err, \"Failed to notify executor about releasing task\", \"taskName\", tNode.Name, \"endpoint\", tNode.IP)\n\t\t\t} else {\n\t\t\t\tlog.Info(\"Successfully to notify client to release task\", \"taskName\", tNode.Name, \"endpoint\", tNode.IP)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc setTask(client taskClient, task *api.Task, log logr.Logger) (*api.Task, error) {\n\tctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)\n\tdefer cancel()\n\tverboseLog := log.V(3)\n\tif verboseLog.Enabled() {\n\t\tverboseLog.Info(\"client set task\", \"task\", utils.DumpJSON(task))\n\t}\n\treturn client.Set(ctx, task)\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/default_scheduler_mock.go",
    "content": "package scheduler\n\n// Code generated by MockGen. DO NOT EDIT.\n// Source: internal/task/scheduler/default_scheduler.go\n\n// Package mock_scheduler is a generated GoMock package.\n\nimport (\n\tcontext \"context\"\n\treflect \"reflect\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// MocktaskClient is a mock of taskClient interface.\ntype MocktaskClient struct {\n\tctrl     *gomock.Controller\n\trecorder *MocktaskClientMockRecorder\n}\n\n// MocktaskClientMockRecorder is the mock recorder for MocktaskClient.\ntype MocktaskClientMockRecorder struct {\n\tmock *MocktaskClient\n}\n\n// NewMocktaskClient creates a new mock instance.\nfunc NewMocktaskClient(ctrl *gomock.Controller) *MocktaskClient {\n\tmock := &MocktaskClient{ctrl: ctrl}\n\tmock.recorder = &MocktaskClientMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MocktaskClient) EXPECT() *MocktaskClientMockRecorder {\n\treturn m.recorder\n}\n\n// Get mocks base method.\nfunc (m *MocktaskClient) Get(ctx context.Context) (*api.Task, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Get\", ctx)\n\tret0, _ := ret[0].(*api.Task)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Get indicates an expected call of Get.\nfunc (mr *MocktaskClientMockRecorder) Get(ctx interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Get\", reflect.TypeOf((*MocktaskClient)(nil).Get), ctx)\n}\n\n// Set mocks base method.\nfunc (m *MocktaskClient) Set(ctx context.Context, task *api.Task) (*api.Task, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Set\", ctx, task)\n\tret0, _ := ret[0].(*api.Task)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Set indicates an expected call of Set.\nfunc (mr *MocktaskClientMockRecorder) Set(ctx, task interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Set\", reflect.TypeOf((*MocktaskClient)(nil).Set), ctx, task)\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/default_scheduler_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/go-logr/logr\"\n\t\"github.com/golang/mock/gomock\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// mockLogger is a simple logger implementation for testing\ntype mockLogger struct{}\n\nfunc (m mockLogger) Init(info logr.RuntimeInfo)                                {}\nfunc (m mockLogger) Info(level int, msg string, keysAndValues ...interface{})  {}\nfunc (m mockLogger) Error(err error, msg string, keysAndValues ...interface{}) {}\nfunc (m mockLogger) Enabled(level int) bool                                    { return false }\nfunc (m mockLogger) WithValues(keysAndValues ...interface{}) logr.LogSink      { return m }\nfunc (m mockLogger) WithName(name string) logr.LogSink                         { return m }\n\nvar testLogger = logr.New(mockLogger{})\n\nfunc Test_scheduleSingleTaskNode(t *testing.T) {\n\tctl := gomock.NewController(t)\n\tdefer ctl.Finish()\n\tmockTimeNow := time.Now()\n\to := timeNow\n\ttimeNow = func() time.Time {\n\t\treturn mockTimeNow\n\t}\n\tdefer func() {\n\t\ttimeNow = o\n\t}()\n\ttype args struct {\n\t\ttNode             *taskNode\n\t\ttaskClientCreator func(endpoint string) taskClient\n\t}\n\ttests := []struct {\n\t\tname           string\n\t\targs           args\n\t\texpectTaskNode *taskNode\n\t}{\n\t\t{\n\t\t\tname: \"pending task node, deleting \",\n\t\t\targs: args{\n\t\t\t\ttNode: &taskNode{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName:              \"test-batch-sandbox-0\",\n\t\t\t\t\t\tDeletionTimestamp: &metav1.Time{Time: mockTimeNow},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\tName:              \"test-batch-sandbox-0\",\n\t\t\t\t\tDeletionTimestamp: &metav1.Time{Time: mockTimeNow},\n\t\t\t\t},\n\t\t\t\tsState:              stateReleased,\n\t\t\t\tsStateLastTransTime: &mockTimeNow,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"assigned task node, task state=Running, deleting; setTask(nil)\",\n\t\t\targs: args{\n\t\t\t\ttNode: &taskNode{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName:              \"test-batch-sandbox-0\",\n\t\t\t\t\t\tDeletionTimestamp: &metav1.Time{Time: mockTimeNow},\n\t\t\t\t\t},\n\t\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\t\tStatus: &api.Task{\n\t\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttState: RunningTaskState,\n\t\t\t\t},\n\t\t\t\ttaskClientCreator: func(endpoint string) taskClient {\n\t\t\t\t\tmock := NewMocktaskClient(ctl)\n\t\t\t\t\tmock.EXPECT().Set(gomock.Any(), nil).Return(nil, nil).Times(1)\n\t\t\t\t\treturn mock\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\tName:              \"test-batch-sandbox-0\",\n\t\t\t\t\tDeletionTimestamp: &metav1.Time{Time: mockTimeNow},\n\t\t\t\t},\n\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\tStatus: &api.Task{\n\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\ttState:              RunningTaskState,\n\t\t\t\tsState:              stateReleasing,\n\t\t\t\tsStateLastTransTime: &mockTimeNow,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"assigned task node, task state=Running; setTask(task)\",\n\t\t\targs: args{\n\t\t\t\ttNode: &taskNode{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-batch-sandbox-0\",\n\t\t\t\t\t},\n\t\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: &api.Task{\n\t\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttState: RunningTaskState,\n\t\t\t\t},\n\t\t\t\ttaskClientCreator: func(endpoint string) taskClient {\n\t\t\t\t\tmock := NewMocktaskClient(ctl)\n\t\t\t\t\tmock.EXPECT().Set(gomock.Any(), &api.Task{\n\t\t\t\t\t\tName: \"test-batch-sandbox-0\",\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t}).Return(nil, nil).Times(1)\n\t\t\t\t\treturn mock\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\tName: \"test-batch-sandbox-0\",\n\t\t\t\t},\n\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\tSpec: taskSpec{\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: &api.Task{\n\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\ttState: RunningTaskState,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"assigned task node, task state=Succeed, endpoint return nil task; sState trans from releasing -> released \",\n\t\t\targs: args{\n\t\t\t\ttNode: &taskNode{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-batch-sandbox-0\",\n\t\t\t\t\t},\n\t\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: nil,\n\t\t\t\t\ttState: SucceedTaskState,\n\t\t\t\t\tsState: stateReleasing,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\tName: \"test-batch-sandbox-0\",\n\t\t\t\t},\n\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\tSpec: taskSpec{\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus:              nil,\n\t\t\t\ttState:              SucceedTaskState,\n\t\t\t\tsState:              stateReleased,\n\t\t\t\tsStateLastTransTime: &mockTimeNow,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tscheduleSingleTaskNode(tt.args.tNode, tt.args.taskClientCreator, \"\", testLogger)\n\t\t\tif !reflect.DeepEqual(tt.expectTaskNode, tt.args.tNode) {\n\t\t\t\tt.Errorf(\"scheduleSingleTaskNode, want %+v, got %+v\", tt.expectTaskNode, tt.args.tNode)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_assignTaskNodes(t *testing.T) {\n\ttype args struct {\n\t\ttaskNodes []*taskNode\n\t\tfreePods  []*corev1.Pod\n\t}\n\ttests := []struct {\n\t\tname            string\n\t\targs            args\n\t\twant            []*corev1.Pod\n\t\texpectTaskNodes []*taskNode\n\t}{\n\t\t{\n\t\t\tname: \"empty free pods, no assignment\",\n\t\t\targs: args{\n\t\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"free pods, assign\",\n\t\t\targs: args{\n\t\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfreePods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"pod-hello-world\"},\n\t\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.2.3.4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*corev1.Pod{},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t\tIP:         \"1.2.3.4\",\n\t\t\t\t\tPodName:    \"pod-hello-world\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"free pods, no unassigned task nodes, no assignment\",\n\t\t\targs: args{\n\t\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t\t\tIP:         \"4.3.2.1\",\n\t\t\t\t\t\tPodName:    \"pod-foo-bar\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tfreePods: []*corev1.Pod{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"pod-hello-world\"},\n\t\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.2.3.4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"pod-hello-world\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t\tIP:         \"4.3.2.1\",\n\t\t\t\t\tPodName:    \"pod-foo-bar\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := assignTaskNodes(tt.args.taskNodes, tt.args.freePods, testLogger); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"assignTaskNodes() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(tt.expectTaskNodes, tt.args.taskNodes) {\n\t\t\t\tt.Errorf(\"assignTaskNodes() = %v, want %v\", tt.expectTaskNodes, tt.args.taskNodes)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_refreshFreePods(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tallPods       []*corev1.Pod\n\t\ttaskNodes     []*taskNode\n\t\texpectedFree  int\n\t\texpectedNames []string\n\t}{\n\t\t{\n\t\t\tname: \"no assigned pods\",\n\t\t\tallPods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-1\"}},\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-2\"}},\n\t\t\t},\n\t\t\texpectedFree:  2,\n\t\t\texpectedNames: []string{\"pod-1\", \"pod-2\"},\n\t\t},\n\t\t{\n\t\t\tname: \"some assigned pods\",\n\t\t\tallPods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-3\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-2\"}},\n\t\t\t},\n\t\t\texpectedFree:  2,\n\t\t\texpectedNames: []string{\"pod-2\", \"pod-3\"},\n\t\t},\n\t\t{\n\t\t\tname: \"all pods assigned\",\n\t\t\tallPods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tIP:         \"1.1.1.2\",\n\t\t\t\t\tPodName:    \"pod-2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedFree:  0,\n\t\t\texpectedNames: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"pods without IP addresses\",\n\t\t\tallPods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-1\"}},\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-2\"}},\n\t\t\t},\n\t\t\texpectedFree:  1,\n\t\t\texpectedNames: []string{\"pod-1\"},\n\t\t},\n\t\t{\n\t\t\tname:    \"empty pods list\",\n\t\t\tallPods: []*corev1.Pod{},\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"task-1\"}},\n\t\t\t},\n\t\t\texpectedFree:  0,\n\t\t\texpectedNames: []string{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsch := &defaultTaskScheduler{\n\t\t\t\tallPods:   tt.allPods,\n\t\t\t\ttaskNodes: tt.taskNodes,\n\t\t\t}\n\n\t\t\tsch.refreshFreePods()\n\n\t\t\tif len(sch.freePods) != tt.expectedFree {\n\t\t\t\tt.Errorf(\"refreshFreePods() freePods length = %v, want %v\", len(sch.freePods), tt.expectedFree)\n\t\t\t}\n\n\t\t\tactualNames := make([]string, len(sch.freePods))\n\t\t\tfor i, pod := range sch.freePods {\n\t\t\t\tactualNames[i] = pod.Name\n\t\t\t}\n\n\t\t\tif !reflect.DeepEqual(actualNames, tt.expectedNames) {\n\t\t\t\tt.Errorf(\"refreshFreePods() freePods names = %v, want %v\", actualNames, tt.expectedNames)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_collectTaskStatus(t *testing.T) {\n\tctl := gomock.NewController(t)\n\tdefer ctl.Finish()\n\n\tmockTimeNow := time.Now()\n\to := timeNow\n\ttimeNow = func() time.Time {\n\t\treturn mockTimeNow\n\t}\n\tdefer func() {\n\t\ttimeNow = o\n\t}()\n\n\ttests := []struct {\n\t\tname               string\n\t\ttaskNodes          []*taskNode\n\t\texpectedCollectIPs []string\n\t\tmockReturnTasks    map[string]*api.Task\n\t\texpectedTaskNodes  []*taskNode\n\t}{\n\t\t{\n\t\t\tname: \"no assigned task nodes\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCollectIPs: []string{},\n\t\t\tmockReturnTasks:    map[string]*api.Task{},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"assigned task nodes with task status\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tIP:         \"1.1.1.2\",\n\t\t\t\t\tPodName:    \"pod-2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCollectIPs: []string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmockReturnTasks: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": {\n\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"1.1.1.2\": {\n\t\t\t\t\tName: \"task-2\",\n\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\tTerminated: &api.Terminated{\n\t\t\t\t\t\t\tExitCode:   0,\n\t\t\t\t\t\t\tFinishedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t\tStatus: &api.Task{\n\t\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttState:              RunningTaskState,\n\t\t\t\t\ttStateLastTransTime: &mockTimeNow,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tIP:         \"1.1.1.2\",\n\t\t\t\t\tPodName:    \"pod-2\",\n\t\t\t\t\tStatus: &api.Task{\n\t\t\t\t\t\tName: \"task-2\",\n\t\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\t\tTerminated: &api.Terminated{\n\t\t\t\t\t\t\t\tExitCode:   0,\n\t\t\t\t\t\t\t\tFinishedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttState:              SucceedTaskState,\n\t\t\t\t\ttStateLastTransTime: &mockTimeNow,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"assigned task nodes with nil task status\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCollectIPs: []string{\"1.1.1.1\"},\n\t\t\tmockReturnTasks: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": nil,\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed assigned and unassigned task nodes\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCollectIPs: []string{\"1.1.1.1\"},\n\t\t\tmockReturnTasks: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": {\n\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tIP:         \"1.1.1.1\",\n\t\t\t\t\tPodName:    \"pod-1\",\n\t\t\t\t\tStatus: &api.Task{\n\t\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\ttState:              RunningTaskState,\n\t\t\t\t\ttStateLastTransTime: &mockTimeNow,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create mock task status collector\n\t\t\tmockCollector := NewMocktaskStatusCollector(ctl)\n\t\t\tif len(tt.expectedCollectIPs) > 0 {\n\t\t\t\tmockCollector.EXPECT().Collect(gomock.Any(), tt.expectedCollectIPs).Return(tt.mockReturnTasks).Times(1)\n\t\t\t}\n\n\t\t\t// Create scheduler with mock collector\n\t\t\tsch := &defaultTaskScheduler{\n\t\t\t\ttaskNodes:           tt.taskNodes,\n\t\t\t\ttaskStatusCollector: mockCollector,\n\t\t\t\tlogger:              testLogger,\n\t\t\t}\n\n\t\t\t// Call collectTaskStatus\n\t\t\tsch.collectTaskStatus(tt.taskNodes)\n\n\t\t\t// Verify results\n\t\t\tfor i, expectedNode := range tt.expectedTaskNodes {\n\t\t\t\tactualNode := tt.taskNodes[i]\n\n\t\t\t\tif actualNode.Name != expectedNode.Name {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].Name = %v, want %v\", i, actualNode.Name, expectedNode.Name)\n\t\t\t\t}\n\n\t\t\t\tif actualNode.IP != expectedNode.IP {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].IP = %v, want %v\", i, actualNode.IP, expectedNode.IP)\n\t\t\t\t}\n\n\t\t\t\tif actualNode.PodName != expectedNode.PodName {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].PodName = %v, want %v\", i, actualNode.PodName, expectedNode.PodName)\n\t\t\t\t}\n\n\t\t\t\tif expectedNode.Status == nil {\n\t\t\t\t\tif actualNode.Status != nil {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].Status = %v, want nil\", i, actualNode.Status)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif actualNode.Status == nil {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].Status = nil, want %v\", i, expectedNode.Status)\n\t\t\t\t\t} else if actualNode.Status.Name != expectedNode.Status.Name {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].Status.Name = %v, want %v\", i, actualNode.Status.Name, expectedNode.Status.Name)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif actualNode.tState != expectedNode.tState {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].tState = %v, want %v\", i, actualNode.tState, expectedNode.tState)\n\t\t\t\t}\n\n\t\t\t\t// Compare time pointers\n\t\t\t\tif expectedNode.tStateLastTransTime == nil {\n\t\t\t\t\tif actualNode.tStateLastTransTime != nil {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].tStateLastTransTime = %v, want nil\", i, actualNode.tStateLastTransTime)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif actualNode.tStateLastTransTime == nil {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].tStateLastTransTime = nil, want %v\", i, expectedNode.tStateLastTransTime)\n\t\t\t\t\t} else if !actualNode.tStateLastTransTime.Equal(*expectedNode.tStateLastTransTime) {\n\t\t\t\t\t\tt.Errorf(\"taskNode[%d].tStateLastTransTime = %v, want %v\", i, actualNode.tStateLastTransTime, expectedNode.tStateLastTransTime)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_indexByName(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\ttaskNodes []*taskNode\n\t\texpected  map[string]*taskNode\n\t}{\n\t\t{\n\t\t\tname:      \"empty task nodes\",\n\t\t\ttaskNodes: []*taskNode{},\n\t\t\texpected:  map[string]*taskNode{},\n\t\t},\n\t\t{\n\t\t\tname: \"single task node\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string]*taskNode{\n\t\t\t\t\"task-1\": {\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple task nodes\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string]*taskNode{\n\t\t\t\t\"task-1\": {\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t\t\"task-2\": {\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t},\n\t\t\t\t\"task-3\": {\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate task node names\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string]*taskNode{\n\t\t\t\t\"task-1\": {\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := indexByName(tt.taskNodes)\n\n\t\t\tif len(result) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"indexByName() map length = %v, want %v\", len(result), len(tt.expected))\n\t\t\t}\n\n\t\t\tfor key, expectedNode := range tt.expected {\n\t\t\t\tactualNode, ok := result[key]\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Errorf(\"indexByName() missing key %v\", key)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif actualNode.Name != expectedNode.Name {\n\t\t\t\t\tt.Errorf(\"indexByName()[%v].Name = %v, want %v\", key, actualNode.Name, expectedNode.Name)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_scheduleTaskNodes(t *testing.T) {\n\tctl := gomock.NewController(t)\n\tdefer ctl.Finish()\n\n\t// Mock time for consistent testing\n\tmockTimeNow := time.Now()\n\to := timeNow\n\ttimeNow = func() time.Time {\n\t\treturn mockTimeNow\n\t}\n\tdefer func() {\n\t\ttimeNow = o\n\t}()\n\n\ttests := []struct {\n\t\tname                      string\n\t\ttaskNodes                 []*taskNode\n\t\tfreePods                  []*corev1.Pod\n\t\tbatchSbx                  *sandboxv1alpha1.BatchSandbox\n\t\texpectedTaskNodes         []*taskNode\n\t\texpectedRemainingFreePods int\n\t\texpectedSetCalls          map[string]*api.Task // IP -> Expected Task\n\t}{\n\t\t{\n\t\t\tname: \"assign free pods to unassigned task nodes\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfreePods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-1\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-batch\"},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.1\",\n\t\t\t\t\tPodName: \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.2\",\n\t\t\t\t\tPodName: \"pod-2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRemainingFreePods: 0,\n\t\t\texpectedSetCalls: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": {\n\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"1.1.1.2\": {\n\t\t\t\t\tName: \"task-2\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no free pods available\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfreePods: []*corev1.Pod{},\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-batch\"},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRemainingFreePods: 0,\n\t\t\texpectedSetCalls:          map[string]*api.Task{},\n\t\t},\n\t\t{\n\t\t\tname: \"some task nodes already assigned\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.1\",\n\t\t\t\t\tPodName: \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfreePods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-batch\"},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.1\",\n\t\t\t\t\tPodName: \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.2\",\n\t\t\t\t\tPodName: \"pod-2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRemainingFreePods: 0,\n\t\t\texpectedSetCalls: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": {\n\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"1.1.1.2\": {\n\t\t\t\t\tName: \"task-2\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"more free pods than unassigned tasks\",\n\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.1\",\n\t\t\t\t\tPodName: \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfreePods: []*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-2\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.2\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-3\"},\n\t\t\t\t\tStatus:     corev1.PodStatus{PodIP: \"1.1.1.3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tbatchSbx: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-batch\"},\n\t\t\t},\n\t\t\texpectedTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-1\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.1\",\n\t\t\t\t\tPodName: \"pod-1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"task-2\"},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tIP:      \"1.1.1.2\",\n\t\t\t\t\tPodName: \"pod-2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRemainingFreePods: 1,\n\t\t\texpectedSetCalls: map[string]*api.Task{\n\t\t\t\t\"1.1.1.1\": {\n\t\t\t\t\tName: \"task-1\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"1.1.1.2\": {\n\t\t\t\t\tName: \"task-2\",\n\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create mock task clients for each pod IP and task node\n\t\t\tmockClients := make(map[string]*MocktaskClient)\n\n\t\t\t// Create task client creator function that returns mock clients\n\t\t\ttaskClientCreator := func(ip string) taskClient {\n\t\t\t\tif mockClient, ok := mockClients[ip]; ok {\n\t\t\t\t\treturn mockClient\n\t\t\t\t}\n\t\t\t\tmockClient := NewMocktaskClient(ctl)\n\t\t\t\tmockClients[ip] = mockClient\n\t\t\t\treturn mockClient\n\t\t\t}\n\n\t\t\t// Set expectations for Set calls\n\t\t\tfor ip, expectedTask := range tt.expectedSetCalls {\n\t\t\t\tmockClient := mockClients[ip]\n\t\t\t\tif mockClient == nil {\n\t\t\t\t\tmockClient = NewMocktaskClient(ctl)\n\t\t\t\t\tmockClients[ip] = mockClient\n\t\t\t\t}\n\t\t\t\tmockClient.EXPECT().Set(gomock.Any(), expectedTask).Return(expectedTask, nil).Times(1)\n\t\t\t}\n\n\t\t\t// Create scheduler\n\t\t\tsch := &defaultTaskScheduler{\n\t\t\t\ttaskNodes:         tt.taskNodes,\n\t\t\t\tfreePods:          tt.freePods,\n\t\t\t\tmaxConcurrency:    defaultSchConcurrency,\n\t\t\t\ttaskClientCreator: taskClientCreator,\n\t\t\t\tlogger:            testLogger,\n\t\t\t}\n\n\t\t\t// Call scheduleTaskNodes\n\t\t\terr := sch.scheduleTaskNodes()\n\n\t\t\t// Verify no error\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"scheduleTaskNodes() error = %v, want nil\", err)\n\t\t\t}\n\n\t\t\t// Verify results\n\t\t\tfor i, expectedNode := range tt.expectedTaskNodes {\n\t\t\t\tactualNode := tt.taskNodes[i]\n\n\t\t\t\tif actualNode.Name != expectedNode.Name {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].Name = %v, want %v\", i, actualNode.Name, expectedNode.Name)\n\t\t\t\t}\n\n\t\t\t\tif actualNode.IP != expectedNode.IP {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].IP = %v, want %v\", i, actualNode.IP, expectedNode.IP)\n\t\t\t\t}\n\n\t\t\t\tif actualNode.PodName != expectedNode.PodName {\n\t\t\t\t\tt.Errorf(\"taskNode[%d].PodName = %v, want %v\", i, actualNode.PodName, expectedNode.PodName)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Verify remaining free pods\n\t\t\tif len(sch.freePods) != tt.expectedRemainingFreePods {\n\t\t\t\tt.Errorf(\"scheduleTaskNodes() remaining freePods length = %v, want %v\", len(sch.freePods), tt.expectedRemainingFreePods)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_parseTaskState(t *testing.T) {\n\tmockTimeNow := time.Now()\n\n\ttests := []struct {\n\t\tname     string\n\t\ttask     *api.Task\n\t\texpected TaskState\n\t}{\n\t\t{\n\t\t\tname: \"running task\",\n\t\t\ttask: &api.Task{\n\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\tRunning: &api.Running{\n\t\t\t\t\t\tStartedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: RunningTaskState,\n\t\t},\n\t\t{\n\t\t\tname: \"succeed task\",\n\t\t\ttask: &api.Task{\n\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\tTerminated: &api.Terminated{\n\t\t\t\t\t\tExitCode:   0,\n\t\t\t\t\t\tFinishedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: SucceedTaskState,\n\t\t},\n\t\t{\n\t\t\tname: \"failed task\",\n\t\t\ttask: &api.Task{\n\t\t\t\tProcessStatus: &api.ProcessStatus{\n\t\t\t\t\tTerminated: &api.Terminated{\n\t\t\t\t\t\tExitCode:   1,\n\t\t\t\t\t\tFinishedAt: metav1.NewTime(mockTimeNow),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: FailedTaskState,\n\t\t},\n\t\t{\n\t\t\tname: \"unknown task state\",\n\t\t\ttask: &api.Task{\n\t\t\t\tProcessStatus: &api.ProcessStatus{},\n\t\t\t},\n\t\t\texpected: UnknownTaskState,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := parseTaskState(tt.task)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"parseTaskState() = %v, want %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_initTaskNodes(t *testing.T) {\n\ttype args struct {\n\t\ttasks []*api.Task\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    []*taskNode\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"init success\",\n\t\t\targs: args{\n\t\t\t\ttasks: []*api.Task{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-task-0\",\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"tail\", \"-f\", \"/dev/null\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-task-0\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"tail\", \"-f\", \"/dev/null\"},\n\t\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"init multiple tasks\",\n\t\t\targs: args{\n\t\t\t\ttasks: []*api.Task{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-task-0\",\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"test-task-1\",\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-task-0\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-task-1\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: taskSpec{\n\t\t\t\t\t\tProcess: &api.Process{\n\t\t\t\t\t\t\tCommand: []string{\"echo\", \"world\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"init empty tasks\",\n\t\t\targs: args{\n\t\t\t\ttasks: []*api.Task{},\n\t\t\t},\n\t\t\twant: []*taskNode{},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := initTaskNodes(tt.args.tasks)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"initTaskNodes() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"initTaskNodes() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tapis \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n\n\t\"github.com/go-logr/logr\"\n\tcorev1 \"k8s.io/api/core/v1\"\n)\n\ntype TaskScheduler interface {\n\tSchedule() error\n\tUpdatePods(pod []*corev1.Pod)\n\tListTask() []Task\n\tStopTask() []Task\n}\n\nfunc NewTaskScheduler(name string, tasks []*apis.Task, pods []*corev1.Pod, resPolicyWhenTaskCompleted sandboxv1alpha1.TaskResourcePolicy, logger logr.Logger) (TaskScheduler, error) {\n\treturn newTaskScheduler(name, tasks, pods, resPolicyWhenTaskCompleted, logger)\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/mock/interface.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: internal/task/scheduler/interface.go\n\n// Package mock_scheduler is a generated GoMock package.\npackage mock_scheduler\n\nimport (\n\treflect \"reflect\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\tv1 \"k8s.io/api/core/v1\"\n\n\tscheduler \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler\"\n)\n\n// MockTaskScheduler is a mock of TaskScheduler interface.\ntype MockTaskScheduler struct {\n\tctrl     *gomock.Controller\n\trecorder *MockTaskSchedulerMockRecorder\n}\n\n// MockTaskSchedulerMockRecorder is the mock recorder for MockTaskScheduler.\ntype MockTaskSchedulerMockRecorder struct {\n\tmock *MockTaskScheduler\n}\n\n// NewMockTaskScheduler creates a new mock instance.\nfunc NewMockTaskScheduler(ctrl *gomock.Controller) *MockTaskScheduler {\n\tmock := &MockTaskScheduler{ctrl: ctrl}\n\tmock.recorder = &MockTaskSchedulerMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockTaskScheduler) EXPECT() *MockTaskSchedulerMockRecorder {\n\treturn m.recorder\n}\n\n// ListTask mocks base method.\nfunc (m *MockTaskScheduler) ListTask() []scheduler.Task {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"ListTask\")\n\tret0, _ := ret[0].([]scheduler.Task)\n\treturn ret0\n}\n\n// ListTask indicates an expected call of ListTask.\nfunc (mr *MockTaskSchedulerMockRecorder) ListTask() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"ListTask\", reflect.TypeOf((*MockTaskScheduler)(nil).ListTask))\n}\n\n// Schedule mocks base method.\nfunc (m *MockTaskScheduler) Schedule() error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Schedule\")\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Schedule indicates an expected call of Schedule.\nfunc (mr *MockTaskSchedulerMockRecorder) Schedule() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Schedule\", reflect.TypeOf((*MockTaskScheduler)(nil).Schedule))\n}\n\n// StopTask mocks base method.\nfunc (m *MockTaskScheduler) StopTask() []scheduler.Task {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"StopTask\")\n\tret0, _ := ret[0].([]scheduler.Task)\n\treturn ret0\n}\n\n// StopTask indicates an expected call of StopTask.\nfunc (mr *MockTaskSchedulerMockRecorder) StopTask() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"StopTask\", reflect.TypeOf((*MockTaskScheduler)(nil).StopTask))\n}\n\n// UpdatePods mocks base method.\nfunc (m *MockTaskScheduler) UpdatePods(pod []*v1.Pod) {\n\tm.ctrl.T.Helper()\n\tm.ctrl.Call(m, \"UpdatePods\", pod)\n}\n\n// UpdatePods indicates an expected call of UpdatePods.\nfunc (mr *MockTaskSchedulerMockRecorder) UpdatePods(pod interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"UpdatePods\", reflect.TypeOf((*MockTaskScheduler)(nil).UpdatePods), pod)\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/mock/types.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: internal/task/scheduler/types.go\n\n// Package mock_scheduler is a generated GoMock package.\npackage mock_scheduler\n\nimport (\n\treflect \"reflect\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\n\tscheduler \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler\"\n)\n\n// MockTask is a mock of Task interface.\ntype MockTask struct {\n\tctrl     *gomock.Controller\n\trecorder *MockTaskMockRecorder\n}\n\n// MockTaskMockRecorder is the mock recorder for MockTask.\ntype MockTaskMockRecorder struct {\n\tmock *MockTask\n}\n\n// NewMockTask creates a new mock instance.\nfunc NewMockTask(ctrl *gomock.Controller) *MockTask {\n\tmock := &MockTask{ctrl: ctrl}\n\tmock.recorder = &MockTaskMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockTask) EXPECT() *MockTaskMockRecorder {\n\treturn m.recorder\n}\n\n// GetName mocks base method.\nfunc (m *MockTask) GetName() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetName\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// GetName indicates an expected call of GetName.\nfunc (mr *MockTaskMockRecorder) GetName() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetName\", reflect.TypeOf((*MockTask)(nil).GetName))\n}\n\n// GetPodName mocks base method.\nfunc (m *MockTask) GetPodName() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetPodName\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// GetPodName indicates an expected call of GetPodName.\nfunc (mr *MockTaskMockRecorder) GetPodName() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetPodName\", reflect.TypeOf((*MockTask)(nil).GetPodName))\n}\n\n// GetState mocks base method.\nfunc (m *MockTask) GetState() scheduler.TaskState {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GetState\")\n\tret0, _ := ret[0].(scheduler.TaskState)\n\treturn ret0\n}\n\n// GetState indicates an expected call of GetState.\nfunc (mr *MockTaskMockRecorder) GetState() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GetState\", reflect.TypeOf((*MockTask)(nil).GetState))\n}\n\n// IsResourceReleased mocks base method.\nfunc (m *MockTask) IsResourceReleased() bool {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsResourceReleased\")\n\tret0, _ := ret[0].(bool)\n\treturn ret0\n}\n\n// IsResourceReleased indicates an expected call of IsResourceReleased.\nfunc (mr *MockTaskMockRecorder) IsResourceReleased() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsResourceReleased\", reflect.TypeOf((*MockTask)(nil).IsResourceReleased))\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/recovery.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"context\"\n\n\t\"github.com/go-logr/logr\"\n\tv1 \"k8s.io/api/core/v1\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// recover reconstructs the task scheduler state from existing pods and their endpoints\n// This function is used to restore the scheduler state after a restart\nfunc (sch *defaultTaskScheduler) recover() error {\n\tvar err error\n\tsch.once.Do(func() {\n\t\tsch.recoverTaskNodesStatus()\n\t\tsch.logger.Info(\"task scheduler recovered\", \"scheduler\", sch.name, \"task_nodes\", len(sch.taskNodes), \"all_pods\", len(sch.allPods))\n\t})\n\treturn err\n}\n\nfunc (sch *defaultTaskScheduler) recoverTaskNodesStatus() error {\n\tips := make([]string, 0, len(sch.allPods)/2)\n\tpods := make([]*v1.Pod, 0, len(sch.allPods)/2)\n\tfor i := range sch.allPods {\n\t\tpod := sch.allPods[i]\n\t\tif pod.Status.PodIP == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tips = append(ips, pod.Status.PodIP)\n\t\tpods = append(pods, pod)\n\t}\n\tif len(ips) == 0 {\n\t\treturn nil\n\t}\n\t// TODO: When the agent starts stopping a task, if a recovery occurs at this moment,\n\t// the recovery may complete after the agent has already finished stopping the task and returned an empty task list.\n\t// This could cause the scheduler to be unable to determine whether the task was never executed or has already completed.\n\t// It might lead to duplicate execution, but it ensures at-least-once delivery semantics.\n\ttasks := sch.taskStatusCollector.Collect(context.Background(), ips)\n\tfor i := range ips {\n\t\tip := ips[i]\n\t\tpod := pods[i]\n\t\ttask := tasks[ip]\n\t\tif task == nil || pod == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif tNode := sch.taskNodeByNameIndex[task.Name]; tNode != nil {\n\t\t\trecoverOneTaskNode(tNode, task, pod.Status.PodIP, pod.Name, sch.logger)\n\t\t} else {\n\t\t}\n\t\t// TODO do we need to stop tasks not belong us? e.g users ScaleIn []*sandboxv1alpha1.Task\n\t}\n\treturn nil\n}\n\nfunc recoverOneTaskNode(tNode *taskNode, currentTask *api.Task, ip string, podName string, log logr.Logger) {\n\ttNode.Status = currentTask\n\ttNode.transTaskState(parseTaskState(currentTask), log)\n\ttNode.IP = ip\n\ttNode.PodName = podName\n\tif currentTask.DeletionTimestamp != nil {\n\t\ttNode.transSchState(stateReleasing, log)\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/recovery_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"reflect\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"github.com/golang/mock/gomock\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc Test_recoverOneTaskNode(t *testing.T) {\n\tmockTimeNow := time.Now()\n\to := timeNow\n\ttimeNow = func() time.Time {\n\t\treturn mockTimeNow\n\t}\n\tdefer func() {\n\t\ttimeNow = o\n\t}()\n\ttestNow := metav1.Time{Time: mockTimeNow}\n\ttestTask := &api.Task{\n\t\tName: \"test\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"sleep\"},\n\t\t},\n\t\tProcessStatus: &api.ProcessStatus{\n\t\t\tRunning: &api.Running{\n\t\t\t\tStartedAt: testNow,\n\t\t\t},\n\t\t},\n\t}\n\ttestReleasingTask := &api.Task{\n\t\tName:              \"test\",\n\t\tDeletionTimestamp: &testNow,\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"sleep\"},\n\t\t},\n\t\tProcessStatus: &api.ProcessStatus{\n\t\t\tRunning: &api.Running{\n\t\t\t\tStartedAt: testNow,\n\t\t\t},\n\t\t},\n\t}\n\ttype args struct {\n\t\ttNode       *taskNode\n\t\tcurrentTask *api.Task\n\t\tip          string\n\t\tpodName     string\n\t}\n\ttests := []struct {\n\t\tname           string\n\t\targs           args\n\t\texpectTaskNode *taskNode\n\t}{\n\t\t{\n\t\t\tname: \"running task\",\n\t\t\targs: args{\n\t\t\t\ttNode:       &taskNode{},\n\t\t\t\tcurrentTask: testTask,\n\t\t\t\tip:          \"1.2.3.4\",\n\t\t\t\tpodName:     \"foo-bar\",\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tStatus:              testTask,\n\t\t\t\tIP:                  \"1.2.3.4\",\n\t\t\t\tPodName:             \"foo-bar\",\n\t\t\t\ttState:              RunningTaskState,\n\t\t\t\ttStateLastTransTime: &mockTimeNow,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"releasing task\",\n\t\t\targs: args{\n\t\t\t\ttNode:       &taskNode{},\n\t\t\t\tcurrentTask: testReleasingTask,\n\t\t\t\tip:          \"1.2.3.4\",\n\t\t\t\tpodName:     \"foo-bar\",\n\t\t\t},\n\t\t\texpectTaskNode: &taskNode{\n\t\t\t\tStatus:              testReleasingTask,\n\t\t\t\tIP:                  \"1.2.3.4\",\n\t\t\t\tPodName:             \"foo-bar\",\n\t\t\t\tsState:              stateReleasing,\n\t\t\t\tsStateLastTransTime: &mockTimeNow,\n\t\t\t\ttState:              RunningTaskState,\n\t\t\t\ttStateLastTransTime: &mockTimeNow,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\trecoverOneTaskNode(tt.args.tNode, tt.args.currentTask, tt.args.ip, tt.args.podName, testLogger)\n\t\t\tif tt.expectTaskNode != nil {\n\t\t\t\tif !reflect.DeepEqual(tt.expectTaskNode, tt.args.tNode) {\n\t\t\t\t\tt.Errorf(\"recoverOneTaskNode, want %+v, got %+v\", tt.expectTaskNode, tt.args.tNode)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_defaultTaskScheduler_recoverTaskNodesStatus(t *testing.T) {\n\tmockTimeNow := time.Now()\n\to := timeNow\n\ttimeNow = func() time.Time {\n\t\treturn mockTimeNow\n\t}\n\tdefer func() {\n\t\ttimeNow = o\n\t}()\n\tctl := gomock.NewController(t)\n\tdefer ctl.Finish()\n\ttestNow := metav1.Now()\n\ttestTaskNode := &taskNode{\n\t\tObjectMeta: v1.ObjectMeta{\n\t\t\tName: \"bsbx-0\",\n\t\t},\n\t\tSpec: taskSpec{\n\t\t\tProcess: &api.Process{\n\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t},\n\t\t},\n\t}\n\ttestTask := &api.Task{\n\t\tName:    testTaskNode.Name,\n\t\tProcess: testTaskNode.Spec.Process,\n\t\tProcessStatus: &api.ProcessStatus{\n\t\t\tRunning: &api.Running{\n\t\t\t\tStartedAt: testNow,\n\t\t\t},\n\t\t},\n\t}\n\trecoveredTestTaskNode := &taskNode{\n\t\tObjectMeta: v1.ObjectMeta{\n\t\t\tName: \"bsbx-0\",\n\t\t},\n\t\tSpec: taskSpec{\n\t\t\tProcess: &api.Process{\n\t\t\t\tCommand: []string{\"hello\"},\n\t\t\t},\n\t\t},\n\t\tStatus:              testTask,\n\t\tPodName:             \"test-0\",\n\t\tIP:                  \"1.2.3.4\",\n\t\ttState:              RunningTaskState,\n\t\ttStateLastTransTime: &mockTimeNow,\n\t}\n\n\ttype fields struct {\n\t\tfreePods            []*corev1.Pod\n\t\tallPods             []*corev1.Pod\n\t\ttaskNodes           []*taskNode\n\t\ttaskNodeByNameIndex map[string]*taskNode\n\t\tmaxConcurrency      int\n\t\tonce                sync.Once\n\t\ttaskStatusCollector taskStatusCollector\n\t}\n\ttests := []struct {\n\t\tname            string\n\t\tfields          fields\n\t\twantErr         bool\n\t\texpectTaskNodes []*taskNode\n\t}{\n\t\t{\n\t\t\tname: \"recover nothing, pod pending\",\n\t\t\tfields: fields{\n\t\t\t\tallPods: []*corev1.Pod{{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{Name: \"test-0\"},\n\t\t\t\t}},\n\t\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"bsbx-0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"bsbx-0\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"recover nothing, client return nil task via endpoint\",\n\t\t\tfields: fields{\n\t\t\t\tallPods: []*corev1.Pod{{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-0\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t\ttaskNodes: []*taskNode{\n\t\t\t\t\t{\n\t\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\t\tName: \"bsbx-0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\ttaskStatusCollector: func() taskStatusCollector {\n\t\t\t\t\tmock := NewMocktaskStatusCollector(ctl)\n\t\t\t\t\tmock.EXPECT().Collect(gomock.Any(), []string{\"1.2.3.4\"}).Return(map[string]*api.Task{\"1.2.3.4\": nil}).Times(1)\n\t\t\t\t\treturn mock\n\t\t\t\t}(),\n\t\t\t},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"bsbx-0\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"recover successfully, client return running task via endpoint\",\n\t\t\tfields: fields{\n\t\t\t\tallPods: []*corev1.Pod{{\n\t\t\t\t\tObjectMeta: v1.ObjectMeta{\n\t\t\t\t\t\tName: \"test-0\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t\ttaskNodes: []*taskNode{testTaskNode},\n\t\t\t\ttaskNodeByNameIndex: map[string]*taskNode{\n\t\t\t\t\t\"bsbx-0\": testTaskNode,\n\t\t\t\t},\n\t\t\t\ttaskStatusCollector: func() taskStatusCollector {\n\t\t\t\t\tmock := NewMocktaskStatusCollector(ctl)\n\t\t\t\t\tmock.EXPECT().Collect(gomock.Any(), []string{\"1.2.3.4\"}).Return(map[string]*api.Task{\"1.2.3.4\": testTask}).Times(1)\n\t\t\t\t\treturn mock\n\t\t\t\t}(),\n\t\t\t},\n\t\t\texpectTaskNodes: []*taskNode{\n\t\t\t\trecoveredTestTaskNode,\n\t\t\t},\n\t\t},\n\t}\n\tfor i := range tests {\n\t\ttt := &tests[i]\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsch := &defaultTaskScheduler{\n\t\t\t\tfreePods:            tt.fields.freePods,\n\t\t\t\tallPods:             tt.fields.allPods,\n\t\t\t\ttaskNodes:           tt.fields.taskNodes,\n\t\t\t\ttaskNodeByNameIndex: tt.fields.taskNodeByNameIndex,\n\t\t\t\tmaxConcurrency:      tt.fields.maxConcurrency,\n\t\t\t\ttaskStatusCollector: tt.fields.taskStatusCollector,\n\t\t\t\tlogger:              testLogger,\n\t\t\t}\n\t\t\tif err := sch.recoverTaskNodesStatus(); (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"defaultTaskScheduler.recoverTaskNodesStatus() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t\tif tt.expectTaskNodes != nil {\n\t\t\t\tif !reflect.DeepEqual(tt.expectTaskNodes, sch.taskNodes) {\n\t\t\t\t\tt.Errorf(\"recoverTaskNodesStatus, want %+v, got %+v\", tt.expectTaskNodes, sch.taskNodes)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/status_collector.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\t\"github.com/go-logr/logr\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\ntype taskClientCreator func(ip string) taskClient\n\nfunc newTaskStatusCollector(creator taskClientCreator, logger logr.Logger) taskStatusCollector {\n\treturn &defaultTaskStatusCollector{creator: creator, logger: logger}\n}\n\n// TODO error\ntype taskStatusCollector interface {\n\tCollect(ctx context.Context, ipList []string) map[string]*api.Task /*ip<->task*/\n}\n\n// TODO maybe cache\ntype defaultTaskStatusCollector struct {\n\tcreator taskClientCreator\n\tlogger  logr.Logger\n}\n\nfunc (s *defaultTaskStatusCollector) Collect(ctx context.Context, ipList []string) map[string]*api.Task {\n\tsemaphore := make(chan struct{}, len(ipList))\n\tvar wg sync.WaitGroup\n\tvar mu sync.Mutex\n\tret := make(map[string]*api.Task, len(ipList))\n\tfor idx := range ipList {\n\t\tip := ipList[idx]\n\t\tsemaphore <- struct{}{}\n\t\twg.Add(1)\n\t\tgo func(ip string) {\n\t\t\tdefer func() {\n\t\t\t\t<-semaphore\n\t\t\t\twg.Done()\n\t\t\t}()\n\t\t\tctx, cancel := context.WithTimeout(ctx, defaultTimeout)\n\t\t\tdefer cancel()\n\t\t\tclient := s.creator(ip)\n\t\t\ttask, err := client.Get(ctx)\n\t\t\tif err != nil {\n\t\t\t\ts.logger.Error(err, \"failed to GetTask\", \"ip\", ip)\n\t\t\t} else if task != nil {\n\t\t\t\tmu.Lock()\n\t\t\t\tret[ip] = task\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}(ip)\n\t}\n\twg.Wait()\n\ts.logger.Info(\"Collect task status\", \"result\", utils.DumpJSON(ret))\n\treturn ret\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/status_collector_mock.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: internal/task/scheduler/status_collector.go\n\n// Package scheduler is a generated GoMock package.\npackage scheduler\n\nimport (\n\tcontext \"context\"\n\treflect \"reflect\"\n\n\tgomock \"github.com/golang/mock/gomock\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// MocktaskStatusCollector is a mock of taskStatusCollector interface.\ntype MocktaskStatusCollector struct {\n\tctrl     *gomock.Controller\n\trecorder *MocktaskStatusCollectorMockRecorder\n}\n\n// MocktaskStatusCollectorMockRecorder is the mock recorder for MocktaskStatusCollector.\ntype MocktaskStatusCollectorMockRecorder struct {\n\tmock *MocktaskStatusCollector\n}\n\n// NewMocktaskStatusCollector creates a new mock instance.\nfunc NewMocktaskStatusCollector(ctrl *gomock.Controller) *MocktaskStatusCollector {\n\tmock := &MocktaskStatusCollector{ctrl: ctrl}\n\tmock.recorder = &MocktaskStatusCollectorMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MocktaskStatusCollector) EXPECT() *MocktaskStatusCollectorMockRecorder {\n\treturn m.recorder\n}\n\n// Collect mocks base method.\nfunc (m *MocktaskStatusCollector) Collect(ctx context.Context, ipList []string) map[string]*api.Task {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Collect\", ctx, ipList)\n\tret0, _ := ret[0].(map[string]*api.Task)\n\treturn ret0\n}\n\n// Collect indicates an expected call of Collect.\nfunc (mr *MocktaskStatusCollectorMockRecorder) Collect(ctx, ipList interface{}) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Collect\", reflect.TypeOf((*MocktaskStatusCollector)(nil).Collect), ctx, ipList)\n}\n"
  },
  {
    "path": "kubernetes/internal/scheduler/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage scheduler\n\ntype Task interface {\n\tGetName() string\n\tGetState() TaskState\n\tGetPodName() string\n\t// IsResourceReleased task resource is released\n\t// TODO func name is strange\n\tIsResourceReleased() bool\n}\n\ntype TaskState string\n\nconst (\n\tRunningTaskState TaskState = \"RUNNING\"\n\tFailedTaskState  TaskState = \"FAILED\"\n\tSucceedTaskState TaskState = \"SUCCEED\"\n\tUnknownTaskState TaskState = \"UNKNOWN\"\n)\n"
  },
  {
    "path": "kubernetes/internal/task-executor/config/config.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage config\n\nimport (\n\t\"flag\"\n\t\"os\"\n\t\"path\"\n\t\"time\"\n\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n\t\"k8s.io/klog/v2\"\n)\n\ntype Config struct {\n\tDataDir           string\n\tListenAddr        string\n\tCRISocket         string\n\tReadTimeout       time.Duration\n\tWriteTimeout      time.Duration\n\tReconcileInterval time.Duration\n\tEnableSidecarMode bool\n\tMainContainerName string\n\tLogMaxSize        int\n\tLogMaxBackups     int\n\tLogMaxAge         int\n\tLogDir            string\n}\n\nfunc NewConfig() *Config {\n\treturn &Config{\n\t\tDataDir:           \"/var/lib/sandbox/tasks\",\n\t\tListenAddr:        \"0.0.0.0:5758\",\n\t\tCRISocket:         \"/var/run/containerd/containerd.sock\",\n\t\tReadTimeout:       30 * time.Second,\n\t\tWriteTimeout:      30 * time.Second,\n\t\tReconcileInterval: 500 * time.Millisecond,\n\t\tEnableSidecarMode: false,\n\t\tMainContainerName: \"main\",\n\t\tLogMaxSize:        100,\n\t\tLogMaxBackups:     10,\n\t\tLogMaxAge:         7,\n\t\tLogDir:            \"logs\",\n\t}\n}\n\nfunc (c *Config) LoadFromEnv() {\n\tif v := os.Getenv(\"DATA_DIR\"); v != \"\" {\n\t\tc.DataDir = v\n\t}\n\tif v := os.Getenv(\"LISTEN_ADDR\"); v != \"\" {\n\t\tc.ListenAddr = v\n\t}\n\tif v := os.Getenv(\"CRI_SOCKET\"); v != \"\" {\n\t\tc.CRISocket = v\n\t}\n\tif v := os.Getenv(\"ENABLE_SIDECAR_MODE\"); v == \"true\" {\n\t\tc.EnableSidecarMode = true\n\t}\n\tif v := os.Getenv(\"MAIN_CONTAINER_NAME\"); v != \"\" {\n\t\tc.MainContainerName = v\n\t}\n}\n\nfunc (c *Config) LoadFromFlags() {\n\tflag.StringVar(&c.DataDir, \"data-dir\", c.DataDir, \"data storage directory\")\n\tflag.StringVar(&c.ListenAddr, \"listen-addr\", c.ListenAddr, \"service listen address\")\n\tflag.StringVar(&c.CRISocket, \"cri-socket\", c.CRISocket, \"CRI socket path for container runner mode\")\n\tflag.BoolVar(&c.EnableSidecarMode, \"enable-sidecar-mode\", c.EnableSidecarMode, \"enable sidecar runner mode\")\n\tflag.StringVar(&c.MainContainerName, \"main-container-name\", c.MainContainerName, \"main container name\")\n\t// set log flags\n\tflag.IntVar(&c.LogMaxSize, \"log-max-size\", c.LogMaxSize, \"maximum log file size in MB\")\n\tflag.IntVar(&c.LogMaxBackups, \"log-max-backups\", c.LogMaxBackups, \"maximum number of log backup files\")\n\tflag.IntVar(&c.LogMaxAge, \"log-max-age\", c.LogMaxAge, \"maximum number of days to keep log files\")\n\tflag.StringVar(&c.LogDir, \"log-dir\", c.LogDir, \"log file directory\")\n\tflag.Parse()\n}\n\nfunc (c *Config) InitKlog() error {\n\tlogFile := path.Join(c.LogDir, \"task-executor.log\")\n\tfs := flag.NewFlagSet(\"klog\", flag.ContinueOnError)\n\tklog.InitFlags(fs)\n\tfs.Set(\"logtostderr\", \"false\")\n\tfs.Set(\"alsologtostderr\", \"false\")\n\tfs.Set(\"stderrthreshold\", \"FATAL\")\n\tfs.Set(\"one_output\", \"true\")\n\tklog.SetOutput(&lumberjack.Logger{\n\t\tFilename:   logFile,\n\t\tMaxSize:    c.LogMaxSize,\n\t\tMaxBackups: c.LogMaxBackups,\n\t\tMaxAge:     c.LogMaxAge,\n\t\tCompress:   true,\n\t})\n\treturn nil\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/manager/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage manager\n\nimport (\n\t\"context\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\n// TaskManager defines the contract for managing tasks in memory.\ntype TaskManager interface {\n\tCreate(ctx context.Context, task *types.Task) (*types.Task, error)\n\t// Sync synchronizes the current task list with the desired state.\n\t// It deletes tasks not in the desired list and creates new ones.\n\t// Returns the current task list after synchronization.\n\tSync(ctx context.Context, desired []*types.Task) ([]*types.Task, error)\n\n\tGet(ctx context.Context, id string) (*types.Task, error)\n\n\tList(ctx context.Context) ([]*types.Task, error)\n\n\tDelete(ctx context.Context, id string) error\n\n\tStart(ctx context.Context)\n\n\tStop()\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/manager/task_manager.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage manager\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"sync\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/runtime\"\n\tstore \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/storage\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\nconst (\n\tmaxConcurrentTasks = 1\n)\n\ntype taskManager struct {\n\tmu    sync.RWMutex\n\ttasks map[string]*types.Task // name -> task\n\n\tstore    store.TaskStore\n\texecutor runtime.Executor\n\tconfig   *config.Config\n\n\tstopping map[string]bool\n\n\tstopCh chan struct{}\n\tdoneCh chan struct{}\n}\n\n// NewTaskManager creates a new task manager instance.\nfunc NewTaskManager(cfg *config.Config, taskStore store.TaskStore, exec runtime.Executor) (TaskManager, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"config cannot be nil\")\n\t}\n\tif taskStore == nil {\n\t\treturn nil, fmt.Errorf(\"task store cannot be nil\")\n\t}\n\tif exec == nil {\n\t\treturn nil, fmt.Errorf(\"executor cannot be nil\")\n\t}\n\n\treturn &taskManager{\n\t\ttasks:    make(map[string]*types.Task),\n\t\tstore:    taskStore,\n\t\texecutor: exec,\n\t\tconfig:   cfg,\n\t\tstopping: make(map[string]bool),\n\t\tstopCh:   make(chan struct{}),\n\t\tdoneCh:   make(chan struct{}),\n\t}, nil\n}\n\n// isTaskActive checks if the task is counting towards the concurrency limit\nfunc (m *taskManager) isTaskActive(task *types.Task) bool {\n\tif task == nil {\n\t\treturn false\n\t}\n\tif task.DeletionTimestamp != nil {\n\t\treturn false\n\t}\n\tstate := task.Status.State\n\treturn state == types.TaskStatePending || state == types.TaskStateRunning\n}\n\n// countActiveTasks counts tasks that are active\nfunc (m *taskManager) countActiveTasks() int {\n\tcount := 0\n\tfor _, task := range m.tasks {\n\t\tif m.isTaskActive(task) {\n\t\t\tcount++\n\t\t}\n\t}\n\treturn count\n}\n\nfunc (m *taskManager) Create(ctx context.Context, task *types.Task) (*types.Task, error) {\n\tif task == nil {\n\t\treturn nil, fmt.Errorf(\"task cannot be nil\")\n\t}\n\tif task.Name == \"\" {\n\t\treturn nil, fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif _, exists := m.tasks[task.Name]; exists {\n\t\treturn nil, fmt.Errorf(\"task %s already exists\", task.Name)\n\t}\n\n\tif m.countActiveTasks() >= maxConcurrentTasks {\n\t\treturn nil, fmt.Errorf(\"maximum concurrent tasks (%d) reached, cannot create new task\", maxConcurrentTasks)\n\t}\n\n\tif err := m.store.Create(ctx, task); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to persist task: %w\", err)\n\t}\n\n\tif err := m.executor.Start(ctx, task); err != nil {\n\t\tif delErr := m.store.Delete(ctx, task.Name); delErr != nil {\n\t\t\tklog.ErrorS(delErr, \"failed to rollback task creation\", \"name\", task.Name)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"failed to start task: %w\", err)\n\t}\n\n\tif status, err := m.executor.Inspect(ctx, task); err == nil {\n\t\ttask.Status = *status\n\t\t// Persist the PID and initial status\n\t\tif err := m.store.Update(ctx, task); err != nil {\n\t\t\tklog.ErrorS(err, \"failed to persist initial task status\", \"name\", task.Name)\n\t\t}\n\t} else {\n\t\tklog.ErrorS(err, \"failed to inspect task after start\", \"name\", task.Name)\n\t}\n\n\tif task.Status.State == \"\" {\n\t\ttask.Status.State = types.TaskStatePending\n\t}\n\n\tm.tasks[task.Name] = task\n\n\tklog.InfoS(\"task created successfully\", \"name\", task.Name)\n\treturn task, nil\n}\n\n// Sync synchronizes the current task list with the desired state\nfunc (m *taskManager) Sync(ctx context.Context, desired []*types.Task) ([]*types.Task, error) {\n\tif desired == nil {\n\t\treturn nil, fmt.Errorf(\"desired task list cannot be nil\")\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tdesiredMap := make(map[string]*types.Task)\n\tfor _, task := range desired {\n\t\tif task != nil && task.Name != \"\" {\n\t\t\tdesiredMap[task.Name] = task\n\t\t}\n\t}\n\n\tvar syncErrors []error\n\n\tfor name, task := range m.tasks {\n\t\tif _, ok := desiredMap[name]; !ok {\n\t\t\tif err := m.softDeleteLocked(ctx, task); err != nil {\n\t\t\t\tklog.ErrorS(err, \"failed to delete task during sync\", \"name\", name)\n\t\t\t\tsyncErrors = append(syncErrors, fmt.Errorf(\"failed to delete task %s: %w\", name, err))\n\t\t\t}\n\t\t}\n\t}\n\n\tfor name, task := range desiredMap {\n\t\tif _, exists := m.tasks[name]; !exists {\n\t\t\tif err := m.createTaskLocked(ctx, task); err != nil {\n\t\t\t\tklog.ErrorS(err, \"failed to create task during sync\", \"name\", name)\n\t\t\t\tsyncErrors = append(syncErrors, fmt.Errorf(\"failed to create task %s: %w\", name, err))\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(syncErrors) > 0 {\n\t\treturn m.listTasksLocked(), errors.Join(syncErrors...)\n\t}\n\treturn m.listTasksLocked(), nil\n}\n\nfunc (m *taskManager) Get(ctx context.Context, name string) (*types.Task, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\ttask, exists := m.tasks[name]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"task %s not found\", name)\n\t}\n\n\treturn task, nil\n}\n\nfunc (m *taskManager) List(ctx context.Context) ([]*types.Task, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\treturn m.listTasksLocked(), nil\n}\n\n// Delete removes a task by marking it for deletion\nfunc (m *taskManager) Delete(ctx context.Context, name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\ttask, exists := m.tasks[name]\n\tif !exists {\n\t\treturn nil\n\t}\n\n\treturn m.softDeleteLocked(ctx, task)\n}\n\n// softDeleteLocked marks a task for deletion\nfunc (m *taskManager) softDeleteLocked(ctx context.Context, task *types.Task) error {\n\tif task.DeletionTimestamp != nil {\n\t\treturn nil\n\t}\n\n\tnow := time.Now()\n\ttask.DeletionTimestamp = &now\n\n\tif err := m.store.Update(ctx, task); err != nil {\n\t\treturn fmt.Errorf(\"failed to mark task for deletion: %w\", err)\n\t}\n\n\tklog.InfoS(\"task marked for deletion\", \"name\", task.Name)\n\treturn nil\n}\n\n// Start initializes the manager, loads tasks from store, and starts the reconcile loop\nfunc (m *taskManager) Start(ctx context.Context) {\n\tklog.InfoS(\"starting task manager\")\n\n\tif err := m.recoverTasks(ctx); err != nil {\n\t\tklog.ErrorS(err, \"failed to recover tasks from store\")\n\t}\n\n\tgo m.reconcileLoop(ctx)\n\n\tklog.InfoS(\"task manager started\")\n}\n\nfunc (m *taskManager) Stop() {\n\tklog.InfoS(\"stopping task manager\")\n\tclose(m.stopCh)\n\t<-m.doneCh\n\tklog.InfoS(\"task manager stopped\")\n}\n\n// createTaskLocked creates a task without acquiring the lock\nfunc (m *taskManager) createTaskLocked(ctx context.Context, task *types.Task) error {\n\tif task == nil || task.Name == \"\" {\n\t\treturn fmt.Errorf(\"invalid task\")\n\t}\n\n\tif _, exists := m.tasks[task.Name]; exists {\n\t\treturn fmt.Errorf(\"task %s already exists\", task.Name)\n\t}\n\n\tif m.countActiveTasks() >= maxConcurrentTasks {\n\t\treturn fmt.Errorf(\"maximum concurrent tasks (%d) reached, cannot create new task\", maxConcurrentTasks)\n\t}\n\n\tif err := m.store.Create(ctx, task); err != nil {\n\t\treturn fmt.Errorf(\"failed to persist task: %w\", err)\n\t}\n\n\tif err := m.executor.Start(ctx, task); err != nil {\n\t\tm.store.Delete(ctx, task.Name)\n\t\treturn fmt.Errorf(\"failed to start task: %w\", err)\n\t}\n\n\tif status, err := m.executor.Inspect(ctx, task); err == nil {\n\t\ttask.Status = *status\n\t\t// Persist the PID and initial status\n\t\tif err := m.store.Update(ctx, task); err != nil {\n\t\t\tklog.ErrorS(err, \"failed to persist initial task status\", \"name\", task.Name)\n\t\t}\n\t} else {\n\t\tklog.ErrorS(err, \"failed to inspect task after start\", \"name\", task.Name)\n\t}\n\n\tm.tasks[task.Name] = task\n\treturn nil\n}\n\n// listTasksLocked returns all tasks without acquiring the lock\nfunc (m *taskManager) listTasksLocked() []*types.Task {\n\ttasks := make([]*types.Task, 0, len(m.tasks))\n\tfor _, task := range m.tasks {\n\t\tif task != nil {\n\t\t\ttasks = append(tasks, task)\n\t\t}\n\t}\n\treturn tasks\n}\n\nfunc (m *taskManager) recoverTasks(ctx context.Context) error {\n\tklog.InfoS(\"recovering tasks from store\")\n\n\ttasks, err := m.store.List(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list tasks from store: %w\", err)\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tfor _, task := range tasks {\n\t\tif task == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tstatus, err := m.executor.Inspect(ctx, task)\n\t\tif err != nil {\n\t\t\tklog.ErrorS(err, \"failed to inspect task during recovery\", \"name\", task.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\ttask.Status = *status\n\n\t\tm.tasks[task.Name] = task\n\n\t\tklog.InfoS(\"recovered task\", \"name\", task.Name, \"state\", task.Status.State, \"deleting\", task.DeletionTimestamp != nil)\n\t}\n\n\tklog.InfoS(\"task recovery completed\", \"count\", len(m.tasks))\n\treturn nil\n}\n\nfunc (m *taskManager) reconcileLoop(ctx context.Context) {\n\tticker := time.NewTicker(m.config.ReconcileInterval)\n\tdefer ticker.Stop()\n\tdefer close(m.doneCh)\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tm.reconcileTasks(ctx)\n\t\tcase <-m.stopCh:\n\t\t\tklog.InfoS(\"reconcile loop stopped\")\n\t\t\treturn\n\t\tcase <-ctx.Done():\n\t\t\tklog.InfoS(\"reconcile loop context canceled\")\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc (m *taskManager) reconcileTasks(ctx context.Context) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tvar tasksToDelete []string\n\n\tfor name, task := range m.tasks {\n\t\tif task == nil {\n\t\t\tcontinue\n\t\t}\n\t\tstatus, err := m.executor.Inspect(ctx, task)\n\t\tif err != nil {\n\t\t\tklog.ErrorS(err, \"failed to inspect task\", \"name\", name)\n\t\t\tcontinue\n\t\t}\n\t\tstate := status.State\n\n\t\tshouldStop := false\n\t\tstopReason := \"\"\n\n\t\tif task.DeletionTimestamp != nil && !m.stopping[name] {\n\t\t\tif !isTerminalState(state) {\n\t\t\t\tshouldStop = true\n\t\t\t\tstopReason = \"deletion requested\"\n\t\t\t}\n\t\t} else if state == types.TaskStateTimeout && !m.stopping[name] {\n\t\t\tshouldStop = true\n\t\t\tstopReason = \"timeout exceeded\"\n\t\t}\n\n\t\tif shouldStop {\n\t\t\tklog.InfoS(\"stopping task\", \"name\", name, \"reason\", stopReason, \"current_state\", state)\n\t\t\tm.stopping[name] = true\n\n\t\t\tgo func(t *types.Task, taskName string) {\n\t\t\t\tdefer func() {\n\t\t\t\t\tm.mu.Lock()\n\t\t\t\t\tdelete(m.stopping, taskName)\n\t\t\t\t\tm.mu.Unlock()\n\t\t\t\t}()\n\n\t\t\t\tklog.V(1).InfoS(\"task stop initiated\", \"name\", taskName, \"reason\", stopReason)\n\t\t\t\tif err := m.executor.Stop(ctx, t); err != nil {\n\t\t\t\t\tklog.ErrorS(err, \"failed to stop task\", \"name\", taskName)\n\t\t\t\t}\n\t\t\t\tklog.InfoS(\"task stopped\", \"name\", taskName)\n\t\t\t}(task, name)\n\t\t}\n\n\t\tif task.DeletionTimestamp != nil && isTerminalState(state) {\n\t\t\tklog.InfoS(\"task terminated, finalizing deletion\", \"name\", name)\n\t\t\ttasksToDelete = append(tasksToDelete, name)\n\t\t}\n\n\t\tif !m.stopping[name] {\n\t\t\tif !reflect.DeepEqual(task.Status, *status) {\n\t\t\t\toldState := task.Status.State\n\t\t\t\ttask.Status = *status\n\t\t\t\t// Log state changes only\n\t\t\t\tif oldState != status.State {\n\t\t\t\t\tklog.InfoS(\"task state changed\", \"name\", name, \"oldState\", oldState, \"newState\", status.State)\n\t\t\t\t}\n\t\t\t\tif err := m.store.Update(ctx, task); err != nil {\n\t\t\t\t\tklog.ErrorS(err, \"failed to update task status in store\", \"name\", name)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, name := range tasksToDelete {\n\t\tif _, exists := m.tasks[name]; !exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := m.store.Delete(ctx, name); err != nil {\n\t\t\tklog.ErrorS(err, \"failed to delete task from store\", \"name\", name)\n\t\t\tcontinue\n\t\t}\n\n\t\tdelete(m.tasks, name)\n\t\tdelete(m.stopping, name)\n\t\tklog.InfoS(\"task deleted successfully\", \"name\", name)\n\t}\n}\n\n// isTerminalState returns true if the task will not transition to another state\nfunc isTerminalState(state types.TaskState) bool {\n\treturn state == types.TaskStateSucceeded ||\n\t\tstate == types.TaskStateFailed ||\n\t\tstate == types.TaskStateNotFound\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/manager/task_manager_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage manager\n\nimport (\n\t\"context\"\n\t\"os/exec\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/runtime\"\n\tstore \"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/storage\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc setupTestManager(t *testing.T) (TaskManager, *config.Config) {\n\tcfg := &config.Config{\n\t\tDataDir:           t.TempDir(),\n\t\tEnableSidecarMode: false,\n\t\tReconcileInterval: 100 * time.Millisecond,\n\t}\n\n\ttaskStore, err := store.NewFileStore(cfg.DataDir)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create store: %v\", err)\n\t}\n\n\texec, err := runtime.NewProcessExecutor(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create executor: %v\", err)\n\t}\n\n\tmgr, err := NewTaskManager(cfg, taskStore, exec)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create manager: %v\", err)\n\t}\n\n\treturn mgr, cfg\n}\n\nfunc cleanupTask(t *testing.T, mgr TaskManager, name string) {\n\tctx := context.Background()\n\tmgr.Delete(ctx, name)\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\t_, err := mgr.Get(ctx, name)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tt.Logf(\"Task %s not deleted within timeout during cleanup\", name)\n}\n\nfunc TestNewTaskManager(t *testing.T) {\n\tcfg := &config.Config{\n\t\tDataDir: t.TempDir(),\n\t}\n\ttaskStore, _ := store.NewFileStore(cfg.DataDir)\n\texec, _ := runtime.NewProcessExecutor(cfg)\n\n\ttests := []struct {\n\t\tname     string\n\t\tcfg      *config.Config\n\t\tstore    store.TaskStore\n\t\texecutor runtime.Executor\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"nil config\",\n\t\t\tcfg:      nil,\n\t\t\tstore:    taskStore,\n\t\t\texecutor: exec,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"nil store\",\n\t\t\tcfg:      cfg,\n\t\t\tstore:    nil,\n\t\t\texecutor: exec,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"nil executor\",\n\t\t\tcfg:      cfg,\n\t\t\tstore:    taskStore,\n\t\t\texecutor: nil,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid parameters\",\n\t\t\tcfg:      cfg,\n\t\t\tstore:    taskStore,\n\t\t\texecutor: exec,\n\t\t\twantErr:  false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmgr, err := NewTaskManager(tt.cfg, tt.store, tt.executor)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"NewTaskManager() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !tt.wantErr && mgr == nil {\n\t\t\t\tt.Error(\"NewTaskManager() returned nil manager\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTaskManager_Create(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\ttests := []struct {\n\t\tname    string\n\t\ttask    *types.Task\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"nil task\",\n\t\t\ttask:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"empty task name\",\n\t\t\ttask: &types.Task{\n\t\t\t\tName: \"\",\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"echo\", \"test\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"valid task\",\n\t\t\ttask: &types.Task{\n\t\t\t\tName: \"test-task\",\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"sh\", \"-c\", \"echo hello && exit 0\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcreated, err := mgr.Create(ctx, tt.task)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"Create() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif !tt.wantErr {\n\t\t\t\tif created == nil {\n\t\t\t\t\tt.Error(\"Create() returned nil task\")\n\t\t\t\t}\n\t\t\t\tif created != nil && created.Name != tt.task.Name {\n\t\t\t\t\tt.Errorf(\"Create() task name = %v, want %v\", created.Name, tt.task.Name)\n\t\t\t\t}\n\n\t\t\t\t// Wait for task to complete naturally\n\t\t\t\ttime.Sleep(200 * time.Millisecond)\n\t\t\t\t// Then clean up\n\t\t\t\tif tt.task != nil {\n\t\t\t\t\tmgr.Delete(ctx, tt.task.Name)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTaskManager_CreateDuplicate(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\ttask := &types.Task{\n\t\tName: \"duplicate-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"test\"},\n\t\t},\n\t}\n\n\t// First create should succeed\n\t_, err := mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"First Create() failed: %v\", err)\n\t}\n\tdefer cleanupTask(t, mgr, task.Name)\n\n\t// Second create should fail\n\t_, err = mgr.Create(ctx, task)\n\tif err == nil {\n\t\tt.Error(\"Create() should fail for duplicate task\")\n\t}\n}\n\nfunc TestTaskManager_CreateMaxConcurrentTasks(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\ttask1 := &types.Task{\n\t\tName: \"task-1\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"sleep\", \"10\"},\n\t\t},\n\t}\n\n\t// Create first task\n\t_, err := mgr.Create(ctx, task1)\n\tif err != nil {\n\t\tt.Fatalf(\"First Create() failed: %v\", err)\n\t}\n\tdefer cleanupTask(t, mgr, task1.Name)\n\n\t// Try to create second task - should fail due to max concurrent limit\n\ttask2 := &types.Task{\n\t\tName: \"task-2\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"test\"},\n\t\t},\n\t}\n\n\t_, err = mgr.Create(ctx, task2)\n\tif err == nil {\n\t\tt.Error(\"Create() should fail when max concurrent tasks reached\")\n\t\tcleanupTask(t, mgr, task2.Name)\n\t}\n}\n\nfunc TestTaskManager_Get(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\ttask := &types.Task{\n\t\tName: \"get-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"get\"},\n\t\t},\n\t}\n\n\t// Create task\n\t_, err := mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer cleanupTask(t, mgr, task.Name)\n\n\t// Get task\n\tgot, err := mgr.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get() failed: %v\", err)\n\t}\n\n\tif got.Name != task.Name {\n\t\tt.Errorf(\"Get() name = %v, want %v\", got.Name, task.Name)\n\t}\n}\n\nfunc TestTaskManager_GetNotFound(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\t_, err := mgr.Get(ctx, \"non-existent\")\n\tif err == nil {\n\t\tt.Error(\"Get() should fail for non-existent task\")\n\t}\n}\n\nfunc TestTaskManager_GetEmptyName(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\t_, err := mgr.Get(ctx, \"\")\n\tif err == nil {\n\t\tt.Error(\"Get() should fail for empty name\")\n\t}\n}\n\nfunc TestTaskManager_List(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\t// Initially empty\n\ttasks, err := mgr.List(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"List() failed: %v\", err)\n\t}\n\tif len(tasks) != 0 {\n\t\tt.Errorf(\"List() initial count = %d, want 0\", len(tasks))\n\t}\n\n\t// Create a task\n\ttask := &types.Task{\n\t\tName: \"list-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"list\"},\n\t\t},\n\t}\n\n\t_, err = mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer mgr.Delete(ctx, task.Name)\n\n\t// List should return 1 task\n\ttasks, err = mgr.List(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"List() failed: %v\", err)\n\t}\n\tif len(tasks) != 1 {\n\t\tt.Errorf(\"List() count = %d, want 1\", len(tasks))\n\t}\n\tif tasks[0].Name != task.Name {\n\t\tt.Errorf(\"List() task name = %v, want %v\", tasks[0].Name, task.Name)\n\t}\n}\n\nfunc TestTaskManager_Delete(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\t// Start the manager to enable the reconcile loop\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\ttask := &types.Task{\n\t\tName: \"delete-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"delete\"},\n\t\t},\n\t}\n\n\t// Create task\n\t_, err := mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\n\t// Delete task (soft delete)\n\terr = mgr.Delete(ctx, task.Name)\n\tif err != nil {\n\t\tt.Errorf(\"Delete() failed: %v\", err)\n\t}\n\n\t// Verify task is marked for deletion but still exists\n\tgot, err := mgr.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get() should succeed after Delete() (soft delete): %v\", err)\n\t}\n\tif got.DeletionTimestamp == nil {\n\t\tt.Error(\"DeletionTimestamp should be set after Delete()\")\n\t}\n\n\t// Wait for task to be finalized\n\ttimeout := 5 * time.Second\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\t_, err := mgr.Get(ctx, task.Name)\n\t\tif err != nil {\n\t\t\t// Task is gone, success\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tt.Error(\"Task was not finalized (deleted) within timeout\")\n}\n\nfunc TestTaskManager_DeleteNonExistent(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\t// Delete non-existent task should not error\n\terr := mgr.Delete(ctx, \"non-existent\")\n\tif err != nil {\n\t\tt.Errorf(\"Delete() should not fail for non-existent task: %v\", err)\n\t}\n}\n\nfunc TestTaskManager_Sync(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\t// Start the manager to enable the reconcile loop\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\t// Create initial task\n\ttask1 := &types.Task{\n\t\tName: \"sync-task-1\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"1\"},\n\t\t},\n\t}\n\n\t_, err := mgr.Create(ctx, task1)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\n\t// Sync with new desired state (task1 removed, task2 added)\n\ttask2 := &types.Task{\n\t\tName: \"sync-task-2\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"2\"},\n\t\t},\n\t}\n\n\t// Sync triggers soft delete for task1 and creation of task2\n\tcurrent, err := mgr.Sync(ctx, []*types.Task{task2})\n\tif err != nil {\n\t\tt.Fatalf(\"Sync() failed: %v\", err)\n\t}\n\tdefer mgr.Delete(ctx, task2.Name)\n\n\t// Verify task1 is marked for deletion in the returned list\n\tvar task1Found bool\n\tfor _, t1 := range current {\n\t\tif t1.Name == task1.Name {\n\t\t\ttask1Found = true\n\t\t\tif t1.DeletionTimestamp == nil {\n\t\t\t\tt.Error(\"task1 should be marked for deletion after Sync()\")\n\t\t\t}\n\t\t}\n\t}\n\tif !task1Found {\n\t\t// It's possible it was deleted super fast, but unlikely\n\t\tt.Log(\"task1 not found in Sync result (maybe already deleted?)\")\n\t}\n\n\t// Verify task2 is created\n\tvar task2Found bool\n\tfor _, t2 := range current {\n\t\tif t2.Name == task2.Name {\n\t\t\ttask2Found = true\n\t\t}\n\t}\n\tif !task2Found {\n\t\tt.Error(\"task2 should be present after Sync()\")\n\t}\n\n\t// Wait for task1 to be finalized\n\ttimeout := 5 * time.Second\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\t_, err := mgr.Get(ctx, task1.Name)\n\t\tif err != nil {\n\t\t\t// Task is gone, success\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tt.Error(\"task1 should be deleted after Sync()\")\n}\n\nfunc TestTaskManager_SyncNil(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tctx := context.Background()\n\n\t_, err := mgr.Sync(ctx, nil)\n\tif err == nil {\n\t\tt.Error(\"Sync() should fail for nil desired list\")\n\t}\n}\n\nfunc TestTaskManager_AsyncStopOnDelete(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\ttimeoutSec := int64(30)\n\ttask := &types.Task{\n\t\tName: \"long-running-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand:        []string{\"sleep\", \"30\"},\n\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t},\n\t}\n\n\t// Create task\n\tcreated, err := mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer cleanupTask(t, mgr, task.Name)\n\n\t// Verify task is running\n\tassert.Equal(t, types.TaskStateRunning, created.Status.State)\n\n\t// Record the time before delete\n\tbeforeDelete := time.Now()\n\n\t// Delete task (should trigger async stop)\n\terr = mgr.Delete(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Delete() failed: %v\", err)\n\t}\n\n\t// Verify DeletionTimestamp is set immediately (soft delete)\n\tgot, err := mgr.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get() after Delete failed: %v\", err)\n\t}\n\tif got.DeletionTimestamp == nil {\n\t\tt.Error(\"DeletionTimestamp should be set immediately after Delete()\")\n\t}\n\n\t// Verify Delete returned quickly (not blocked by Stop)\n\tdeleteDuration := time.Since(beforeDelete)\n\tif deleteDuration > 500*time.Millisecond {\n\t\tt.Errorf(\"Delete() took too long (%v), should be fast (async stop)\", deleteDuration)\n\t}\n\n\t// Wait for task to be finalized\n\tdeadline := time.Now().Add(15 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\t_, err := mgr.Get(ctx, task.Name)\n\t\tif err != nil {\n\t\t\t// Task is gone, success\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\tt.Error(\"Task was not finalized within timeout after async stop\")\n}\n\nfunc TestTaskManager_TimeoutHandling(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found, skipping timeout test\")\n\t}\n\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\n\tctx := context.Background()\n\n\t// Create task with short timeout\n\ttimeoutSec := int64(2)\n\ttask := &types.Task{\n\t\tName: \"timeout-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand:        []string{\"sleep\", \"30\"},\n\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t},\n\t}\n\n\t_, err := mgr.Create(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer cleanupTask(t, mgr, task.Name)\n\n\t// Wait for timeout to be detected and async stop triggered\n\ttime.Sleep(3 * time.Second)\n\n\t// Check task status - should be Timeout or Failed (after stop)\n\tgot, err := mgr.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get() failed: %v\", err)\n\t}\n\n\t// State should be Timeout (during stop) or Failed (after stop completes)\n\tif got.Status.State != types.TaskStateTimeout && got.Status.State != types.TaskStateFailed {\n\t\tt.Errorf(\"Expected Timeout or Failed state, got: %s\", got.Status.State)\n\t}\n\n\t// If in Timeout state, verify reason\n\tif got.Status.State == types.TaskStateTimeout {\n\t\tassert.NotEmpty(t, got.Status.SubStatuses)\n\t\tassert.Equal(t, \"TaskTimeout\", got.Status.SubStatuses[0].Reason)\n\t}\n\n\t// Wait for final state\n\tdeadline := time.Now().Add(15 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\tgot, err := mgr.Get(ctx, task.Name)\n\t\tif err != nil {\n\t\t\t// Task was deleted, that's also acceptable\n\t\t\treturn\n\t\t}\n\t\tif got.Status.State == types.TaskStateFailed {\n\t\t\t// Stop completed\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(200 * time.Millisecond)\n\t}\n}\n\nfunc TestTaskManager_CountActiveTasks(t *testing.T) {\n\tmgr, _ := setupTestManager(t)\n\tmgr.Start(context.Background())\n\tdefer mgr.Stop()\n\tctx := context.Background()\n\n\t// Initially empty\n\tactiveCount := mgr.(*taskManager).countActiveTasks()\n\tif activeCount != 0 {\n\t\tt.Errorf(\"Initial active count = %d, want 0\", activeCount)\n\t}\n\n\t// Create a short-lived task that will complete quickly\n\ttask1 := &types.Task{\n\t\tName: \"quick-task-1\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"done\"},\n\t\t},\n\t}\n\t_, err := mgr.Create(ctx, task1)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer mgr.Delete(ctx, task1.Name)\n\n\t// Wait for task1 to complete\n\ttime.Sleep(500 * time.Millisecond)\n\n\t// Should have 0 active tasks after task1 completes\n\tactiveCount = mgr.(*taskManager).countActiveTasks()\n\tif activeCount != 0 {\n\t\tt.Errorf(\"Active count after task1 completion = %d, want 0\", activeCount)\n\t}\n\n\t// Create a running task\n\ttask2 := &types.Task{\n\t\tName: \"active-task-2\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"sleep\", \"5\"},\n\t\t},\n\t}\n\t_, err = mgr.Create(ctx, task2)\n\tif err != nil {\n\t\tt.Fatalf(\"Create() failed: %v\", err)\n\t}\n\tdefer mgr.Delete(ctx, task2.Name)\n\n\t// Should have 1 active task\n\tactiveCount = mgr.(*taskManager).countActiveTasks()\n\tif activeCount != 1 {\n\t\tt.Errorf(\"Active count after create = %d, want 1\", activeCount)\n\t}\n}\n\nfunc TestIsTerminalState(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tstate    types.TaskState\n\t\texpected bool\n\t}{\n\t\t{\"Succeeded is terminal\", types.TaskStateSucceeded, true},\n\t\t{\"Failed is terminal\", types.TaskStateFailed, true},\n\t\t{\"NotFound is terminal\", types.TaskStateNotFound, true},\n\t\t{\"Pending is not terminal\", types.TaskStatePending, false},\n\t\t{\"Running is not terminal\", types.TaskStateRunning, false},\n\t\t{\"Unknown is not terminal\", types.TaskStateUnknown, false},\n\t\t{\"Timeout is not terminal\", types.TaskStateTimeout, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := isTerminalState(tt.state)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"isTerminalState(%v) = %v, want %v\", tt.state, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/runtime/composite.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\nfunc NewExecutor(cfg *config.Config) (Executor, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"config cannot be nil\")\n\t}\n\n\tprocExec, err := NewProcessExecutor(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create process executor: %w\", err)\n\t}\n\tklog.InfoS(\"process executor initialized\", \"enableSidecar\", cfg.EnableSidecarMode, \"mainContainer\", cfg.MainContainerName)\n\n\tcontainerExec, err := newContainerExecutor(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create container executor: %w\", err)\n\t}\n\n\treturn &compositeExecutor{\n\t\tprocessExec:   procExec,\n\t\tcontainerExec: containerExec,\n\t}, nil\n}\n\n// compositeExecutor dispatches tasks to the appropriate underlying executor\ntype compositeExecutor struct {\n\tprocessExec   Executor\n\tcontainerExec Executor\n}\n\nfunc (e *compositeExecutor) getDelegate(task *types.Task) (Executor, error) {\n\tif task == nil {\n\t\treturn nil, fmt.Errorf(\"task cannot be nil\")\n\t}\n\texecutor := e.processExec\n\tif task.Process == nil {\n\t\texecutor = e.containerExec\n\t}\n\tif executor == nil {\n\t\treturn nil, fmt.Errorf(\"no executor available for task: %s\", task.Name)\n\t}\n\treturn executor, nil\n}\n\nfunc (e *compositeExecutor) Start(ctx context.Context, task *types.Task) error {\n\tdelegate, err := e.getDelegate(task)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn delegate.Start(ctx, task)\n}\n\nfunc (e *compositeExecutor) Inspect(ctx context.Context, task *types.Task) (*types.Status, error) {\n\tdelegate, err := e.getDelegate(task)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn delegate.Inspect(ctx, task)\n}\n\nfunc (e *compositeExecutor) Stop(ctx context.Context, task *types.Task) error {\n\tdelegate, err := e.getDelegate(task)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn delegate.Stop(ctx, task)\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/runtime/container.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\ntype containerExecutor struct {\n\tconfig *config.Config\n}\n\n// newContainerExecutor creates a new container-based task executor.\n// This is a placeholder implementation - container mode is not yet supported.\nfunc newContainerExecutor(cfg *config.Config) (Executor, error) {\n\tif cfg == nil {\n\t\treturn nil, fmt.Errorf(\"config cannot be nil\")\n\t}\n\n\treturn &containerExecutor{\n\t\tconfig: cfg,\n\t}, nil\n}\n\n// Start is not implemented for container mode yet.\nfunc (e *containerExecutor) Start(ctx context.Context, task *types.Task) error {\n\treturn errors.New(\"container mode is not implemented yet - use process mode instead\")\n}\n\n// Inspect is not implemented for container mode yet.\nfunc (e *containerExecutor) Inspect(ctx context.Context, task *types.Task) (*types.Status, error) {\n\treturn nil, errors.New(\"container mode is not implemented yet - use process mode instead\")\n}\n\n// Stop is not implemented for container mode yet.\nfunc (e *containerExecutor) Stop(ctx context.Context, task *types.Task) error {\n\treturn errors.New(\"container mode is not implemented yet - use process mode instead\")\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/runtime/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\n// Executor defines the contract for running tasks across different modes.\ntype Executor interface {\n\tStart(ctx context.Context, task *types.Task) error\n\t// Inspect retrieves the current runtime state.\n\tInspect(ctx context.Context, task *types.Task) (*types.Status, error)\n\n\tStop(ctx context.Context, task *types.Task) error\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/runtime/process.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/utils\"\n)\n\nconst (\n\tExitFile   = \"exit\"\n\tPidFile    = \"pid\"\n\tStdoutFile = \"stdout.log\"\n\tStderrFile = \"stderr.log\"\n)\n\n// processExecutor handles both Host and Sidecar modes as they share the same\n// shim-based process execution model.\ntype processExecutor struct {\n\tconfig  *config.Config\n\trootDir string\n}\n\nfunc NewProcessExecutor(config *config.Config) (Executor, error) {\n\treturn &processExecutor{rootDir: config.DataDir, config: config}, nil\n}\n\nfunc (e *processExecutor) Start(ctx context.Context, task *types.Task) error {\n\tif task == nil {\n\t\treturn fmt.Errorf(\"task cannot be nil\")\n\t}\n\ttaskDir, err := utils.SafeJoin(e.rootDir, task.Name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\tpidPath := filepath.Join(taskDir, PidFile)\n\texitPath := filepath.Join(taskDir, ExitFile)\n\n\tvar cmdList []string\n\tif task.Process != nil {\n\t\tcmdList = append(task.Process.Command, task.Process.Args...)\n\t} else {\n\t\treturn fmt.Errorf(\"process spec is required for process executor but task.Process is nil (task name: %s)\", task.Name)\n\t}\n\n\tif len(cmdList) == 0 {\n\t\treturn fmt.Errorf(\"no command specified in process spec (task name: %s)\", task.Name)\n\t}\n\n\tsafeCmdStr := shellEscape(cmdList)\n\tshimScript := e.buildShimScript(exitPath, safeCmdStr)\n\n\tvar cmd *exec.Cmd\n\n\tif e.config.EnableSidecarMode {\n\t\ttargetPID, err := e.findPidByEnvVar(\"SANDBOX_MAIN_CONTAINER\", e.config.MainContainerName)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to resolve target PID: %w\", err)\n\t\t}\n\n\t\ttargetEnv, err := getProcEnviron(targetPID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to read target process environment: %w\", err)\n\t\t}\n\n\t\tnsenterArgs := []string{\n\t\t\t\"-t\", strconv.Itoa(targetPID),\n\t\t\t\"--mount\", \"--uts\", \"--ipc\", \"--net\", \"--pid\",\n\t\t\t\"--\",\n\t\t\t\"/bin/sh\", \"-c\", shimScript,\n\t\t}\n\t\tcmd = exec.Command(\"nsenter\", nsenterArgs...)\n\t\tcmd.Env = targetEnv\n\t\tklog.InfoS(\"Starting sidecar task\", \"id\", task.Name, \"targetPID\", targetPID)\n\n\t} else {\n\t\tcmd = exec.Command(\"/bin/sh\", \"-c\", shimScript)\n\t\tcmd.Env = os.Environ()\n\t\tklog.InfoS(\"Starting host task\", \"name\", task.Name, \"cmd\", safeCmdStr, \"exitPath\", exitPath)\n\t}\n\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tSetpgid: true,\n\t\tPgid:    0,\n\t}\n\n\treturn e.executeCommand(task, cmd, pidPath)\n}\n\n// executeCommand handles log setup and process starting\nfunc (e *processExecutor) executeCommand(task *types.Task, cmd *exec.Cmd, pidPath string) error {\n\tif task == nil || cmd == nil {\n\t\treturn fmt.Errorf(\"task and cmd cannot be nil\")\n\t}\n\n\ttaskDir, err := utils.SafeJoin(e.rootDir, task.Name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\n\tstdoutPath := filepath.Join(taskDir, StdoutFile)\n\tstderrPath := filepath.Join(taskDir, StderrFile)\n\n\tstdoutFile, err := os.OpenFile(stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to open stdout: %w\", err)\n\t}\n\n\tstderrFile, err := os.OpenFile(stderrPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)\n\tif err != nil {\n\t\tstdoutFile.Close()\n\t\treturn fmt.Errorf(\"failed to open stderr: %w\", err)\n\t}\n\n\tcmd.Stdout = stdoutFile\n\tcmd.Stderr = stderrFile\n\n\tif task.Process != nil {\n\t\tfor _, env := range task.Process.Env {\n\t\t\tif env.Name != \"\" {\n\t\t\t\tcmd.Env = append(cmd.Env, fmt.Sprintf(\"%s=%s\", env.Name, env.Value))\n\t\t\t}\n\t\t}\n\n\t\tif task.Process.WorkingDir != \"\" {\n\t\t\tcmd.Dir = task.Process.WorkingDir\n\t\t\tklog.InfoS(\"Set working directory\", \"name\", task.Name, \"workingDir\", task.Process.WorkingDir)\n\t\t}\n\t}\n\n\tif err := cmd.Start(); err != nil {\n\t\tklog.ErrorS(err, \"failed to start command\", \"name\", task.Name)\n\t\tstdoutFile.Close()\n\t\tstderrFile.Close()\n\t\treturn fmt.Errorf(\"failed to start cmd: %w\", err)\n\t}\n\n\t// Write PID to file immediately (Host-side PID)\n\t// This fixes the issue where sidecar tasks would write the container-internal PID\n\tpid := cmd.Process.Pid\n\tif err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil {\n\t\tklog.ErrorS(err, \"failed to write pid file\", \"name\", task.Name)\n\t\t_ = cmd.Process.Kill()\n\t\tstdoutFile.Close()\n\t\tstderrFile.Close()\n\t\treturn fmt.Errorf(\"failed to write pid file: %w\", err)\n\t}\n\n\tklog.InfoS(\"Task command started successfully\", \"name\", task.Name, \"pid\", pid)\n\n\tstdoutFile.Close()\n\tstderrFile.Close()\n\n\tgo func() {\n\t\tif err := cmd.Wait(); err != nil {\n\t\t\tklog.ErrorS(err, \"task process exited with error\", \"name\", task.Name)\n\t\t} else {\n\t\t\tklog.InfoS(\"task process exited successfully\", \"name\", task.Name)\n\t\t}\n\t}()\n\treturn nil\n}\n\nfunc (e *processExecutor) buildShimScript(exitPath, cmdStr string) string {\n\t// The shim script acts as a mini-init process.\n\t// 1. It runs the user command in the background.\n\t// 2. It traps SIGTERM and forwards it to the child process.\n\t// 3. It waits for the child to exit and captures the exit code.\n\t// This ensures graceful shutdown propagation in sidecar/host modes.\n\tscript := fmt.Sprintf(`\ncleanup() {\n    if [ -n \"$CHILD_PID\" ]; then\n        kill -TERM \"$CHILD_PID\" 2>/dev/null\n    fi\n}\ntrap cleanup TERM\n\n%s &\nCHILD_PID=$!\nwait \"$CHILD_PID\"\nEXIT_CODE=$?\n\nprintf \"%%d\" $EXIT_CODE > %s\nexit $EXIT_CODE\n`, cmdStr, shellEscapePath(exitPath))\n\tklog.InfoS(\"Generated shim script\", \"exitPath\", exitPath, \"script\", script)\n\treturn script\n}\n\nfunc (e *processExecutor) Inspect(ctx context.Context, task *types.Task) (*types.Status, error) {\n\ttaskDir, err := utils.SafeJoin(e.rootDir, task.Name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\texitPath := filepath.Join(taskDir, ExitFile)\n\tpidPath := filepath.Join(taskDir, PidFile)\n\n\tstatus := &types.Status{\n\t\tState: types.TaskStateUnknown,\n\t}\n\tsubStatus := types.SubStatus{}\n\tvar pid int\n\tif exitData, err := os.ReadFile(exitPath); err == nil {\n\t\tfileInfo, _ := os.Stat(exitPath)\n\t\texitCode, _ := strconv.Atoi(string(exitData))\n\n\t\tsubStatus.ExitCode = exitCode\n\t\tfinishedAt := fileInfo.ModTime()\n\t\tsubStatus.FinishedAt = &finishedAt\n\n\t\tif exitCode == 0 {\n\t\t\tstatus.State = types.TaskStateSucceeded\n\t\t\tsubStatus.Reason = \"Succeeded\"\n\t\t} else {\n\t\t\tstatus.State = types.TaskStateFailed\n\t\t\tsubStatus.Reason = \"Failed\"\n\t\t}\n\n\t\tif pidFileInfo, err := os.Stat(pidPath); err == nil {\n\t\t\tstartedAt := pidFileInfo.ModTime()\n\t\t\tsubStatus.StartedAt = &startedAt\n\t\t}\n\n\t\tstatus.SubStatuses = []types.SubStatus{subStatus}\n\t\treturn status, nil\n\t}\n\n\tif pidData, err := os.ReadFile(pidPath); err == nil {\n\t\tpid, _ = strconv.Atoi(strings.TrimSpace(string(pidData)))\n\t\tfileInfo, _ := os.Stat(pidPath)\n\t\tstartedAt := fileInfo.ModTime()\n\t\tsubStatus.StartedAt = &startedAt\n\n\t\tif isProcessRunning(pid) {\n\t\t\tstatus.State = types.TaskStateRunning\n\t\t\tif task.Process != nil && task.Process.TimeoutSeconds != nil {\n\t\t\t\ttimeout := time.Duration(*task.Process.TimeoutSeconds) * time.Second\n\t\t\t\telapsed := time.Since(startedAt)\n\t\t\t\tif elapsed > timeout {\n\t\t\t\t\tstatus.State = types.TaskStateTimeout\n\t\t\t\t\tsubStatus.Reason = \"TaskTimeout\"\n\t\t\t\t\tsubStatus.Message = fmt.Sprintf(\"Task exceeded timeout of %d seconds\", *task.Process.TimeoutSeconds)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tstatus.State = types.TaskStateFailed\n\t\t\tsubStatus.ExitCode = 137\n\t\t\tsubStatus.Reason = \"ProcessCrashed\"\n\t\t\tsubStatus.Message = \"Process exited without writing exit code\"\n\t\t\tsubStatus.FinishedAt = &startedAt\n\t\t}\n\t\tstatus.SubStatuses = []types.SubStatus{subStatus}\n\t\treturn status, nil\n\t}\n\n\tstatus.State = types.TaskStatePending\n\tsubStatus.Reason = \"Pending\"\n\tstatus.SubStatuses = []types.SubStatus{subStatus}\n\n\treturn status, nil\n}\n\nfunc (e *processExecutor) Stop(ctx context.Context, task *types.Task) error {\n\ttaskDir, err := utils.SafeJoin(e.rootDir, task.Name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\tpidPath := filepath.Join(taskDir, PidFile)\n\tpidData, err := os.ReadFile(pidPath)\n\tif err != nil {\n\t\treturn nil\n\t}\n\tvar pid int\n\tpid, err = strconv.Atoi(strings.TrimSpace(string(pidData)))\n\tif err != nil || pid == 0 {\n\t\treturn nil\n\t}\n\tklog.InfoS(\"Read PID from pid file\", \"name\", task.Name, \"pid\", pid)\n\n\tpgid := -pid\n\n\ttargetPID := 0\n\tif e.config.EnableSidecarMode {\n\t\tchildren, err := getChildrenPIDs(pid)\n\t\tif err == nil && len(children) > 0 {\n\t\t\ttargetPID = children[0]\n\t\t\tklog.InfoS(\"Sidecar mode: targeted Shim process via /proc/children\", \"nsenterPID\", pid, \"shimPID\", targetPID)\n\t\t} else {\n\t\t\tklog.Warning(\"Sidecar mode: failed to find child process via /proc/children, falling back to PGID\", \"pid\", pid, \"err\", err)\n\t\t}\n\t} else {\n\t\ttargetPID = pid\n\t}\n\n\tkilledShim := false\n\tif targetPID > 0 {\n\t\tif err := syscall.Kill(targetPID, syscall.SIGTERM); err == nil {\n\t\t\tkilledShim = true\n\t\t} else if err != syscall.ESRCH {\n\t\t\tklog.ErrorS(err, \"Failed to send SIGTERM to target process\", \"targetPID\", targetPID)\n\t\t}\n\t}\n\n\tif !killedShim {\n\t\t_ = syscall.Kill(pgid, syscall.SIGTERM)\n\t}\n\n\ttimeout := 10 * time.Second\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tif !isProcessRunning(pid) {\n\t\t\treturn nil\n\t\t}\n\t\ttime.Sleep(500 * time.Millisecond)\n\t}\n\n\tklog.InfoS(\"Process did not exit after timeout, sending SIGKILL\", \"pgid\", pgid)\n\tif targetPID > 0 {\n\t\t_ = syscall.Kill(targetPID, syscall.SIGKILL)\n\t}\n\t_ = syscall.Kill(pgid, syscall.SIGKILL)\n\n\treturn nil\n}\n\n// getChildrenPIDs reads /proc/<pid>/task/<pid>/children to find direct children\nfunc getChildrenPIDs(pid int) ([]int, error) {\n\tpath := fmt.Sprintf(\"/proc/%d/task/%d/children\", pid, pid)\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar pids []int\n\tfor _, field := range strings.Fields(string(data)) {\n\t\tif id, err := strconv.Atoi(field); err == nil {\n\t\t\tpids = append(pids, id)\n\t\t}\n\t}\n\treturn pids, nil\n}\n\nfunc isProcessRunning(pid int) bool {\n\tprocess, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn process.Signal(syscall.Signal(0)) == nil\n}\n\n// shellEscape quotes arguments for safe shell execution\nfunc shellEscape(args []string) string {\n\tquoted := make([]string, len(args))\n\tfor i, s := range args {\n\t\tquoted[i] = shellEscapePath(s)\n\t}\n\treturn strings.Join(quoted, \" \")\n}\n\n// shellEscapePath escapes a single string for safe shell execution.\n// It wraps the string in single quotes and escapes any embedded single quotes.\n// e.g., foo'bar -> 'foo'\\”bar'\nfunc shellEscapePath(s string) string {\n\treturn \"'\" + strings.ReplaceAll(s, \"'\", \"'\\\\''\") + \"'\"\n}\n\n// findPidByEnvVar finds a process by checking for a specific environment variable\nfunc (e *processExecutor) findPidByEnvVar(envName, expectedValue string) (int, error) {\n\tprocDir, err := os.Open(\"/proc\")\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to open /proc: %w\", err)\n\t}\n\tdefer procDir.Close()\n\n\tentries, err := procDir.Readdirnames(-1)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"failed to read /proc entries: %w\", err)\n\t}\n\n\tselfPID := os.Getpid()\n\ttargetEnv := fmt.Sprintf(\"%s=%s\", envName, expectedValue)\n\n\tfor _, entry := range entries {\n\t\tpid, err := strconv.Atoi(entry)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif pid == selfPID {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Read process environment\n\t\tenvPath := filepath.Join(\"/proc\", entry, \"environ\")\n\t\tenvData, err := os.ReadFile(envPath)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Environment variables are null-separated\n\t\tenvVars := strings.Split(string(envData), \"\\x00\")\n\t\tfor _, env := range envVars {\n\t\t\tif env == targetEnv {\n\t\t\t\tklog.InfoS(\"Found main container by environment variable\", \"pid\", pid, \"env\", targetEnv)\n\t\t\t\treturn pid, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn 0, fmt.Errorf(\"no process found with environment variable %s=%s\", envName, expectedValue)\n}\n\n// getProcEnviron reads environment variables from /proc/<pid>/environ\nfunc getProcEnviron(pid int) ([]string, error) {\n\tenvPath := filepath.Join(\"/proc\", strconv.Itoa(pid), \"environ\")\n\tdata, err := os.ReadFile(envPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Environment variables in /proc/<pid>/environ are separated by null bytes\n\tvar envs []string\n\tfor _, env := range strings.Split(string(data), \"\\x00\") {\n\t\tif len(env) > 0 {\n\t\t\tenvs = append(envs, env)\n\t\t}\n\t}\n\treturn envs, nil\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/runtime/process_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage runtime\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/utils\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc setupTestExecutor(t *testing.T) (Executor, string) {\n\tdataDir := t.TempDir()\n\tcfg := &config.Config{\n\t\tDataDir:           dataDir,\n\t\tEnableSidecarMode: false,\n\t}\n\texecutor, err := NewProcessExecutor(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create executor: %v\", err)\n\t}\n\treturn executor, dataDir\n}\n\nfunc TestProcessExecutor_Lifecycle(t *testing.T) {\n\t// Skip if not running on Linux/Unix-like systems where sh is available\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found, skipping process executor test\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\t// 1. Create a task that runs for a while\n\ttask := &types.Task{\n\t\tName: \"long-running\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"/bin/sh\", \"-c\", \"sleep 10\"},\n\t\t},\n\t}\n\n\t// Create task directory manually (normally handled by store)\n\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\t// 2. Start\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// 3. Inspect (Running)\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\tif status.State != types.TaskStateRunning {\n\t\tt.Errorf(\"Task should be running, got: %s\", status.State)\n\t}\n\n\t// 4. Stop\n\tif err := executor.Stop(ctx, task); err != nil {\n\t\tt.Fatalf(\"Stop failed: %v\", err)\n\t}\n\n\t// 5. Inspect (Terminated)\n\t// Wait a bit for file to be written\n\ttime.Sleep(100 * time.Millisecond)\n\tstatus, err = executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\t// sleep command killed by signal results in non-zero exit code, so it's Failed\n\tif status.State != types.TaskStateFailed {\n\t\tt.Errorf(\"Task should be failed (terminated), got: %s\", status.State)\n\t}\n}\n\nfunc TestProcessExecutor_ShortLived(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\ttask := &types.Task{\n\t\tName: \"short-lived\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"done\"},\n\t\t},\n\t}\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// Wait for process to finish\n\ttime.Sleep(200 * time.Millisecond)\n\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\tif status.State != types.TaskStateSucceeded {\n\t\tt.Errorf(\"Task should be succeeded, got: %s\", status.State)\n\t}\n\tassert.NotEmpty(t, status.SubStatuses)\n\tif status.SubStatuses[0].ExitCode != 0 {\n\t\tt.Errorf(\"Exit code should be 0, got %d\", status.SubStatuses[0].ExitCode)\n\t}\n}\n\nfunc TestProcessExecutor_Failure(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\ttask := &types.Task{\n\t\tName: \"failing-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"/bin/sh\", \"-c\", \"exit 1\"},\n\t\t},\n\t}\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\ttime.Sleep(200 * time.Millisecond)\n\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\tif status.State != types.TaskStateFailed {\n\t\tt.Errorf(\"Task should be failed\")\n\t}\n\tassert.NotEmpty(t, status.SubStatuses)\n\tif status.SubStatuses[0].ExitCode != 1 {\n\t\tt.Errorf(\"Exit code should be 1, got %d\", status.SubStatuses[0].ExitCode)\n\t}\n}\n\nfunc TestProcessExecutor_InvalidArgs(t *testing.T) {\n\texec, _ := setupTestExecutor(t)\n\tctx := context.Background()\n\n\t// Nil task\n\tif err := exec.Start(ctx, nil); err == nil {\n\t\tt.Error(\"Start should fail with nil task\")\n\t}\n\n\t// Missing process spec\n\ttask := &types.Task{\n\t\tName:    \"invalid\",\n\t\tProcess: &api.Process{},\n\t}\n\tif err := exec.Start(ctx, task); err == nil {\n\t\tt.Error(\"Start should fail with missing process spec\")\n\t}\n}\n\nfunc TestShellEscape(t *testing.T) {\n\ttests := []struct {\n\t\tinput    []string\n\t\texpected string\n\t}{\n\t\t{[]string{\"echo\", \"hello\"}, \"'echo' 'hello'\"},\n\t\t{[]string{\"echo\", \"hello world\"}, \"'echo' 'hello world'\"},\n\t\t{[]string{\"foo'bar\"}, \"'foo'\\\\''bar'\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tgot := shellEscape(tt.input)\n\t\tif got != tt.expected {\n\t\t\tt.Errorf(\"shellEscape(%v) = %q, want %q\", tt.input, got, tt.expected)\n\t\t}\n\t}\n}\n\nfunc TestNewExecutor(t *testing.T) {\n\t// 1. Container mode + Host Mode\n\tcfg := &config.Config{}\n\te, err := NewExecutor(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecutor(container) failed: %v\", err)\n\t}\n\tif _, ok := e.(*compositeExecutor); !ok {\n\t\tt.Error(\"NewExecutor should return CompositeExecutor\")\n\t}\n\n\t// 2. Process mode only\n\tcfg = &config.Config{\n\t\tDataDir: t.TempDir(),\n\t}\n\te, err = NewExecutor(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"NewExecutor(process) failed: %v\", err)\n\t}\n\tif _, ok := e.(*compositeExecutor); !ok {\n\t\tt.Error(\"NewExecutor should return CompositeExecutor\")\n\t}\n\n\t// 3. Nil config\n\tif _, err := NewExecutor(nil); err == nil {\n\t\tt.Error(\"NewExecutor should fail with nil config\")\n\t}\n}\n\nfunc TestProcessExecutor_EnvInheritance(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\t// 1. Setup Host Environment\n\texpectedHostVar := \"HOST_TEST_VAR=host_value\"\n\tos.Setenv(\"HOST_TEST_VAR\", \"host_value\")\n\tdefer os.Unsetenv(\"HOST_TEST_VAR\")\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\t// 2. Define Task with Custom Env\n\ttask := &types.Task{\n\t\tName: \"env-test\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"env\"},\n\t\t\tEnv: []corev1.EnvVar{\n\t\t\t\t{Name: \"TASK_TEST_VAR\", Value: \"task_value\"},\n\t\t\t},\n\t\t},\n\t}\n\texpectedTaskVar := \"TASK_TEST_VAR=task_value\"\n\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\t// 3. Start Task\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// 4. Wait for completion\n\ttime.Sleep(200 * time.Millisecond)\n\n\tstatus, err := executor.Inspect(ctx, task)\n\tassert.Nil(t, err)\n\tassert.Equal(t, types.TaskStateSucceeded, status.State)\n\n\t// 5. Verify Output\n\tstdoutPath := filepath.Join(taskDir, StdoutFile)\n\toutput, err := os.ReadFile(stdoutPath)\n\tassert.Nil(t, err)\n\toutputStr := string(output)\n\n\tassert.Contains(t, outputStr, expectedHostVar, \"Should inherit host environment variables\")\n\tassert.Contains(t, outputStr, expectedTaskVar, \"Should include task-specific environment variables\")\n}\n\nfunc TestProcessExecutor_TimeoutDetection(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\ttimeoutSec := int64(2)\n\ttask := &types.Task{\n\t\tName: \"timeout-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand:        []string{\"sleep\", \"30\"},\n\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t},\n\t}\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// Wait for timeout to be detected (2 seconds + margin)\n\ttime.Sleep(2500 * time.Millisecond)\n\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\n\t// Should detect timeout\n\tassert.Equal(t, types.TaskStateTimeout, status.State, \"Task should be in Timeout state\")\n\tassert.NotEmpty(t, status.SubStatuses)\n\tassert.Equal(t, \"TaskTimeout\", status.SubStatuses[0].Reason)\n\tassert.Contains(t, status.SubStatuses[0].Message, \"timeout of 2 seconds\")\n\n\t// Cleanup\n\texecutor.Stop(ctx, task)\n}\n\nfunc TestProcessExecutor_TimeoutNotExceeded(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tctx := context.Background()\n\n\ttimeoutSec := int64(10)\n\ttask := &types.Task{\n\t\tName: \"quick-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand:        []string{\"echo\", \"done\"},\n\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t},\n\t}\n\ttaskDir, err := utils.SafeJoin(executor.(*processExecutor).rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// Wait for process to complete\n\ttime.Sleep(200 * time.Millisecond)\n\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\n\t// Should be Succeeded, not Timeout\n\tassert.Equal(t, types.TaskStateSucceeded, status.State, \"Task should be Succeeded, not Timeout\")\n}\n\nfunc TestProcessExecutor_NoTimeout(t *testing.T) {\n\tif _, err := exec.LookPath(\"sh\"); err != nil {\n\t\tt.Skip(\"sh not found\")\n\t}\n\n\texecutor, _ := setupTestExecutor(t)\n\tpExecutor := executor.(*processExecutor)\n\tctx := context.Background()\n\n\t// Task without timeout setting\n\ttask := &types.Task{\n\t\tName: \"no-timeout-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"sleep\", \"1\"},\n\t\t},\n\t}\n\ttaskDir, err := utils.SafeJoin(pExecutor.rootDir, task.Name)\n\tassert.Nil(t, err)\n\tos.MkdirAll(taskDir, 0755)\n\n\tif err := executor.Start(ctx, task); err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\n\t// Inspect immediately\n\tstatus, err := executor.Inspect(ctx, task)\n\tif err != nil {\n\t\tt.Fatalf(\"Inspect failed: %v\", err)\n\t}\n\n\t// Should be Running, not Timeout\n\tassert.Equal(t, types.TaskStateRunning, status.State, \"Task should be Running when no timeout is set\")\n\n\t// Cleanup\n\texecutor.Stop(ctx, task)\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/server/handler.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/manager\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// ErrorResponse represents a standard error response\ntype ErrorResponse struct {\n\tCode    string `json:\"code\"`\n\tMessage string `json:\"message\"`\n}\n\ntype Handler struct {\n\tmanager manager.TaskManager\n\tconfig  *config.Config\n}\n\nfunc NewHandler(mgr manager.TaskManager, cfg *config.Config) *Handler {\n\tif mgr == nil {\n\t\tklog.Warning(\"TaskManager is nil, handler may not work properly\")\n\t}\n\tif cfg == nil {\n\t\tklog.Warning(\"Config is nil, handler may not work properly\")\n\t}\n\treturn &Handler{\n\t\tmanager: mgr,\n\t\tconfig:  cfg,\n\t}\n}\n\nfunc (h *Handler) CreateTask(w http.ResponseWriter, r *http.Request) {\n\tif h.manager == nil {\n\t\twriteError(w, http.StatusInternalServerError, \"task manager not initialized\")\n\t\treturn\n\t}\n\n\tvar apiTask api.Task\n\tif err := json.NewDecoder(r.Body).Decode(&apiTask); err != nil {\n\t\twriteError(w, http.StatusBadRequest, fmt.Sprintf(\"invalid request body: %v\", err))\n\t\treturn\n\t}\n\n\tif apiTask.Name == \"\" {\n\t\twriteError(w, http.StatusBadRequest, \"task name is required\")\n\t\treturn\n\t}\n\n\ttask := h.convertAPIToInternalTask(&apiTask)\n\tif task == nil {\n\t\twriteError(w, http.StatusBadRequest, \"failed to convert task\")\n\t\treturn\n\t}\n\n\tcreated, err := h.manager.Create(r.Context(), task)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to create task\", \"name\", apiTask.Name)\n\t\twriteError(w, http.StatusInternalServerError, fmt.Sprintf(\"failed to create task: %v\", err))\n\t\treturn\n\t}\n\n\tresponse := convertInternalToAPITask(created)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusCreated)\n\tjson.NewEncoder(w).Encode(response)\n\n\tklog.InfoS(\"task created via API\", \"name\", apiTask.Name)\n}\n\nfunc (h *Handler) SyncTasks(w http.ResponseWriter, r *http.Request) {\n\tif h.manager == nil {\n\t\twriteError(w, http.StatusInternalServerError, \"task manager not initialized\")\n\t\treturn\n\t}\n\n\tvar apiTasks []api.Task\n\tif err := json.NewDecoder(r.Body).Decode(&apiTasks); err != nil {\n\t\twriteError(w, http.StatusBadRequest, fmt.Sprintf(\"invalid request body: %v\", err))\n\t\treturn\n\t}\n\n\tdesired := make([]*types.Task, 0, len(apiTasks))\n\tfor i := range apiTasks {\n\t\tif apiTasks[i].Name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ttask := h.convertAPIToInternalTask(&apiTasks[i])\n\t\tif task != nil {\n\t\t\tdesired = append(desired, task)\n\t\t}\n\t}\n\n\tcurrent, err := h.manager.Sync(r.Context(), desired)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to sync tasks\")\n\t\twriteError(w, http.StatusInternalServerError, fmt.Sprintf(\"failed to sync tasks: %v\", err))\n\t\treturn\n\t}\n\n\tresponse := make([]api.Task, 0, len(current))\n\tfor _, task := range current {\n\t\tif task != nil {\n\t\t\tresponse = append(response, *convertInternalToAPITask(task))\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(response)\n\n\tklog.V(1).InfoS(\"tasks synced via API\", \"count\", len(response))\n}\n\nfunc (h *Handler) GetTask(w http.ResponseWriter, r *http.Request) {\n\tif h.manager == nil {\n\t\twriteError(w, http.StatusInternalServerError, \"task manager not initialized\")\n\t\treturn\n\t}\n\n\t// Extract task ID from path\n\ttaskID := r.PathValue(\"id\")\n\tif taskID == \"\" {\n\t\twriteError(w, http.StatusBadRequest, \"task id is required\")\n\t\treturn\n\t}\n\n\ttask, err := h.manager.Get(r.Context(), taskID)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to get task\", \"id\", taskID)\n\t\twriteError(w, http.StatusNotFound, fmt.Sprintf(\"task not found: %v\", err))\n\t\treturn\n\t}\n\n\tresponse := convertInternalToAPITask(task)\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(response)\n}\n\nfunc (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {\n\tif h.manager == nil {\n\t\twriteError(w, http.StatusInternalServerError, \"task manager not initialized\")\n\t\treturn\n\t}\n\n\ttasks, err := h.manager.List(r.Context())\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to list tasks\")\n\t\twriteError(w, http.StatusInternalServerError, fmt.Sprintf(\"failed to list tasks: %v\", err))\n\t\treturn\n\t}\n\n\tresponse := make([]api.Task, 0, len(tasks))\n\tfor _, task := range tasks {\n\t\tif task != nil {\n\t\t\tresponse = append(response, *convertInternalToAPITask(task))\n\t\t}\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(response)\n}\n\nfunc (h *Handler) Health(w http.ResponseWriter, r *http.Request) {\n\tresponse := map[string]string{\n\t\t\"status\": \"healthy\",\n\t}\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(response)\n}\n\nfunc (h *Handler) DeleteTask(w http.ResponseWriter, r *http.Request) {\n\tif h.manager == nil {\n\t\twriteError(w, http.StatusInternalServerError, \"task manager not initialized\")\n\t\treturn\n\t}\n\n\t// Extract task ID from path\n\ttaskID := r.PathValue(\"id\")\n\tif taskID == \"\" {\n\t\twriteError(w, http.StatusBadRequest, \"task id is required\")\n\t\treturn\n\t}\n\n\terr := h.manager.Delete(r.Context(), taskID)\n\tif err != nil {\n\t\tklog.ErrorS(err, \"failed to delete task\", \"id\", taskID)\n\t\twriteError(w, http.StatusInternalServerError, fmt.Sprintf(\"failed to delete task: %v\", err))\n\t\treturn\n\t}\n\n\tw.WriteHeader(http.StatusNoContent)\n\tklog.InfoS(\"task deleted via API\", \"id\", taskID)\n}\n\nfunc writeError(w http.ResponseWriter, code int, message string) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(code)\n\tjson.NewEncoder(w).Encode(ErrorResponse{\n\t\tCode:    http.StatusText(code),\n\t\tMessage: message,\n\t})\n}\n\nfunc (h *Handler) convertAPIToInternalTask(apiTask *api.Task) *types.Task {\n\tif apiTask == nil {\n\t\treturn nil\n\t}\n\ttask := &types.Task{\n\t\tName:            apiTask.Name,\n\t\tProcess:         apiTask.Process,\n\t\tPodTemplateSpec: apiTask.PodTemplateSpec,\n\t}\n\ttask.Status = types.Status{\n\t\tState: types.TaskStatePending,\n\t}\n\n\treturn task\n}\n\nfunc convertInternalToAPITask(task *types.Task) *api.Task {\n\tif task == nil {\n\t\treturn nil\n\t}\n\n\tapiTask := &api.Task{\n\t\tName:            task.Name,\n\t\tProcess:         task.Process,\n\t\tPodTemplateSpec: task.PodTemplateSpec,\n\t}\n\n\tif task.Process != nil && len(task.Status.SubStatuses) > 0 {\n\t\tsub := task.Status.SubStatuses[0]\n\t\tapiStatus := &api.ProcessStatus{}\n\n\t\tif task.Status.State == types.TaskStateTimeout {\n\t\t\tterm := &api.Terminated{\n\t\t\t\tExitCode: 137,\n\t\t\t\tReason:   sub.Reason,\n\t\t\t\tMessage:  sub.Message,\n\t\t\t}\n\t\t\tif sub.StartedAt != nil {\n\t\t\t\tterm.StartedAt = metav1.NewTime(*sub.StartedAt)\n\t\t\t}\n\t\t\tterm.FinishedAt = metav1.Now()\n\t\t\tapiStatus.Terminated = term\n\t\t} else if sub.FinishedAt != nil {\n\t\t\tterm := &api.Terminated{\n\t\t\t\tExitCode: int32(sub.ExitCode),\n\t\t\t\tReason:   sub.Reason,\n\t\t\t\tMessage:  sub.Message,\n\t\t\t}\n\t\t\tterm.FinishedAt = metav1.NewTime(*sub.FinishedAt)\n\t\t\tif sub.StartedAt != nil {\n\t\t\t\tterm.StartedAt = metav1.NewTime(*sub.StartedAt)\n\t\t\t}\n\t\t\tapiStatus.Terminated = term\n\t\t} else if sub.StartedAt != nil {\n\t\t\tapiStatus.Running = &api.Running{\n\t\t\t\tStartedAt: metav1.NewTime(*sub.StartedAt),\n\t\t\t}\n\t\t} else {\n\t\t\tapiStatus.Waiting = &api.Waiting{\n\t\t\t\tReason:  sub.Reason,\n\t\t\t\tMessage: sub.Message,\n\t\t\t}\n\t\t}\n\t\tapiTask.ProcessStatus = apiStatus\n\t}\n\n\tif task.PodTemplateSpec != nil {\n\t\tpodStatus := &corev1.PodStatus{\n\t\t\tPhase: corev1.PodUnknown,\n\t\t}\n\n\t\tswitch task.Status.State {\n\t\tcase types.TaskStatePending:\n\t\t\tpodStatus.Phase = corev1.PodPending\n\t\tcase types.TaskStateRunning:\n\t\t\tpodStatus.Phase = corev1.PodRunning\n\t\tcase types.TaskStateSucceeded:\n\t\t\tpodStatus.Phase = corev1.PodSucceeded\n\t\tcase types.TaskStateFailed:\n\t\t\tpodStatus.Phase = corev1.PodFailed\n\t\t}\n\n\t\tfor _, sub := range task.Status.SubStatuses {\n\t\t\tcs := corev1.ContainerStatus{\n\t\t\t\tName: sub.Name,\n\t\t\t}\n\t\t\tif sub.FinishedAt != nil {\n\t\t\t\tcs.State.Terminated = &corev1.ContainerStateTerminated{\n\t\t\t\t\tExitCode:   int32(sub.ExitCode),\n\t\t\t\t\tReason:     sub.Reason,\n\t\t\t\t\tMessage:    sub.Message,\n\t\t\t\t\tFinishedAt: metav1.NewTime(*sub.FinishedAt),\n\t\t\t\t}\n\t\t\t\tif sub.StartedAt != nil {\n\t\t\t\t\tcs.State.Terminated.StartedAt = metav1.NewTime(*sub.StartedAt)\n\t\t\t\t}\n\t\t\t} else if sub.StartedAt != nil {\n\t\t\t\tcs.State.Running = &corev1.ContainerStateRunning{\n\t\t\t\t\tStartedAt: metav1.NewTime(*sub.StartedAt),\n\t\t\t\t}\n\t\t\t\tcs.Ready = true\n\t\t\t} else {\n\t\t\t\tcs.State.Waiting = &corev1.ContainerStateWaiting{\n\t\t\t\t\tReason:  sub.Reason,\n\t\t\t\t\tMessage: sub.Message,\n\t\t\t\t}\n\t\t\t}\n\t\t\tpodStatus.ContainerStatuses = append(podStatus.ContainerStatuses, cs)\n\t\t}\n\n\t\tallReady := len(podStatus.ContainerStatuses) > 0\n\t\tfor _, cs := range podStatus.ContainerStatuses {\n\t\t\tif !cs.Ready {\n\t\t\t\tallReady = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treadyStatus := corev1.ConditionFalse\n\t\tif allReady {\n\t\t\treadyStatus = corev1.ConditionTrue\n\t\t}\n\n\t\tvar latestTransition time.Time\n\t\tfor _, sub := range task.Status.SubStatuses {\n\t\t\tif sub.StartedAt != nil && sub.StartedAt.After(latestTransition) {\n\t\t\t\tlatestTransition = *sub.StartedAt\n\t\t\t}\n\t\t\tif sub.FinishedAt != nil && sub.FinishedAt.After(latestTransition) {\n\t\t\t\tlatestTransition = *sub.FinishedAt\n\t\t\t}\n\t\t}\n\t\tltt := metav1.NewTime(latestTransition)\n\t\tif latestTransition.IsZero() {\n\t\t\tltt = metav1.Now()\n\t\t}\n\n\t\tpodStatus.Conditions = append(podStatus.Conditions,\n\t\t\tcorev1.PodCondition{\n\t\t\t\tType:               corev1.PodReady,\n\t\t\t\tStatus:             readyStatus,\n\t\t\t\tLastTransitionTime: ltt,\n\t\t\t},\n\t\t\tcorev1.PodCondition{\n\t\t\t\tType:               corev1.ContainersReady,\n\t\t\t\tStatus:             readyStatus,\n\t\t\t\tLastTransitionTime: ltt,\n\t\t\t},\n\t\t)\n\n\t\tapiTask.PodStatus = podStatus\n\t}\n\n\treturn apiTask\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/server/handler_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/config\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// MockTaskManager implements manager.TaskManager for testing\ntype MockTaskManager struct {\n\ttasks map[string]*types.Task\n\terr   error\n}\n\nfunc NewMockTaskManager() *MockTaskManager {\n\treturn &MockTaskManager{\n\t\ttasks: make(map[string]*types.Task),\n\t}\n}\n\nfunc (m *MockTaskManager) Create(ctx context.Context, task *types.Task) (*types.Task, error) {\n\tif m.err != nil {\n\t\treturn nil, m.err\n\t}\n\tm.tasks[task.Name] = task\n\treturn task, nil\n}\n\nfunc (m *MockTaskManager) Sync(ctx context.Context, desired []*types.Task) ([]*types.Task, error) {\n\tif m.err != nil {\n\t\treturn nil, m.err\n\t}\n\tm.tasks = make(map[string]*types.Task)\n\tvar result []*types.Task\n\tfor _, t := range desired {\n\t\tm.tasks[t.Name] = t\n\t\tresult = append(result, t)\n\t}\n\treturn result, nil\n}\n\nfunc (m *MockTaskManager) Get(ctx context.Context, id string) (*types.Task, error) {\n\tif m.err != nil {\n\t\treturn nil, m.err\n\t}\n\tif t, ok := m.tasks[id]; ok {\n\t\treturn t, nil\n\t}\n\treturn nil, fmt.Errorf(\"not found\")\n}\n\nfunc (m *MockTaskManager) List(ctx context.Context) ([]*types.Task, error) {\n\tif m.err != nil {\n\t\treturn nil, m.err\n\t}\n\tvar list []*types.Task\n\tfor _, t := range m.tasks {\n\t\tlist = append(list, t)\n\t}\n\treturn list, nil\n}\n\nfunc (m *MockTaskManager) Delete(ctx context.Context, id string) error {\n\tif m.err != nil {\n\t\treturn m.err\n\t}\n\tdelete(m.tasks, id)\n\treturn nil\n}\n\nfunc (m *MockTaskManager) Start(ctx context.Context) {}\nfunc (m *MockTaskManager) Stop()                     {}\n\nfunc TestHandler_Health(t *testing.T) {\n\tcfg := &config.Config{}\n\th := NewHandler(NewMockTaskManager(), cfg)\n\treq := httptest.NewRequest(\"GET\", \"/health\", nil)\n\tw := httptest.NewRecorder()\n\n\th.Health(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"Health returned status %d\", w.Code)\n\t}\n}\n\nfunc TestHandler_CreateTask(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\n\ttask := api.Task{\n\t\tName: \"test-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\"},\n\t\t},\n\t}\n\tbody, _ := json.Marshal(task)\n\n\treq := httptest.NewRequest(\"POST\", \"/tasks\", bytes.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\th.CreateTask(w, req)\n\n\tif w.Code != http.StatusCreated {\n\t\tt.Errorf(\"CreateTask returned status %d\", w.Code)\n\t}\n\n\tif _, ok := mgr.tasks[\"test-task\"]; !ok {\n\t\tt.Error(\"Task was not created in manager\")\n\t}\n}\n\nfunc TestHandler_GetTask(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tmgr.tasks[\"test-task\"] = &types.Task{Name: \"test-task\"}\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\n\trouter := NewRouter(h)\n\treq := httptest.NewRequest(\"GET\", \"/tasks/test-task\", nil)\n\tw := httptest.NewRecorder()\n\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"GetTask returned status %d\", w.Code)\n\t}\n\n\tvar resp api.Task\n\tjson.NewDecoder(w.Body).Decode(&resp)\n\tif resp.Name != \"test-task\" {\n\t\tt.Errorf(\"GetTask returned name %s\", resp.Name)\n\t}\n}\n\nfunc TestHandler_DeleteTask(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tmgr.tasks[\"test-task\"] = &types.Task{Name: \"test-task\"}\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\trouter := NewRouter(h)\n\n\treq := httptest.NewRequest(\"DELETE\", \"/tasks/test-task\", nil)\n\tw := httptest.NewRecorder()\n\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusNoContent {\n\t\tt.Errorf(\"DeleteTask returned status %d\", w.Code)\n\t}\n\n\tif _, ok := mgr.tasks[\"test-task\"]; ok {\n\t\tt.Error(\"Task was not deleted from manager\")\n\t}\n}\n\nfunc TestHandler_ListTasks(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tmgr.tasks[\"task-1\"] = &types.Task{Name: \"task-1\"}\n\tmgr.tasks[\"task-2\"] = &types.Task{Name: \"task-2\"}\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\n\treq := httptest.NewRequest(\"GET\", \"/getTasks\", nil)\n\tw := httptest.NewRecorder()\n\n\th.ListTasks(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"ListTasks returned status %d\", w.Code)\n\t}\n\n\tvar resp []api.Task\n\tjson.NewDecoder(w.Body).Decode(&resp)\n\tif len(resp) != 2 {\n\t\tt.Errorf(\"ListTasks returned %d tasks, want 2\", len(resp))\n\t}\n}\n\nfunc TestHandler_SyncTasks(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\n\ttasks := []api.Task{\n\t\t{Name: \"task-1\", Process: &api.Process{}},\n\t}\n\tbody, _ := json.Marshal(tasks)\n\n\treq := httptest.NewRequest(\"POST\", \"/setTasks\", bytes.NewReader(body))\n\tw := httptest.NewRecorder()\n\n\th.SyncTasks(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"SyncTasks returned status %d\", w.Code)\n\t}\n\n\tif _, ok := mgr.tasks[\"task-1\"]; !ok {\n\t\tt.Error(\"Task was not synced to manager\")\n\t}\n}\n\nfunc TestHandler_Errors(t *testing.T) {\n\tmgr := NewMockTaskManager()\n\tmgr.err = errors.New(\"mock error\")\n\tcfg := &config.Config{}\n\th := NewHandler(mgr, cfg)\n\n\t// Create fail\n\ttask := api.Task{Name: \"fail\"}\n\tbody, _ := json.Marshal(task)\n\treq := httptest.NewRequest(\"POST\", \"/tasks\", bytes.NewReader(body))\n\tw := httptest.NewRecorder()\n\th.CreateTask(w, req)\n\tif w.Code != http.StatusInternalServerError {\n\t\tt.Errorf(\"CreateTask should fail with 500, got %d\", w.Code)\n\t}\n}\n\nfunc TestConvertInternalToAPITask(t *testing.T) {\n\tnow := time.Now()\n\n\tt.Run(\"Process Task\", func(t *testing.T) {\n\t\ttask := &types.Task{\n\t\t\tName:    \"proc-task\",\n\t\t\tProcess: &api.Process{Command: []string{\"ls\"}},\n\t\t\tStatus: types.Status{\n\t\t\t\tState: types.TaskStateSucceeded,\n\t\t\t\tSubStatuses: []types.SubStatus{\n\t\t\t\t\t{\n\t\t\t\t\t\tExitCode:   0,\n\t\t\t\t\t\tReason:     \"Completed\",\n\t\t\t\t\t\tFinishedAt: &now,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tapiTask := convertInternalToAPITask(task)\n\t\tassert.NotNil(t, apiTask.ProcessStatus)\n\t\tassert.NotNil(t, apiTask.ProcessStatus.Terminated)\n\t\tassert.Equal(t, int32(0), apiTask.ProcessStatus.Terminated.ExitCode)\n\t\tassert.Nil(t, apiTask.PodStatus)\n\t})\n\n\tt.Run(\"Pod Task - Partially Ready\", func(t *testing.T) {\n\t\ttask := &types.Task{\n\t\t\tName:            \"pod-task-partial\",\n\t\t\tPodTemplateSpec: &corev1.PodTemplateSpec{},\n\t\t\tStatus: types.Status{\n\t\t\t\tState: types.TaskStateRunning,\n\t\t\t\tSubStatuses: []types.SubStatus{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:      \"c1\",\n\t\t\t\t\t\tStartedAt: &now,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:   \"c2\",\n\t\t\t\t\t\tReason: \"Pending\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tapiTask := convertInternalToAPITask(task)\n\t\tassert.NotNil(t, apiTask.PodStatus)\n\t\tassert.Equal(t, corev1.PodRunning, apiTask.PodStatus.Phase)\n\t\tassert.Len(t, apiTask.PodStatus.ContainerStatuses, 2)\n\t\tassert.True(t, apiTask.PodStatus.ContainerStatuses[0].Ready)\n\t\tassert.False(t, apiTask.PodStatus.ContainerStatuses[1].Ready)\n\t\tassert.False(t, utils.IsPodReadyConditionTrue(*apiTask.PodStatus))\n\n\t\t// Conditions check\n\t\tvar podReady, containersReady *corev1.PodCondition\n\t\tfor i := range apiTask.PodStatus.Conditions {\n\t\t\tc := &apiTask.PodStatus.Conditions[i]\n\t\t\tif c.Type == corev1.PodReady {\n\t\t\t\tpodReady = c\n\t\t\t} else if c.Type == corev1.ContainersReady {\n\t\t\t\tcontainersReady = c\n\t\t\t}\n\t\t}\n\t\tassert.NotNil(t, podReady)\n\t\tassert.Equal(t, corev1.ConditionFalse, podReady.Status)\n\t\tassert.NotNil(t, containersReady)\n\t\tassert.Equal(t, corev1.ConditionFalse, containersReady.Status)\n\t\tassert.Equal(t, now.Unix(), podReady.LastTransitionTime.Unix())\n\t})\n\n\tt.Run(\"Pod Task - Fully Ready\", func(t *testing.T) {\n\t\tlater := now.Add(time.Minute)\n\t\ttask := &types.Task{\n\t\t\tName:            \"pod-task-ready\",\n\t\t\tPodTemplateSpec: &corev1.PodTemplateSpec{},\n\t\t\tStatus: types.Status{\n\t\t\t\tState: types.TaskStateRunning,\n\t\t\t\tSubStatuses: []types.SubStatus{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:      \"c1\",\n\t\t\t\t\t\tStartedAt: &now,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:      \"c2\",\n\t\t\t\t\t\tStartedAt: &later,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tapiTask := convertInternalToAPITask(task)\n\t\tassert.NotNil(t, apiTask.PodStatus)\n\n\t\t// Conditions check\n\t\tvar podReady, containersReady *corev1.PodCondition\n\t\tfor i := range apiTask.PodStatus.Conditions {\n\t\t\tc := &apiTask.PodStatus.Conditions[i]\n\t\t\tif c.Type == corev1.PodReady {\n\t\t\t\tpodReady = c\n\t\t\t} else if c.Type == corev1.ContainersReady {\n\t\t\t\tcontainersReady = c\n\t\t\t}\n\t\t}\n\t\tassert.NotNil(t, podReady)\n\t\tassert.Equal(t, corev1.ConditionTrue, podReady.Status)\n\t\tassert.NotNil(t, containersReady)\n\t\tassert.Equal(t, corev1.ConditionTrue, containersReady.Status)\n\t\t// Should use the latest timestamp (later)\n\t\tassert.Equal(t, later.Unix(), podReady.LastTransitionTime.Unix())\n\t\tassert.True(t, utils.IsPodReadyConditionTrue(*apiTask.PodStatus))\n\t})\n}\n\nfunc TestConvertInternalToAPITask_Timeout(t *testing.T) {\n\tnow := time.Now()\n\ttimeoutSec := int64(60)\n\n\tt.Run(\"Process Task Timeout\", func(t *testing.T) {\n\t\ttask := &types.Task{\n\t\t\tName: \"timeout-task\",\n\t\t\tProcess: &api.Process{\n\t\t\t\tCommand:        []string{\"sleep\", \"100\"},\n\t\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t\t},\n\t\t\tStatus: types.Status{\n\t\t\t\tState: types.TaskStateTimeout,\n\t\t\t\tSubStatuses: []types.SubStatus{\n\t\t\t\t\t{\n\t\t\t\t\t\tReason:     \"TaskTimeout\",\n\t\t\t\t\t\tMessage:    \"Task exceeded timeout of 60 seconds\",\n\t\t\t\t\t\tStartedAt:  &now,\n\t\t\t\t\t\tFinishedAt: nil, // Not finished yet\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tapiTask := convertInternalToAPITask(task)\n\n\t\t// Should map to Terminated with exit code 137\n\t\tassert.NotNil(t, apiTask.ProcessStatus)\n\t\tassert.NotNil(t, apiTask.ProcessStatus.Terminated)\n\t\tassert.Nil(t, apiTask.ProcessStatus.Running)\n\t\tassert.Nil(t, apiTask.ProcessStatus.Waiting)\n\t\tassert.Equal(t, int32(137), apiTask.ProcessStatus.Terminated.ExitCode)\n\t\tassert.Equal(t, \"TaskTimeout\", apiTask.ProcessStatus.Terminated.Reason)\n\t\tassert.Equal(t, \"Task exceeded timeout of 60 seconds\", apiTask.ProcessStatus.Terminated.Message)\n\t\tassert.Equal(t, now.Unix(), apiTask.ProcessStatus.Terminated.StartedAt.Unix())\n\t\t// FinishedAt should be set to \"now\" for timeout\n\t\tassert.False(t, apiTask.ProcessStatus.Terminated.FinishedAt.IsZero())\n\t\tassert.Nil(t, apiTask.PodStatus)\n\t})\n\n\tt.Run(\"Timeout After Completion\", func(t *testing.T) {\n\t\tlater := now.Add(2 * time.Minute)\n\t\ttask := &types.Task{\n\t\t\tName:    \"completed-task\",\n\t\t\tProcess: &api.Process{Command: []string{\"ls\"}},\n\t\t\tStatus: types.Status{\n\t\t\t\tState: types.TaskStateFailed, // After stop, it becomes Failed\n\t\t\t\tSubStatuses: []types.SubStatus{\n\t\t\t\t\t{\n\t\t\t\t\t\tExitCode:   137,\n\t\t\t\t\t\tReason:     \"Killed\",\n\t\t\t\t\t\tStartedAt:  &now,\n\t\t\t\t\t\tFinishedAt: &later,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tapiTask := convertInternalToAPITask(task)\n\n\t\t// Should be Terminated with actual exit code\n\t\tassert.NotNil(t, apiTask.ProcessStatus.Terminated)\n\t\tassert.Equal(t, int32(137), apiTask.ProcessStatus.Terminated.ExitCode)\n\t\tassert.Equal(t, now.Unix(), apiTask.ProcessStatus.Terminated.StartedAt.Unix())\n\t\tassert.Equal(t, later.Unix(), apiTask.ProcessStatus.Terminated.FinishedAt.Unix())\n\t})\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/server/router.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage server\n\nimport (\n\t\"net/http\"\n)\n\nfunc NewRouter(h *Handler) http.Handler {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"POST /setTasks\", h.SyncTasks)\n\tmux.HandleFunc(\"GET /getTasks\", h.ListTasks)\n\tmux.HandleFunc(\"POST /tasks\", h.CreateTask)\n\tmux.HandleFunc(\"GET /tasks/{id}\", h.GetTask)\n\tmux.HandleFunc(\"DELETE /tasks/{id}\", h.DeleteTask)\n\tmux.HandleFunc(\"GET /health\", h.Health)\n\n\treturn mux\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/storage/file_store.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/utils\"\n)\n\ntype fileStore struct {\n\tdataDir string\n\tlocks   sync.Map // key: taskName, value: *sync.RWMutex\n}\n\nfunc NewFileStore(dataDir string) (TaskStore, error) {\n\tif dataDir == \"\" {\n\t\treturn nil, fmt.Errorf(\"dataDir cannot be empty\")\n\t}\n\n\tif err := os.MkdirAll(dataDir, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create data directory %s: %w\", dataDir, err)\n\t}\n\n\ttestFile := filepath.Join(dataDir, \".test\")\n\tif err := os.WriteFile(testFile, []byte(\"test\"), 0644); err != nil {\n\t\treturn nil, fmt.Errorf(\"data directory %s is not writable: %w\", dataDir, err)\n\t}\n\tos.Remove(testFile)\n\n\tklog.InfoS(\"initialized file store\", \"dataDir\", dataDir)\n\n\treturn &fileStore{\n\t\tdataDir: dataDir,\n\t}, nil\n}\n\nfunc (s *fileStore) getTaskLock(name string) *sync.RWMutex {\n\tval, _ := s.locks.LoadOrStore(name, &sync.RWMutex{})\n\treturn val.(*sync.RWMutex)\n}\n\nfunc (s *fileStore) Create(ctx context.Context, task *types.Task) error {\n\tif task == nil {\n\t\treturn fmt.Errorf(\"task cannot be nil\")\n\t}\n\tif task.Name == \"\" {\n\t\treturn fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tmu := s.getTaskLock(task.Name)\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\ttaskDir, err := utils.SafeJoin(s.dataDir, task.Name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\n\tif _, err := os.Stat(taskDir); err == nil {\n\t\treturn fmt.Errorf(\"task %s already exists\", task.Name)\n\t}\n\n\tif err := os.MkdirAll(taskDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"failed to create task directory: %w\", err)\n\t}\n\n\tif err := s.writeTaskFile(taskDir, task); err != nil {\n\t\tos.RemoveAll(taskDir)\n\t\treturn err\n\t}\n\n\tklog.InfoS(\"created task\", \"name\", task.Name, \"dir\", taskDir)\n\treturn nil\n}\n\nfunc (s *fileStore) Update(ctx context.Context, task *types.Task) error {\n\tif task == nil {\n\t\treturn fmt.Errorf(\"task cannot be nil\")\n\t}\n\tif task.Name == \"\" {\n\t\treturn fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tmu := s.getTaskLock(task.Name)\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\ttaskDir, err := utils.SafeJoin(s.dataDir, task.Name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\n\t// Check if task exists\n\tif _, err := os.Stat(taskDir); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"task %s does not exist\", task.Name)\n\t}\n\n\tif err := s.writeTaskFile(taskDir, task); err != nil {\n\t\treturn err\n\t}\n\n\tklog.V(2).InfoS(\"updated task\", \"name\", task.Name, \"state\", task.Status.State)\n\treturn nil\n}\n\nfunc (s *fileStore) Get(ctx context.Context, name string) (*types.Task, error) {\n\tif name == \"\" {\n\t\treturn nil, fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tmu := s.getTaskLock(name)\n\tmu.RLock()\n\tdefer mu.RUnlock()\n\n\ttaskDir, err := utils.SafeJoin(s.dataDir, name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\n\t// Check if task exists\n\tif _, err := os.Stat(taskDir); os.IsNotExist(err) {\n\t\treturn nil, fmt.Errorf(\"task %s not found\", name)\n\t}\n\n\treturn s.readTaskFile(taskDir, name)\n}\n\nfunc (s *fileStore) List(ctx context.Context) ([]*types.Task, error) {\n\tentries, err := os.ReadDir(s.dataDir)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read data directory: %w\", err)\n\t}\n\n\ttasks := make([]*types.Task, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\ttaskName := entry.Name()\n\t\ttaskDir, err := utils.SafeJoin(s.dataDir, taskName)\n\t\tif err != nil {\n\t\t\tklog.ErrorS(err, \"invalid task directory, skipping\", \"name\", taskName)\n\t\t\tcontinue\n\t\t}\n\n\t\tmu := s.getTaskLock(taskName)\n\t\tmu.RLock()\n\t\ttask, err := s.readTaskFile(taskDir, taskName)\n\t\tmu.RUnlock()\n\n\t\tif err != nil {\n\t\t\tklog.ErrorS(err, \"failed to read task, skipping\", \"name\", taskName)\n\t\t\tcontinue\n\t\t}\n\n\t\ttasks = append(tasks, task)\n\t}\n\n\treturn tasks, nil\n}\n\nfunc (s *fileStore) Delete(ctx context.Context, name string) error {\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"task name cannot be empty\")\n\t}\n\n\tmu := s.getTaskLock(name)\n\tmu.Lock()\n\tdefer mu.Unlock()\n\n\ttaskDir, err := utils.SafeJoin(s.dataDir, name)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"invalid task name: %w\", err)\n\t}\n\n\t// Check if task exists\n\tif _, err := os.Stat(taskDir); os.IsNotExist(err) {\n\t\tklog.InfoS(\"task already deleted\", \"name\", name)\n\t\treturn nil\n\t}\n\n\tif err := os.RemoveAll(taskDir); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete task %s: %w\", name, err)\n\t}\n\n\tklog.InfoS(\"deleted task\", \"name\", name)\n\treturn nil\n}\n\nfunc (s *fileStore) getTaskFilePath(taskDir string) string {\n\treturn filepath.Join(taskDir, \"task.json\")\n}\n\n// writeTaskFile writes task data to disk atomically\nfunc (s *fileStore) writeTaskFile(taskDir string, task *types.Task) error {\n\tdata, err := json.MarshalIndent(task, \"\", \"  \")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to marshal task: %w\", err)\n\t}\n\n\ttaskFile := s.getTaskFilePath(taskDir)\n\ttmpFile := taskFile + \".tmp\"\n\n\tif err := os.WriteFile(tmpFile, data, 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write temp file: %w\", err)\n\t}\n\n\tf, err := os.Open(tmpFile)\n\tif err != nil {\n\t\tos.Remove(tmpFile)\n\t\treturn fmt.Errorf(\"failed to open temp file for sync: %w\", err)\n\t}\n\tif err := f.Sync(); err != nil {\n\t\tf.Close()\n\t\tos.Remove(tmpFile)\n\t\treturn fmt.Errorf(\"failed to sync temp file: %w\", err)\n\t}\n\tf.Close()\n\n\tif err := os.Rename(tmpFile, taskFile); err != nil {\n\t\tos.Remove(tmpFile)\n\t\treturn fmt.Errorf(\"failed to rename temp file: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *fileStore) readTaskFile(taskDir, taskName string) (*types.Task, error) {\n\ttaskFile := s.getTaskFilePath(taskDir)\n\n\tdata, err := os.ReadFile(taskFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read task file: %w\", err)\n\t}\n\n\tvar task types.Task\n\tif err := json.Unmarshal(data, &task); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal task file: %w\", err)\n\t}\n\n\treturn &task, nil\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/storage/file_store_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nfunc TestNewFileStore(t *testing.T) {\n\t// Test case 1: Valid directory\n\ttmpDir := t.TempDir()\n\tstore, err := NewFileStore(tmpDir)\n\tif err != nil {\n\t\tt.Fatalf(\"NewFileStore failed: %v\", err)\n\t}\n\tif store == nil {\n\t\tt.Fatal(\"NewFileStore returned nil store\")\n\t}\n\n\t// Test case 2: Empty directory\n\t_, err = NewFileStore(\"\")\n\tif err == nil {\n\t\tt.Fatal(\"NewFileStore should fail with empty dir\")\n\t}\n}\n\nfunc TestFileStore_CRUD(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tstore, err := NewFileStore(tmpDir)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create store: %v\", err)\n\t}\n\n\tctx := context.Background()\n\ttask := &types.Task{\n\t\tName: \"test-task\",\n\t\tProcess: &api.Process{\n\t\t\tCommand: []string{\"echo\", \"hello\"},\n\t\t},\n\t}\n\n\t// 1. Create\n\tif err := store.Create(ctx, task); err != nil {\n\t\tt.Fatalf(\"Create failed: %v\", err)\n\t}\n\n\t// Verify file exists\n\ttaskDir := filepath.Join(tmpDir, task.Name)\n\tif _, err := os.Stat(taskDir); os.IsNotExist(err) {\n\t\tt.Error(\"Task directory was not created\")\n\t}\n\n\t// 2. Get\n\tgot, err := store.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get failed: %v\", err)\n\t}\n\tif got.Name != task.Name {\n\t\tt.Errorf(\"Get returned wrong name: got %s, want %s\", got.Name, task.Name)\n\t}\n\n\t// 3. Update\n\tnow := time.Now()\n\tgot.DeletionTimestamp = &now\n\n\tif err := store.Update(ctx, got); err != nil {\n\t\tt.Fatalf(\"Update failed: %v\", err)\n\t}\n\n\tupdated, err := store.Get(ctx, task.Name)\n\tif err != nil {\n\t\tt.Fatalf(\"Get after update failed: %v\", err)\n\t}\n\tif updated.DeletionTimestamp == nil {\n\t\tt.Error(\"Update failed to persist DeletionTimestamp\")\n\t}\n\n\t// 4. List\n\ttasks, err := store.List(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"List failed: %v\", err)\n\t}\n\tif len(tasks) != 1 {\n\t\tt.Errorf(\"List returned %d tasks, want 1\", len(tasks))\n\t}\n\tif tasks[0].Name != task.Name {\n\t\tt.Errorf(\"List returned wrong task: %s\", tasks[0].Name)\n\t}\n\n\t// 5. Delete\n\tif err := store.Delete(ctx, task.Name); err != nil {\n\t\tt.Fatalf(\"Delete failed: %v\", err)\n\t}\n\n\t// Verify deletion\n\tif _, err := store.Get(ctx, task.Name); err == nil {\n\t\tt.Error(\"Get should fail after delete\")\n\t}\n\n\ttasks, err = store.List(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"List failed: %v\", err)\n\t}\n\tif len(tasks) != 0 {\n\t\tt.Errorf(\"List returned %d tasks after delete, want 0\", len(tasks))\n\t}\n\n\t// Verify directory gone\n\tif _, err := os.Stat(taskDir); !os.IsNotExist(err) {\n\t\tt.Error(\"Task directory still exists after delete\")\n\t}\n}\n\nfunc TestFileStore_EdgeCases(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tstore, _ := NewFileStore(tmpDir)\n\tctx := context.Background()\n\n\t// Create with nil task\n\tif err := store.Create(ctx, nil); err == nil {\n\t\tt.Error(\"Create should fail with nil task\")\n\t}\n\n\t// Create with empty name\n\tif err := store.Create(ctx, &types.Task{}); err == nil {\n\t\tt.Error(\"Create should fail with empty name\")\n\t}\n\n\t// Create duplicate\n\ttask := &types.Task{Name: \"dup\"}\n\tstore.Create(ctx, task)\n\tif err := store.Create(ctx, task); err == nil {\n\t\tt.Error(\"Create should fail for duplicate task\")\n\t}\n\n\t// Update non-existent\n\tif err := store.Update(ctx, &types.Task{Name: \"missing\"}); err == nil {\n\t\tt.Error(\"Update should fail for non-existent task\")\n\t}\n\n\t// Get non-existent\n\tif _, err := store.Get(ctx, \"missing\"); err == nil {\n\t\tt.Error(\"Get should fail for non-existent task\")\n\t}\n\n\t// Delete non-existent\n\tif err := store.Delete(ctx, \"missing\"); err != nil {\n\t\tt.Errorf(\"Delete should not fail for non-existent task, got %v\", err)\n\t}\n}\n\nfunc TestFileStore_CorruptedData(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tstore, _ := NewFileStore(tmpDir)\n\tctx := context.Background()\n\n\t// Manually create a corrupted task file\n\ttaskDir := filepath.Join(tmpDir, \"corrupted\")\n\tos.MkdirAll(taskDir, 0755)\n\tos.WriteFile(filepath.Join(taskDir, \"task.json\"), []byte(\"{invalid-json\"), 0644)\n\n\t// List should skip corrupted task\n\ttasks, err := store.List(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"List failed: %v\", err)\n\t}\n\tif len(tasks) != 0 {\n\t\tt.Errorf(\"List should skip corrupted task, got %d\", len(tasks))\n\t}\n\n\t// Get should fail for corrupted task\n\tif _, err := store.Get(ctx, \"corrupted\"); err == nil {\n\t\tt.Error(\"Get should fail for corrupted task\")\n\t}\n}\n\n// TestConcurrency verifies thread safety\nfunc TestFileStore_Concurrency(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tstore, _ := NewFileStore(tmpDir)\n\tctx := context.Background()\n\ttaskName := \"concurrent-task\"\n\n\tstore.Create(ctx, &types.Task{Name: taskName})\n\n\tdone := make(chan bool)\n\tfor i := 0; i < 10; i++ {\n\t\tgo func(id int) {\n\t\t\tstore.Update(ctx, &types.Task{\n\t\t\t\tName: taskName,\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tArgs: []string{time.Now().String()},\n\t\t\t\t},\n\t\t\t})\n\t\t\tstore.Get(ctx, taskName)\n\t\t\tdone <- true\n\t\t}(i)\n\t}\n\n\tfor i := 0; i < 10; i++ {\n\t\t<-done\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/storage/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage store\n\nimport (\n\t\"context\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/task-executor/types\"\n)\n\n// TaskStore defines the contract for persisting task state.\ntype TaskStore interface {\n\tCreate(ctx context.Context, task *types.Task) error\n\n\tUpdate(ctx context.Context, task *types.Task) error\n\n\tGet(ctx context.Context, name string) (*types.Task, error)\n\n\tList(ctx context.Context) ([]*types.Task, error)\n\n\tDelete(ctx context.Context, name string) error\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/types/task.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage types\n\nimport (\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\n// TaskState defines the simplified internal state of a task.\ntype TaskState string\n\nconst (\n\tTaskStatePending   TaskState = \"Pending\"\n\tTaskStateRunning   TaskState = \"Running\"\n\tTaskStateSucceeded TaskState = \"Succeeded\"\n\tTaskStateFailed    TaskState = \"Failed\"\n\tTaskStateUnknown   TaskState = \"Unknown\"\n\tTaskStateNotFound  TaskState = \"NotFound\"\n\tTaskStateTimeout   TaskState = \"Timeout\"\n)\n\n// Status represents the internal status of a task.\n// This is decoupled from the Kubernetes API status.\ntype Status struct {\n\tState       TaskState   `json:\"state\"`\n\tSubStatuses []SubStatus `json:\"subStatuses,omitempty\"`\n}\n\ntype SubStatus struct {\n\tName       string     `json:\"name,omitempty\"` // for process it's empty, for PodTemplateSpec is container name\n\tReason     string     `json:\"reason,omitempty\"`\n\tMessage    string     `json:\"message,omitempty\"`\n\tExitCode   int        `json:\"exitCode,omitempty\"`\n\tStartedAt  *time.Time `json:\"startedAt,omitempty\"`\n\tFinishedAt *time.Time `json:\"finishedAt,omitempty\"`\n}\n\ntype Task struct {\n\tName              string     `json:\"name\"`\n\tDeletionTimestamp *time.Time `json:\"deletionTimestamp,omitempty\"`\n\n\tProcess         *api.Process            `json:\"process\"`\n\tPodTemplateSpec *corev1.PodTemplateSpec `json:\"podTemplateSpec\"`\n\n\t// Status is now a first-class citizen and persisted.\n\tStatus Status `json:\"status\"`\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/utils/pathutil.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//\thttp://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc SafeJoin(baseDir, userPath string) (string, error) {\n\tjoinedPath := filepath.Join(baseDir, userPath)\n\n\tabsBaseDir, err := filepath.Abs(baseDir)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve base directory absolute path: %w\", err)\n\t}\n\tabsJoinedPath, err := filepath.Abs(joinedPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve joined path absolute path: %w\", err)\n\t}\n\n\tif !isSubPath(absBaseDir, absJoinedPath) {\n\t\treturn \"\", fmt.Errorf(\"path traversal detected\")\n\t}\n\n\treturn absJoinedPath, nil\n}\n\nfunc isSubPath(parent, child string) bool {\n\tif len(parent) == 0 {\n\t\treturn false\n\t}\n\n\tparentWithSep := parent\n\tif !os.IsPathSeparator(parent[len(parent)-1]) {\n\t\tparentWithSep = parent + string(filepath.Separator)\n\t}\n\n\treturn child == parent || (len(child) > len(parentWithSep) && child[:len(parentWithSep)] == parentWithSep)\n}\n"
  },
  {
    "path": "kubernetes/internal/task-executor/utils/pathutil_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestSafeJoin(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"safejoin-test\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create temp dir: %v\", err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttests := []struct {\n\t\tname     string\n\t\tbaseDir  string\n\t\tuserPath string\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid path\",\n\t\t\tbaseDir:  tempDir,\n\t\t\tuserPath: \"foo\",\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid nested path\",\n\t\t\tbaseDir:  tempDir,\n\t\t\tuserPath: \"foo/bar\",\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"path traversal attempt\",\n\t\t\tbaseDir:  tempDir,\n\t\t\tuserPath: \"../foo\",\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"path traversal to root (treated as relative)\",\n\t\t\tbaseDir:  tempDir,\n\t\t\tuserPath: \"/etc/passwd\",\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"complex traversal\",\n\t\t\tbaseDir:  tempDir,\n\t\t\tuserPath: \"foo/../../bar\",\n\t\t\twantErr:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := SafeJoin(tt.baseDir, tt.userPath)\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"SafeJoin() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !tt.wantErr {\n\t\t\t\texpected := filepath.Join(tt.baseDir, tt.userPath)\n\t\t\t\tabsExpected, _ := filepath.Abs(expected)\n\t\t\t\tif got != absExpected {\n\t\t\t\t\tt.Errorf(\"SafeJoin() = %v, want %v\", got, absExpected)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/controller/util.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage controller\n\nimport (\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/types\"\n)\n\n// GetControllerKey return key of CloneSet.\nfunc GetControllerKey(obj metav1.Object) string {\n\treturn types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}.String()\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/expectations/init.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage expectations\n\nimport (\n\t\"flag\"\n\t\"time\"\n)\n\nfunc init() {\n\tflag.DurationVar(&ExpectationTimeout, \"expectation-timeout\", time.Minute*5, \"The expectation timeout. Defaults 5min\")\n}\n\nvar ExpectationTimeout time.Duration\n"
  },
  {
    "path": "kubernetes/internal/utils/expectations/resource_version_expectation.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage expectations\n\nimport (\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/types\"\n)\n\ntype ResourceVersionExpectation interface {\n\tExpect(obj metav1.Object)\n\tObserve(obj metav1.Object)\n\tIsSatisfied(obj metav1.Object) (bool, time.Duration)\n\tDelete(obj metav1.Object)\n}\n\nfunc NewResourceVersionExpectation() ResourceVersionExpectation {\n\treturn &realResourceVersionExpectation{objectVersions: make(map[types.UID]*objectCacheVersions, 100)}\n}\n\ntype realResourceVersionExpectation struct {\n\tsync.Mutex\n\tobjectVersions map[types.UID]*objectCacheVersions\n}\n\ntype objectCacheVersions struct {\n\tversion                   string\n\tfirstUnsatisfiedTimestamp time.Time\n}\n\nfunc (r *realResourceVersionExpectation) Expect(obj metav1.Object) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.objectVersions[obj.GetUID()]\n\tif expectations == nil {\n\t\tr.objectVersions[obj.GetUID()] = &objectCacheVersions{}\n\t}\n\tif isResourceVersionNewer(r.objectVersions[obj.GetUID()].version, obj.GetResourceVersion()) {\n\t\tr.objectVersions[obj.GetUID()].version = obj.GetResourceVersion()\n\t}\n}\n\nfunc (r *realResourceVersionExpectation) Observe(obj metav1.Object) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.objectVersions[obj.GetUID()]\n\tif expectations == nil {\n\t\treturn\n\t}\n\tif isResourceVersionNewer(r.objectVersions[obj.GetUID()].version, obj.GetResourceVersion()) {\n\t\tdelete(r.objectVersions, obj.GetUID())\n\t}\n}\n\nfunc (r *realResourceVersionExpectation) IsSatisfied(obj metav1.Object) (bool, time.Duration) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.objectVersions[obj.GetUID()]\n\tif expectations == nil {\n\t\treturn true, 0\n\t}\n\n\tif isResourceVersionNewer(r.objectVersions[obj.GetUID()].version, obj.GetResourceVersion()) {\n\t\tdelete(r.objectVersions, obj.GetUID())\n\t}\n\t_, existing := r.objectVersions[obj.GetUID()]\n\tif existing {\n\t\tif r.objectVersions[obj.GetUID()].firstUnsatisfiedTimestamp.IsZero() {\n\t\t\tr.objectVersions[obj.GetUID()].firstUnsatisfiedTimestamp = time.Now()\n\t\t}\n\n\t\treturn false, time.Since(r.objectVersions[obj.GetUID()].firstUnsatisfiedTimestamp)\n\t}\n\n\treturn !existing, 0\n}\n\nfunc (r *realResourceVersionExpectation) Delete(obj metav1.Object) {\n\tr.Lock()\n\tdefer r.Unlock()\n\tdelete(r.objectVersions, obj.GetUID())\n}\n\nfunc isResourceVersionNewer(old, new string) bool {\n\tif len(old) == 0 {\n\t\treturn true\n\t}\n\n\toldCount, err := strconv.ParseUint(old, 10, 64)\n\tif err != nil {\n\t\treturn true\n\t}\n\n\tnewCount, err := strconv.ParseUint(new, 10, 64)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\treturn newCount >= oldCount\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/expectations/resource_version_expectation_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage expectations\n\nimport (\n\t\"testing\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\nfunc TestResourceVersionExpectation(t *testing.T) {\n\tcases := []struct {\n\t\texpect      *v1.Pod\n\t\tobserve     *v1.Pod\n\t\tisSatisfied *v1.Pod\n\t\tresult      bool\n\t}{\n\t\t{\n\t\t\texpect:      &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tobserve:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"1\"}},\n\t\t\tisSatisfied: &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"1\"}},\n\t\t\tresult:      false,\n\t\t},\n\t\t{\n\t\t\texpect:      &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tobserve:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tisSatisfied: &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tresult:      true,\n\t\t},\n\t\t{\n\t\t\texpect:      &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tobserve:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"1\"}},\n\t\t\tisSatisfied: &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tresult:      true,\n\t\t},\n\t\t{\n\t\t\texpect:      &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tobserve:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"2\"}},\n\t\t\tisSatisfied: &v1.Pod{ObjectMeta: metav1.ObjectMeta{ResourceVersion: \"3\"}},\n\t\t\tresult:      true,\n\t\t},\n\t}\n\n\tfor i, testCase := range cases {\n\t\tc := NewResourceVersionExpectation()\n\t\tc.Expect(testCase.expect)\n\t\tc.Observe(testCase.observe)\n\t\tgot, _ := c.IsSatisfied(testCase.isSatisfied)\n\t\tif got != testCase.result {\n\t\t\tt.Fatalf(\"#%d expected %v, got %v\", i, testCase.result, got)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/expectations/scale_expectations.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage expectations\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n)\n\n// ScaleAction is the action of scale, like create and delete.\ntype ScaleAction string\n\nconst (\n\t// Create action\n\tCreate ScaleAction = \"create\"\n\t// Delete action\n\tDelete ScaleAction = \"delete\"\n)\n\n// ScaleExpectations is an interface that allows users to set and wait on expectations of pods scale.\ntype ScaleExpectations interface {\n\tExpectScale(controllerKey string, action ScaleAction, name string)\n\tObserveScale(controllerKey string, action ScaleAction, name string)\n\tSatisfiedExpectations(controllerKey string) (bool, time.Duration, map[ScaleAction][]string)\n\tDeleteExpectations(controllerKey string)\n\tGetExpectations(controllerKey string) map[ScaleAction]sets.String\n}\n\n// NewScaleExpectations returns a common ScaleExpectations.\nfunc NewScaleExpectations() ScaleExpectations {\n\treturn &realScaleExpectations{\n\t\tcontrollerCache: make(map[string]*realControllerScaleExpectations),\n\t}\n}\n\ntype realScaleExpectations struct {\n\tsync.Mutex\n\t// key: parent key, workload namespace/name\n\tcontrollerCache map[string]*realControllerScaleExpectations\n}\n\ntype realControllerScaleExpectations struct {\n\t// item: name for this object\n\tobjsCache                 map[ScaleAction]sets.String\n\tfirstUnsatisfiedTimestamp time.Time\n}\n\nfunc (r *realScaleExpectations) GetExpectations(controllerKey string) map[ScaleAction]sets.String {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.controllerCache[controllerKey]\n\tif expectations == nil {\n\t\treturn nil\n\t}\n\n\tres := make(map[ScaleAction]sets.String, len(expectations.objsCache))\n\tfor k, v := range expectations.objsCache {\n\t\tres[k] = sets.NewString(v.List()...)\n\t}\n\n\treturn res\n}\n\nfunc (r *realScaleExpectations) ExpectScale(controllerKey string, action ScaleAction, name string) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.controllerCache[controllerKey]\n\tif expectations == nil {\n\t\texpectations = &realControllerScaleExpectations{\n\t\t\tobjsCache: make(map[ScaleAction]sets.String),\n\t\t}\n\t\tr.controllerCache[controllerKey] = expectations\n\t}\n\n\tif s := expectations.objsCache[action]; s != nil {\n\t\ts.Insert(name)\n\t} else {\n\t\texpectations.objsCache[action] = sets.NewString(name)\n\t}\n}\n\nfunc (r *realScaleExpectations) ObserveScale(controllerKey string, action ScaleAction, name string) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.controllerCache[controllerKey]\n\tif expectations == nil {\n\t\treturn\n\t}\n\n\ts := expectations.objsCache[action]\n\tif s == nil {\n\t\treturn\n\t}\n\ts.Delete(name)\n\n\tfor _, s := range expectations.objsCache {\n\t\tif s.Len() > 0 {\n\t\t\treturn\n\t\t}\n\t}\n\tdelete(r.controllerCache, controllerKey)\n}\n\nfunc (r *realScaleExpectations) SatisfiedExpectations(controllerKey string) (bool, time.Duration, map[ScaleAction][]string) {\n\tr.Lock()\n\tdefer r.Unlock()\n\n\texpectations := r.controllerCache[controllerKey]\n\tif expectations == nil {\n\t\treturn true, 0, nil\n\t}\n\n\tfor a, s := range expectations.objsCache {\n\t\tif s.Len() > 0 {\n\t\t\tif expectations.firstUnsatisfiedTimestamp.IsZero() {\n\t\t\t\texpectations.firstUnsatisfiedTimestamp = time.Now()\n\t\t\t}\n\t\t\treturn false, time.Since(expectations.firstUnsatisfiedTimestamp), map[ScaleAction][]string{a: s.List()}\n\t\t}\n\t}\n\n\tdelete(r.controllerCache, controllerKey)\n\treturn true, 0, nil\n}\n\nfunc (r *realScaleExpectations) DeleteExpectations(controllerKey string) {\n\tr.Lock()\n\tdefer r.Unlock()\n\tdelete(r.controllerCache, controllerKey)\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/expectations/scale_expectations_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage expectations\n\nimport (\n\t\"testing\"\n)\n\nfunc TestScale(t *testing.T) {\n\te := NewScaleExpectations()\n\tcontrollerKey01 := \"default/cs01\"\n\tcontrollerKey02 := \"default/cs02\"\n\tpod01 := \"pod01\"\n\tpod02 := \"pod02\"\n\n\te.ExpectScale(controllerKey01, Create, pod01)\n\te.ExpectScale(controllerKey01, Create, pod02)\n\te.ExpectScale(controllerKey01, Delete, pod01)\n\tif ok, _, _ := e.SatisfiedExpectations(controllerKey01); ok {\n\t\tt.Fatalf(\"expected not satisfied\")\n\t}\n\n\te.ObserveScale(controllerKey01, Create, pod02)\n\te.ObserveScale(controllerKey01, Create, pod01)\n\tif ok, _, _ := e.SatisfiedExpectations(controllerKey01); ok {\n\t\tt.Fatalf(\"expected not satisfied\")\n\t}\n\n\te.ObserveScale(controllerKey02, Delete, pod01)\n\tif ok, _, _ := e.SatisfiedExpectations(controllerKey01); ok {\n\t\tt.Fatalf(\"expected not satisfied\")\n\t}\n\n\te.ObserveScale(controllerKey01, Delete, pod01)\n\tif ok, _, _ := e.SatisfiedExpectations(controllerKey01); !ok {\n\t\tt.Fatalf(\"expected satisfied\")\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/fieldindex/register.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage fieldindex\n\nimport (\n\t\"context\"\n\t\"sync\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\t\"sigs.k8s.io/controller-runtime/pkg/cache\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\nconst (\n\tIndexNameForOwnerRefUID = \"ownerRefUID\"\n\tIndexNameForPoolRef     = \"poolRef\"\n)\n\nvar (\n\tregisterOnce sync.Once\n)\n\nvar OwnerIndexFunc = func(obj client.Object) []string {\n\tvar owners []string\n\tfor _, ref := range obj.GetOwnerReferences() {\n\t\towners = append(owners, string(ref.UID))\n\t}\n\treturn owners\n}\n\nvar PoolRefIndexFunc = func(obj client.Object) []string {\n\tbatchSandbox, ok := obj.(*sandboxv1alpha1.BatchSandbox)\n\tif ok {\n\t\treturn []string{batchSandbox.Spec.PoolRef}\n\t}\n\treturn nil\n}\n\nfunc RegisterFieldIndexes(c cache.Cache) error {\n\tvar err error\n\tregisterOnce.Do(func() {\n\t\t// pod ownerReference\n\t\tif err = c.IndexField(context.TODO(), &v1.Pod{}, IndexNameForOwnerRefUID, OwnerIndexFunc); err != nil {\n\t\t\treturn\n\t\t}\n\t\tif err = c.IndexField(context.TODO(), &sandboxv1alpha1.BatchSandbox{}, IndexNameForPoolRef, PoolRefIndexFunc); err != nil {\n\t\t\treturn\n\t\t}\n\t})\n\treturn err\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/finalizer.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/client-go/util/retry\"\n\t\"sigs.k8s.io/controller-runtime/pkg/client\"\n\t\"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil\"\n)\n\ntype FinalizerOpType string\n\nconst (\n\tAddFinalizerOpType    FinalizerOpType = \"Add\"\n\tRemoveFinalizerOpType FinalizerOpType = \"Remove\"\n)\n\nfunc UpdateFinalizer(c client.Client, object client.Object, op FinalizerOpType, finalizer string) error {\n\tswitch op {\n\tcase AddFinalizerOpType, RemoveFinalizerOpType:\n\tdefault:\n\t\treturn errors.New(\"UpdateFinalizer Func 'op' parameter must be 'Add' or 'Remove'\")\n\t}\n\n\tkey := client.ObjectKeyFromObject(object)\n\treturn retry.RetryOnConflict(retry.DefaultRetry, func() error {\n\t\tfetchedObject := object.DeepCopyObject().(client.Object)\n\t\tgetErr := c.Get(context.TODO(), key, fetchedObject)\n\t\tif getErr != nil {\n\t\t\treturn getErr\n\t\t}\n\t\tfinalizers := fetchedObject.GetFinalizers()\n\t\tswitch op {\n\t\tcase AddFinalizerOpType:\n\t\t\tif controllerutil.ContainsFinalizer(fetchedObject, finalizer) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tfinalizers = append(finalizers, finalizer)\n\t\tcase RemoveFinalizerOpType:\n\t\t\tfinalizerSet := sets.NewString(finalizers...)\n\t\t\tif !finalizerSet.Has(finalizer) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tfinalizers = finalizerSet.Delete(finalizer).List()\n\t\t}\n\t\tfetchedObject.SetFinalizers(finalizers)\n\t\treturn c.Update(context.TODO(), fetchedObject)\n\t})\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/helper.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// GetAnnotation from metaObject annotations\nfunc GetAnnotation(obj metav1.Object, key string) string {\n\tif obj == nil {\n\t\treturn \"\"\n\t}\n\tannotations := obj.GetAnnotations()\n\tif annotations == nil {\n\t\treturn \"\"\n\t}\n\treturn annotations[key]\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/json.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n)\n\n// DumpJSON returns the JSON encoding\nfunc DumpJSON(o interface{}) string {\n\tj, _ := json.Marshal(o)\n\treturn string(j)\n}\n\n// IsJSONObjectEqual checks if two objects are equal after encoding json\nfunc IsJSONObjectEqual(o1, o2 interface{}) bool {\n\tif reflect.DeepEqual(o1, o2) {\n\t\treturn true\n\t}\n\n\toj1, _ := json.Marshal(o1)\n\toj2, _ := json.Marshal(o2)\n\tos1 := string(oj1)\n\tos2 := string(oj2)\n\tif os1 == os2 {\n\t\treturn true\n\t}\n\n\tom1 := make(map[string]interface{})\n\tom2 := make(map[string]interface{})\n\t_ = json.Unmarshal(oj1, &om1)\n\t_ = json.Unmarshal(oj2, &om2)\n\n\treturn reflect.DeepEqual(om1, om2)\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/logging/logger.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage logging\n\nimport (\n\t\"os\"\n\n\t\"github.com/go-logr/logr\"\n\tzap2 \"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n\t\"sigs.k8s.io/controller-runtime/pkg/log/zap\"\n)\n\n// Options contains configuration for the logger\ntype Options struct {\n\t// Development configures the logger to use a development config\n\tDevelopment bool\n\t// EnableFileOutput enables output to file\n\tEnableFileOutput bool\n\t// LogFilePath is the path to the log file\n\tLogFilePath string\n\t// MaxSize is the maximum size in megabytes of the log file before it gets rotated\n\tMaxSize int\n\t// MaxBackups is the maximum number of old log files to retain\n\tMaxBackups int\n\t// MaxAge is the maximum number of days to retain old log files\n\tMaxAge int\n\t// Compress determines if the rotated log files should be compressed using gzip\n\tCompress bool\n\t// ZapOptions are additional zap options\n\tZapOptions zap.Options\n}\n\n// DefaultOptions returns default logger options\nfunc DefaultOptions() Options {\n\treturn Options{\n\t\tDevelopment:      false,\n\t\tEnableFileOutput: false,\n\t\tLogFilePath:      \"/var/log/sandbox-controller/controller.log\",\n\t\tMaxSize:          100,  // 100MB\n\t\tMaxBackups:       10,   // keep 10 old log files\n\t\tMaxAge:           30,   // keep log files for 30 days\n\t\tCompress:         true, // compress rotated files\n\t\tZapOptions: zap.Options{\n\t\t\tDevelopment: false,\n\t\t},\n\t}\n}\n\n// NewLoggerWithZapOptions creates a logger using controller-runtime's zap options\n// and adds file output support\nfunc NewLoggerWithZapOptions(opts Options) logr.Logger {\n\t// Add AddCaller option to include file and line number in logs\n\tif opts.ZapOptions.ZapOpts == nil {\n\t\topts.ZapOptions.ZapOpts = []zap2.Option{}\n\t}\n\topts.ZapOptions.ZapOpts = append(opts.ZapOptions.ZapOpts, zap2.AddCaller())\n\n\t// If file output is not enabled, use the default zap logger\n\tif !opts.EnableFileOutput {\n\t\treturn zap.New(zap.UseFlagOptions(&opts.ZapOptions))\n\t}\n\n\t// Create file writer with rotation\n\tfileWriter := &lumberjack.Logger{\n\t\tFilename:   opts.LogFilePath,\n\t\tMaxSize:    opts.MaxSize,\n\t\tMaxBackups: opts.MaxBackups,\n\t\tMaxAge:     opts.MaxAge,\n\t\tCompress:   opts.Compress,\n\t\tLocalTime:  true,\n\t}\n\n\t// Create multi-writer that writes to both stdout and file\n\tmultiWriter := zapcore.NewMultiWriteSyncer(\n\t\tzapcore.AddSync(os.Stdout),\n\t\tzapcore.AddSync(fileWriter),\n\t)\n\n\t// Create logger with multi-writer\n\treturn zap.New(\n\t\tzap.UseFlagOptions(&opts.ZapOptions),\n\t\tzap.WriteTo(multiWriter),\n\t)\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/pod.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/meta\"\n\tapimachineryvalidation \"k8s.io/apimachinery/pkg/api/validation\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n)\n\n// IsPodAvailable returns true if a pod is available; false otherwise.\n// Precondition for an available pod is that it must be ready. On top\n// of that, there are two cases when a pod can be considered available:\n// 1. minReadySeconds == 0, or\n// 2. LastTransitionTime (is set) + minReadySeconds < current time\nfunc IsPodAvailable(pod *v1.Pod, minReadySeconds int32, now metav1.Time) bool {\n\tif !IsPodReady(pod) {\n\t\treturn false\n\t}\n\n\tc := GetPodReadyCondition(pod.Status)\n\tminReadySecondsDuration := time.Duration(minReadySeconds) * time.Second\n\tif minReadySeconds == 0 || (!c.LastTransitionTime.IsZero() && c.LastTransitionTime.Add(minReadySecondsDuration).Before(now.Time)) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n// IsPodReady returns true if a pod is ready; false otherwise.\nfunc IsPodReady(pod *v1.Pod) bool {\n\treturn IsPodReadyConditionTrue(pod.Status)\n}\n\n// IsPodTerminal returns true if a pod is terminal, all containers are stopped and cannot ever regress.\nfunc IsPodTerminal(pod *v1.Pod) bool {\n\treturn IsPodPhaseTerminal(pod.Status.Phase)\n}\n\n// IsPodPhaseTerminal returns true if the pod's phase is terminal.\nfunc IsPodPhaseTerminal(phase v1.PodPhase) bool {\n\treturn phase == v1.PodFailed || phase == v1.PodSucceeded\n}\n\n// IsPodReadyConditionTrue returns true if a pod is ready; false otherwise.\nfunc IsPodReadyConditionTrue(status v1.PodStatus) bool {\n\tcondition := GetPodReadyCondition(status)\n\treturn condition != nil && condition.Status == v1.ConditionTrue\n}\n\n// IsContainersReadyConditionTrue returns true if a pod is ready; false otherwise.\nfunc IsContainersReadyConditionTrue(status v1.PodStatus) bool {\n\tcondition := GetContainersReadyCondition(status)\n\treturn condition != nil && condition.Status == v1.ConditionTrue\n}\n\n// GetPodReadyCondition extracts the pod ready condition from the given status and returns that.\n// Returns nil if the condition is not present.\nfunc GetPodReadyCondition(status v1.PodStatus) *v1.PodCondition {\n\t_, condition := GetPodCondition(&status, v1.PodReady)\n\treturn condition\n}\n\n// GetContainersReadyCondition extracts the containers ready condition from the given status and returns that.\n// Returns nil if the condition is not present.\nfunc GetContainersReadyCondition(status v1.PodStatus) *v1.PodCondition {\n\t_, condition := GetPodCondition(&status, v1.ContainersReady)\n\treturn condition\n}\n\n// GetPodCondition extracts the provided condition from the given status and returns that.\n// Returns nil and -1 if the condition is not present, and the index of the located condition.\nfunc GetPodCondition(status *v1.PodStatus, conditionType v1.PodConditionType) (int, *v1.PodCondition) {\n\tif status == nil {\n\t\treturn -1, nil\n\t}\n\treturn GetPodConditionFromList(status.Conditions, conditionType)\n}\n\n// GetPodConditionFromList extracts the provided condition from the given list of condition and\n// returns the index of the condition and the condition. Returns -1 and nil if the condition is not present.\nfunc GetPodConditionFromList(conditions []v1.PodCondition, conditionType v1.PodConditionType) (int, *v1.PodCondition) {\n\tif conditions == nil {\n\t\treturn -1, nil\n\t}\n\tfor i := range conditions {\n\t\tif conditions[i].Type == conditionType {\n\t\t\treturn i, &conditions[i]\n\t\t}\n\t}\n\treturn -1, nil\n}\n\nfunc GetPodFromTemplate(\n\ttemplate *v1.PodTemplateSpec,\n\tparentObject runtime.Object,\n\tcontrollerRef *metav1.OwnerReference,\n) (*v1.Pod, error) {\n\tdesiredLabels := getPodsLabelSet(template)\n\tdesiredFinalizers := getPodsFinalizers(template)\n\tdesiredAnnotations := getPodsAnnotationSet(template)\n\taccessor, err := meta.Accessor(parentObject)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parentObject does not have ObjectMeta, %v\", err)\n\t}\n\tprefix := getPodsPrefix(accessor.GetName())\n\n\tpod := &v1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tLabels:       desiredLabels,\n\t\t\tAnnotations:  desiredAnnotations,\n\t\t\tGenerateName: prefix,\n\t\t\tFinalizers:   desiredFinalizers,\n\t\t},\n\t}\n\tif controllerRef != nil {\n\t\tpod.OwnerReferences = append(pod.OwnerReferences, *controllerRef)\n\t}\n\tpod.Spec = *template.Spec.DeepCopy()\n\treturn pod, nil\n}\n\nfunc getPodsLabelSet(template *v1.PodTemplateSpec) labels.Set {\n\tdesiredLabels := make(labels.Set)\n\tfor k, v := range template.Labels {\n\t\tdesiredLabels[k] = v\n\t}\n\treturn desiredLabels\n}\n\nfunc getPodsFinalizers(template *v1.PodTemplateSpec) []string {\n\tdesiredFinalizers := make([]string, len(template.Finalizers))\n\tcopy(desiredFinalizers, template.Finalizers)\n\treturn desiredFinalizers\n}\n\nfunc getPodsAnnotationSet(template *v1.PodTemplateSpec) labels.Set {\n\tdesiredAnnotations := make(labels.Set)\n\tfor k, v := range template.Annotations {\n\t\tdesiredAnnotations[k] = v\n\t}\n\treturn desiredAnnotations\n}\n\nfunc getPodsPrefix(controllerName string) string {\n\t// use the dash (if the name isn't too long) to make the pod name a bit prettier\n\tprefix := fmt.Sprintf(\"%s-\", controllerName)\n\tif len(apimachineryvalidation.NameIsDNSSubdomain(prefix, true)) != 0 {\n\t\tprefix = controllerName\n\t}\n\treturn prefix\n}\n\nfunc IsAssigned(pod *v1.Pod) bool {\n\treturn pod != nil && (pod.Spec.NodeName != \"\" || pod.Status.PodIP != \"\")\n}\n\nfunc PodNameSorter(a, b *v1.Pod) int {\n\tif a.Name < b.Name {\n\t\treturn -1\n\t} else if a.Name > b.Name {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\nfunc WithPodIndexSorter(podIndex map[string]int) func(*v1.Pod, *v1.Pod) int {\n\treturn func(a, b *v1.Pod) int {\n\t\taIdx, aOk := podIndex[a.Name]\n\t\tbIdx, bOk := podIndex[b.Name]\n\t\tif !aOk && !bOk {\n\t\t\treturn 0\n\t\t}\n\t\tif !aOk {\n\t\t\treturn 1\n\t\t}\n\t\tif !bOk {\n\t\t\treturn -1\n\t\t}\n\t\tif aIdx < bIdx {\n\t\t\treturn -1\n\t\t} else if aIdx > bIdx {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\t}\n}\n\ntype MultiPodSorter []func(a, b *v1.Pod) int\n\nfunc (m MultiPodSorter) Sort(a, b *v1.Pod) int {\n\tfor i := range m {\n\t\tret := m[i](a, b)\n\t\tif ret != 0 {\n\t\t\treturn ret\n\t\t}\n\t}\n\treturn 0\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/pod_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\nfunc TestWithPodIndexSorter(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tpodIndex map[string]int\n\t\tpodA     *v1.Pod\n\t\tpodB     *v1.Pod\n\t\twant     int\n\t}{\n\t\t{\n\t\t\tname: \"a index < b index\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-a\": 1,\n\t\t\t\t\"pod-b\": 2,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: -1,\n\t\t},\n\t\t{\n\t\t\tname: \"a index > b index\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-a\": 5,\n\t\t\t\t\"pod-b\": 3,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"a index == b index\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-a\": 2,\n\t\t\t\t\"pod-b\": 2,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"a has no index, b has index - a should be last\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-b\": 1,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"a has index, b has no index - b should be last\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-a\": 1,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: -1,\n\t\t},\n\t\t{\n\t\t\tname:     \"both have no index\",\n\t\t\tpodIndex: map[string]int{},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     0,\n\t\t},\n\t\t{\n\t\t\tname: \"index 0 vs index 1\",\n\t\t\tpodIndex: map[string]int{\n\t\t\t\t\"pod-a\": 0,\n\t\t\t\t\"pod-b\": 1,\n\t\t\t},\n\t\t\tpodA: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB: &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant: -1,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsorter := WithPodIndexSorter(tt.podIndex)\n\t\t\tgot := sorter(tt.podA, tt.podB)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"WithPodIndexSorter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMultiPodSorter(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tsorters  MultiPodSorter\n\t\tpodA     *v1.Pod\n\t\tpodB     *v1.Pod\n\t\twant     int\n\t\twantDesc string\n\t}{\n\t\t{\n\t\t\tname: \"first sorter decides - a < b\",\n\t\t\tsorters: MultiPodSorter{\n\t\t\t\tfunc(a, b *v1.Pod) int {\n\t\t\t\t\tif a.Name < b.Name {\n\t\t\t\t\t\treturn -1\n\t\t\t\t\t} else if a.Name > b.Name {\n\t\t\t\t\t\treturn 1\n\t\t\t\t\t}\n\t\t\t\t\treturn 0\n\t\t\t\t},\n\t\t\t},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     -1,\n\t\t\twantDesc: \"pod-a should come before pod-b\",\n\t\t},\n\t\t{\n\t\t\tname: \"first sorter equal, second sorter decides\",\n\t\t\tsorters: MultiPodSorter{\n\t\t\t\tfunc(a, b *v1.Pod) int {\n\t\t\t\t\treturn 0\n\t\t\t\t},\n\t\t\t\tfunc(a, b *v1.Pod) int {\n\t\t\t\t\tif a.Name < b.Name {\n\t\t\t\t\t\treturn -1\n\t\t\t\t\t} else if a.Name > b.Name {\n\t\t\t\t\t\treturn 1\n\t\t\t\t\t}\n\t\t\t\t\treturn 0\n\t\t\t\t},\n\t\t\t},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     -1,\n\t\t\twantDesc: \"first sorter returns 0, second sorter decides\",\n\t\t},\n\t\t{\n\t\t\tname: \"all sorters return equal\",\n\t\t\tsorters: MultiPodSorter{\n\t\t\t\tfunc(a, b *v1.Pod) int { return 0 },\n\t\t\t\tfunc(a, b *v1.Pod) int { return 0 },\n\t\t\t\tfunc(a, b *v1.Pod) int { return 0 },\n\t\t\t},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     0,\n\t\t\twantDesc: \"all sorters return 0\",\n\t\t},\n\t\t{\n\t\t\tname: \"index sorter then name sorter - decided by index\",\n\t\t\tsorters: MultiPodSorter{\n\t\t\t\tWithPodIndexSorter(map[string]int{\n\t\t\t\t\t\"pod-b\": 0,\n\t\t\t\t\t\"pod-a\": 1,\n\t\t\t\t}),\n\t\t\t\tPodNameSorter,\n\t\t\t},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     1,\n\t\t\twantDesc: \"pod-b has lower index (0) than pod-a (1), so pod-a > pod-b\",\n\t\t},\n\t\t{\n\t\t\tname: \"index sorter then name sorter - decided by name\",\n\t\t\tsorters: MultiPodSorter{\n\t\t\t\tWithPodIndexSorter(map[string]int{\n\t\t\t\t\t\"pod-a\": 1,\n\t\t\t\t\t\"pod-b\": 1,\n\t\t\t\t}),\n\t\t\t\tPodNameSorter,\n\t\t\t},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     -1,\n\t\t\twantDesc: \"same index, fallback to name comparison\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty sorters list\",\n\t\t\tsorters:  MultiPodSorter{},\n\t\t\tpodA:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t\tpodB:     &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t\twant:     0,\n\t\t\twantDesc: \"no sorters, should return 0\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.sorters.Sort(tt.podA, tt.podB)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"MultiPodSorter.Sort() = %v, want %v (%s)\", got, tt.want, tt.wantDesc)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMultiPodSorter_Integration(t *testing.T) {\n\tpods := []*v1.Pod{\n\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-c\"}},\n\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-a\"}},\n\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-b\"}},\n\t\t{ObjectMeta: metav1.ObjectMeta{Name: \"pod-d\"}},\n\t}\n\n\tpodIndex := map[string]int{\n\t\t\"pod-a\": 2,\n\t\t\"pod-b\": 0,\n\t\t\"pod-c\": 1,\n\t}\n\n\tsorter := MultiPodSorter{\n\t\tWithPodIndexSorter(podIndex),\n\t\tPodNameSorter,\n\t}\n\n\tslices.SortStableFunc(pods, sorter.Sort)\n\n\texpectedOrder := []string{\"pod-b\", \"pod-c\", \"pod-a\", \"pod-d\"}\n\n\tfor i, pod := range pods {\n\t\tif pod.Name != expectedOrder[i] {\n\t\t\tt.Errorf(\"pod at index %d: got %s, want %s\", i, pod.Name, expectedOrder[i])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "kubernetes/internal/utils/requeueduration/duration.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage requeueduration\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n)\n\n// DurationStore can store a duration map for multiple workloads\ntype DurationStore struct {\n\tstore sync.Map\n}\n\nfunc (dm *DurationStore) Push(key string, newDuration time.Duration) {\n\tvalue, _ := dm.store.LoadOrStore(key, &Duration{})\n\trequeueDuration, ok := value.(*Duration)\n\tif !ok {\n\t\tdm.store.Delete(key)\n\t\treturn\n\t}\n\trequeueDuration.Update(newDuration)\n}\n\nfunc (dm *DurationStore) Pop(key string) time.Duration {\n\tvalue, ok := dm.store.Load(key)\n\tif !ok {\n\t\treturn 0\n\t}\n\tdefer dm.store.Delete(key)\n\trequeueDuration, ok := value.(*Duration)\n\tif !ok {\n\t\treturn 0\n\t}\n\treturn requeueDuration.Get()\n}\n\n// Duration helps calculate the shortest non-zore duration to requeue\ntype Duration struct {\n\tsync.Mutex\n\tduration time.Duration\n\tmessage  string\n}\n\nfunc (rd *Duration) Update(newDuration time.Duration) {\n\trd.Lock()\n\tdefer rd.Unlock()\n\tif newDuration > 0 {\n\t\tif rd.duration <= 0 || newDuration < rd.duration {\n\t\t\trd.duration = newDuration\n\t\t}\n\t}\n}\n\nfunc (rd *Duration) UpdateWithMsg(newDuration time.Duration, format string, args ...interface{}) {\n\trd.Lock()\n\tdefer rd.Unlock()\n\tif newDuration > 0 {\n\t\tif rd.duration <= 0 || newDuration < rd.duration {\n\t\t\trd.duration = newDuration\n\t\t\trd.message = fmt.Sprintf(format, args...)\n\t\t}\n\t}\n}\n\nfunc (rd *Duration) Merge(rd2 *Duration) {\n\trd2.Lock()\n\tdefer rd2.Unlock()\n\trd.UpdateWithMsg(rd2.duration, \"%s\", rd2.message)\n}\n\nfunc (rd *Duration) Get() time.Duration {\n\trd.Lock()\n\tdefer rd.Unlock()\n\treturn rd.duration\n}\n\nfunc (rd *Duration) GetWithMsg() (time.Duration, string) {\n\trd.Lock()\n\tdefer rd.Unlock()\n\treturn rd.duration, rd.message\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/clientset.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage versioned\n\nimport (\n\tfmt \"fmt\"\n\thttp \"net/http\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1\"\n\tdiscovery \"k8s.io/client-go/discovery\"\n\trest \"k8s.io/client-go/rest\"\n\tflowcontrol \"k8s.io/client-go/util/flowcontrol\"\n)\n\ntype Interface interface {\n\tDiscovery() discovery.DiscoveryInterface\n\tSandboxV1alpha1() sandboxv1alpha1.SandboxV1alpha1Interface\n}\n\n// Clientset contains the clients for groups.\ntype Clientset struct {\n\t*discovery.DiscoveryClient\n\tsandboxV1alpha1 *sandboxv1alpha1.SandboxV1alpha1Client\n}\n\n// SandboxV1alpha1 retrieves the SandboxV1alpha1Client\nfunc (c *Clientset) SandboxV1alpha1() sandboxv1alpha1.SandboxV1alpha1Interface {\n\treturn c.sandboxV1alpha1\n}\n\n// Discovery retrieves the DiscoveryClient\nfunc (c *Clientset) Discovery() discovery.DiscoveryInterface {\n\tif c == nil {\n\t\treturn nil\n\t}\n\treturn c.DiscoveryClient\n}\n\n// NewForConfig creates a new Clientset for the given config.\n// If config's RateLimiter is not set and QPS and Burst are acceptable,\n// NewForConfig will generate a rate-limiter in configShallowCopy.\n// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient),\n// where httpClient was generated with rest.HTTPClientFor(c).\nfunc NewForConfig(c *rest.Config) (*Clientset, error) {\n\tconfigShallowCopy := *c\n\n\tif configShallowCopy.UserAgent == \"\" {\n\t\tconfigShallowCopy.UserAgent = rest.DefaultKubernetesUserAgent()\n\t}\n\n\t// share the transport between all clients\n\thttpClient, err := rest.HTTPClientFor(&configShallowCopy)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn NewForConfigAndClient(&configShallowCopy, httpClient)\n}\n\n// NewForConfigAndClient creates a new Clientset for the given config and http client.\n// Note the http client provided takes precedence over the configured transport values.\n// If config's RateLimiter is not set and QPS and Burst are acceptable,\n// NewForConfigAndClient will generate a rate-limiter in configShallowCopy.\nfunc NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, error) {\n\tconfigShallowCopy := *c\n\tif configShallowCopy.RateLimiter == nil && configShallowCopy.QPS > 0 {\n\t\tif configShallowCopy.Burst <= 0 {\n\t\t\treturn nil, fmt.Errorf(\"burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0\")\n\t\t}\n\t\tconfigShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst)\n\t}\n\n\tvar cs Clientset\n\tvar err error\n\tcs.sandboxV1alpha1, err = sandboxv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfigAndClient(&configShallowCopy, httpClient)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cs, nil\n}\n\n// NewForConfigOrDie creates a new Clientset for the given config and\n// panics if there is an error in the config.\nfunc NewForConfigOrDie(c *rest.Config) *Clientset {\n\tcs, err := NewForConfig(c)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn cs\n}\n\n// New creates a new Clientset for the given RESTClient.\nfunc New(c rest.Interface) *Clientset {\n\tvar cs Clientset\n\tcs.sandboxV1alpha1 = sandboxv1alpha1.New(c)\n\n\tcs.DiscoveryClient = discovery.NewDiscoveryClient(c)\n\treturn &cs\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/fake/clientset_generated.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage fake\n\nimport (\n\tclientset \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1\"\n\tfakesandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/fake\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/watch\"\n\t\"k8s.io/client-go/discovery\"\n\tfakediscovery \"k8s.io/client-go/discovery/fake\"\n\t\"k8s.io/client-go/testing\"\n)\n\n// NewSimpleClientset returns a clientset that will respond with the provided objects.\n// It's backed by a very simple object tracker that processes creates, updates and deletions as-is,\n// without applying any field management, validations and/or defaults. It shouldn't be considered a replacement\n// for a real clientset and is mostly useful in simple unit tests.\n//\n// DEPRECATED: NewClientset replaces this with support for field management, which significantly improves\n// server side apply testing. NewClientset is only available when apply configurations are generated (e.g.\n// via --with-applyconfig).\nfunc NewSimpleClientset(objects ...runtime.Object) *Clientset {\n\to := testing.NewObjectTracker(scheme, codecs.UniversalDecoder())\n\tfor _, obj := range objects {\n\t\tif err := o.Add(obj); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n\n\tcs := &Clientset{tracker: o}\n\tcs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake}\n\tcs.AddReactor(\"*\", \"*\", testing.ObjectReaction(o))\n\tcs.AddWatchReactor(\"*\", func(action testing.Action) (handled bool, ret watch.Interface, err error) {\n\t\tvar opts metav1.ListOptions\n\t\tif watchActcion, ok := action.(testing.WatchActionImpl); ok {\n\t\t\topts = watchActcion.ListOptions\n\t\t}\n\t\tgvr := action.GetResource()\n\t\tns := action.GetNamespace()\n\t\twatch, err := o.Watch(gvr, ns, opts)\n\t\tif err != nil {\n\t\t\treturn false, nil, err\n\t\t}\n\t\treturn true, watch, nil\n\t})\n\n\treturn cs\n}\n\n// Clientset implements clientset.Interface. Meant to be embedded into a\n// struct to get a default implementation. This makes faking out just the method\n// you want to test easier.\ntype Clientset struct {\n\ttesting.Fake\n\tdiscovery *fakediscovery.FakeDiscovery\n\ttracker   testing.ObjectTracker\n}\n\nfunc (c *Clientset) Discovery() discovery.DiscoveryInterface {\n\treturn c.discovery\n}\n\nfunc (c *Clientset) Tracker() testing.ObjectTracker {\n\treturn c.tracker\n}\n\nvar (\n\t_ clientset.Interface = &Clientset{}\n\t_ testing.FakeClient  = &Clientset{}\n)\n\n// SandboxV1alpha1 retrieves the SandboxV1alpha1Client\nfunc (c *Clientset) SandboxV1alpha1() sandboxv1alpha1.SandboxV1alpha1Interface {\n\treturn &fakesandboxv1alpha1.FakeSandboxV1alpha1{Fake: &c.Fake}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/fake/doc.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\n// This package has the automatically generated fake clientset.\npackage fake\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/fake/register.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage fake\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\tschema \"k8s.io/apimachinery/pkg/runtime/schema\"\n\tserializer \"k8s.io/apimachinery/pkg/runtime/serializer\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n)\n\nvar scheme = runtime.NewScheme()\nvar codecs = serializer.NewCodecFactory(scheme)\n\nvar localSchemeBuilder = runtime.SchemeBuilder{\n\tsandboxv1alpha1.AddToScheme,\n}\n\n// AddToScheme adds all types of this clientset into the given scheme. This allows composition\n// of clientsets, like in:\n//\n//\timport (\n//\t  \"k8s.io/client-go/kubernetes\"\n//\t  clientsetscheme \"k8s.io/client-go/kubernetes/scheme\"\n//\t  aggregatorclientsetscheme \"k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme\"\n//\t)\n//\n//\tkclientset, _ := kubernetes.NewForConfig(c)\n//\t_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)\n//\n// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types\n// correctly.\nvar AddToScheme = localSchemeBuilder.AddToScheme\n\nfunc init() {\n\tv1.AddToGroupVersion(scheme, schema.GroupVersion{Version: \"v1\"})\n\tutilruntime.Must(AddToScheme(scheme))\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/scheme/doc.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\n// This package contains the scheme of the automatically generated clientset.\npackage scheme\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/scheme/register.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage scheme\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\tschema \"k8s.io/apimachinery/pkg/runtime/schema\"\n\tserializer \"k8s.io/apimachinery/pkg/runtime/serializer\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n)\n\nvar Scheme = runtime.NewScheme()\nvar Codecs = serializer.NewCodecFactory(Scheme)\nvar ParameterCodec = runtime.NewParameterCodec(Scheme)\nvar localSchemeBuilder = runtime.SchemeBuilder{\n\tsandboxv1alpha1.AddToScheme,\n}\n\n// AddToScheme adds all types of this clientset into the given scheme. This allows composition\n// of clientsets, like in:\n//\n//\timport (\n//\t  \"k8s.io/client-go/kubernetes\"\n//\t  clientsetscheme \"k8s.io/client-go/kubernetes/scheme\"\n//\t  aggregatorclientsetscheme \"k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme\"\n//\t)\n//\n//\tkclientset, _ := kubernetes.NewForConfig(c)\n//\t_ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme)\n//\n// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types\n// correctly.\nvar AddToScheme = localSchemeBuilder.AddToScheme\n\nfunc init() {\n\tv1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: \"v1\"})\n\tutilruntime.Must(AddToScheme(Scheme))\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/batchsandbox.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tcontext \"context\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tscheme \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/scheme\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\ttypes \"k8s.io/apimachinery/pkg/types\"\n\twatch \"k8s.io/apimachinery/pkg/watch\"\n\tgentype \"k8s.io/client-go/gentype\"\n)\n\n// BatchSandboxesGetter has a method to return a BatchSandboxInterface.\n// A group's client should implement this interface.\ntype BatchSandboxesGetter interface {\n\tBatchSandboxes(namespace string) BatchSandboxInterface\n}\n\n// BatchSandboxInterface has methods to work with BatchSandbox resources.\ntype BatchSandboxInterface interface {\n\tCreate(ctx context.Context, batchSandbox *sandboxv1alpha1.BatchSandbox, opts v1.CreateOptions) (*sandboxv1alpha1.BatchSandbox, error)\n\tUpdate(ctx context.Context, batchSandbox *sandboxv1alpha1.BatchSandbox, opts v1.UpdateOptions) (*sandboxv1alpha1.BatchSandbox, error)\n\t// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().\n\tUpdateStatus(ctx context.Context, batchSandbox *sandboxv1alpha1.BatchSandbox, opts v1.UpdateOptions) (*sandboxv1alpha1.BatchSandbox, error)\n\tDelete(ctx context.Context, name string, opts v1.DeleteOptions) error\n\tDeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error\n\tGet(ctx context.Context, name string, opts v1.GetOptions) (*sandboxv1alpha1.BatchSandbox, error)\n\tList(ctx context.Context, opts v1.ListOptions) (*sandboxv1alpha1.BatchSandboxList, error)\n\tWatch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)\n\tPatch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *sandboxv1alpha1.BatchSandbox, err error)\n\tBatchSandboxExpansion\n}\n\n// batchSandboxes implements BatchSandboxInterface\ntype batchSandboxes struct {\n\t*gentype.ClientWithList[*sandboxv1alpha1.BatchSandbox, *sandboxv1alpha1.BatchSandboxList]\n}\n\n// newBatchSandboxes returns a BatchSandboxes\nfunc newBatchSandboxes(c *SandboxV1alpha1Client, namespace string) *batchSandboxes {\n\treturn &batchSandboxes{\n\t\tgentype.NewClientWithList[*sandboxv1alpha1.BatchSandbox, *sandboxv1alpha1.BatchSandboxList](\n\t\t\t\"batchsandboxes\",\n\t\t\tc.RESTClient(),\n\t\t\tscheme.ParameterCodec,\n\t\t\tnamespace,\n\t\t\tfunc() *sandboxv1alpha1.BatchSandbox { return &sandboxv1alpha1.BatchSandbox{} },\n\t\t\tfunc() *sandboxv1alpha1.BatchSandboxList { return &sandboxv1alpha1.BatchSandboxList{} },\n\t\t),\n\t}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/doc.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\n// This package has the automatically generated typed clients.\npackage v1alpha1\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/fake/doc.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\n// Package fake has the automatically generated clients.\npackage fake\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/fake/fake_batchsandbox.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage fake\n\nimport (\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1\"\n\tgentype \"k8s.io/client-go/gentype\"\n)\n\n// fakeBatchSandboxes implements BatchSandboxInterface\ntype fakeBatchSandboxes struct {\n\t*gentype.FakeClientWithList[*v1alpha1.BatchSandbox, *v1alpha1.BatchSandboxList]\n\tFake *FakeSandboxV1alpha1\n}\n\nfunc newFakeBatchSandboxes(fake *FakeSandboxV1alpha1, namespace string) sandboxv1alpha1.BatchSandboxInterface {\n\treturn &fakeBatchSandboxes{\n\t\tgentype.NewFakeClientWithList[*v1alpha1.BatchSandbox, *v1alpha1.BatchSandboxList](\n\t\t\tfake.Fake,\n\t\t\tnamespace,\n\t\t\tv1alpha1.SchemeGroupVersion.WithResource(\"batchsandboxes\"),\n\t\t\tv1alpha1.SchemeGroupVersion.WithKind(\"BatchSandbox\"),\n\t\t\tfunc() *v1alpha1.BatchSandbox { return &v1alpha1.BatchSandbox{} },\n\t\t\tfunc() *v1alpha1.BatchSandboxList { return &v1alpha1.BatchSandboxList{} },\n\t\t\tfunc(dst, src *v1alpha1.BatchSandboxList) { dst.ListMeta = src.ListMeta },\n\t\t\tfunc(list *v1alpha1.BatchSandboxList) []*v1alpha1.BatchSandbox {\n\t\t\t\treturn gentype.ToPointerSlice(list.Items)\n\t\t\t},\n\t\t\tfunc(list *v1alpha1.BatchSandboxList, items []*v1alpha1.BatchSandbox) {\n\t\t\t\tlist.Items = gentype.FromPointerSlice(items)\n\t\t\t},\n\t\t),\n\t\tfake,\n\t}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/fake/fake_pool.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage fake\n\nimport (\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1\"\n\tgentype \"k8s.io/client-go/gentype\"\n)\n\n// fakePools implements PoolInterface\ntype fakePools struct {\n\t*gentype.FakeClientWithList[*v1alpha1.Pool, *v1alpha1.PoolList]\n\tFake *FakeSandboxV1alpha1\n}\n\nfunc newFakePools(fake *FakeSandboxV1alpha1, namespace string) sandboxv1alpha1.PoolInterface {\n\treturn &fakePools{\n\t\tgentype.NewFakeClientWithList[*v1alpha1.Pool, *v1alpha1.PoolList](\n\t\t\tfake.Fake,\n\t\t\tnamespace,\n\t\t\tv1alpha1.SchemeGroupVersion.WithResource(\"pools\"),\n\t\t\tv1alpha1.SchemeGroupVersion.WithKind(\"Pool\"),\n\t\t\tfunc() *v1alpha1.Pool { return &v1alpha1.Pool{} },\n\t\t\tfunc() *v1alpha1.PoolList { return &v1alpha1.PoolList{} },\n\t\t\tfunc(dst, src *v1alpha1.PoolList) { dst.ListMeta = src.ListMeta },\n\t\t\tfunc(list *v1alpha1.PoolList) []*v1alpha1.Pool { return gentype.ToPointerSlice(list.Items) },\n\t\t\tfunc(list *v1alpha1.PoolList, items []*v1alpha1.Pool) { list.Items = gentype.FromPointerSlice(items) },\n\t\t),\n\t\tfake,\n\t}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/fake/fake_sandbox_client.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage fake\n\nimport (\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/typed/sandbox/v1alpha1\"\n\trest \"k8s.io/client-go/rest\"\n\ttesting \"k8s.io/client-go/testing\"\n)\n\ntype FakeSandboxV1alpha1 struct {\n\t*testing.Fake\n}\n\nfunc (c *FakeSandboxV1alpha1) BatchSandboxes(namespace string) v1alpha1.BatchSandboxInterface {\n\treturn newFakeBatchSandboxes(c, namespace)\n}\n\nfunc (c *FakeSandboxV1alpha1) Pools(namespace string) v1alpha1.PoolInterface {\n\treturn newFakePools(c, namespace)\n}\n\n// RESTClient returns a RESTClient that is used to communicate\n// with API server by this client implementation.\nfunc (c *FakeSandboxV1alpha1) RESTClient() rest.Interface {\n\tvar ret *rest.RESTClient\n\treturn ret\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/generated_expansion.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage v1alpha1\n\ntype BatchSandboxExpansion interface{}\n\ntype PoolExpansion interface{}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/pool.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tcontext \"context\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tscheme \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/scheme\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\ttypes \"k8s.io/apimachinery/pkg/types\"\n\twatch \"k8s.io/apimachinery/pkg/watch\"\n\tgentype \"k8s.io/client-go/gentype\"\n)\n\n// PoolsGetter has a method to return a PoolInterface.\n// A group's client should implement this interface.\ntype PoolsGetter interface {\n\tPools(namespace string) PoolInterface\n}\n\n// PoolInterface has methods to work with Pool resources.\ntype PoolInterface interface {\n\tCreate(ctx context.Context, pool *sandboxv1alpha1.Pool, opts v1.CreateOptions) (*sandboxv1alpha1.Pool, error)\n\tUpdate(ctx context.Context, pool *sandboxv1alpha1.Pool, opts v1.UpdateOptions) (*sandboxv1alpha1.Pool, error)\n\t// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().\n\tUpdateStatus(ctx context.Context, pool *sandboxv1alpha1.Pool, opts v1.UpdateOptions) (*sandboxv1alpha1.Pool, error)\n\tDelete(ctx context.Context, name string, opts v1.DeleteOptions) error\n\tDeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error\n\tGet(ctx context.Context, name string, opts v1.GetOptions) (*sandboxv1alpha1.Pool, error)\n\tList(ctx context.Context, opts v1.ListOptions) (*sandboxv1alpha1.PoolList, error)\n\tWatch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)\n\tPatch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *sandboxv1alpha1.Pool, err error)\n\tPoolExpansion\n}\n\n// pools implements PoolInterface\ntype pools struct {\n\t*gentype.ClientWithList[*sandboxv1alpha1.Pool, *sandboxv1alpha1.PoolList]\n}\n\n// newPools returns a Pools\nfunc newPools(c *SandboxV1alpha1Client, namespace string) *pools {\n\treturn &pools{\n\t\tgentype.NewClientWithList[*sandboxv1alpha1.Pool, *sandboxv1alpha1.PoolList](\n\t\t\t\"pools\",\n\t\t\tc.RESTClient(),\n\t\t\tscheme.ParameterCodec,\n\t\t\tnamespace,\n\t\t\tfunc() *sandboxv1alpha1.Pool { return &sandboxv1alpha1.Pool{} },\n\t\t\tfunc() *sandboxv1alpha1.PoolList { return &sandboxv1alpha1.PoolList{} },\n\t\t),\n\t}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/clientset/versioned/typed/sandbox/v1alpha1/sandbox_client.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by client-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\thttp \"net/http\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tscheme \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/scheme\"\n\trest \"k8s.io/client-go/rest\"\n)\n\ntype SandboxV1alpha1Interface interface {\n\tRESTClient() rest.Interface\n\tBatchSandboxesGetter\n\tPoolsGetter\n}\n\n// SandboxV1alpha1Client is used to interact with features provided by the sandbox.opensandbox.io group.\ntype SandboxV1alpha1Client struct {\n\trestClient rest.Interface\n}\n\nfunc (c *SandboxV1alpha1Client) BatchSandboxes(namespace string) BatchSandboxInterface {\n\treturn newBatchSandboxes(c, namespace)\n}\n\nfunc (c *SandboxV1alpha1Client) Pools(namespace string) PoolInterface {\n\treturn newPools(c, namespace)\n}\n\n// NewForConfig creates a new SandboxV1alpha1Client for the given config.\n// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient),\n// where httpClient was generated with rest.HTTPClientFor(c).\nfunc NewForConfig(c *rest.Config) (*SandboxV1alpha1Client, error) {\n\tconfig := *c\n\tsetConfigDefaults(&config)\n\thttpClient, err := rest.HTTPClientFor(&config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewForConfigAndClient(&config, httpClient)\n}\n\n// NewForConfigAndClient creates a new SandboxV1alpha1Client for the given config and http client.\n// Note the http client provided takes precedence over the configured transport values.\nfunc NewForConfigAndClient(c *rest.Config, h *http.Client) (*SandboxV1alpha1Client, error) {\n\tconfig := *c\n\tsetConfigDefaults(&config)\n\tclient, err := rest.RESTClientForConfigAndClient(&config, h)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &SandboxV1alpha1Client{client}, nil\n}\n\n// NewForConfigOrDie creates a new SandboxV1alpha1Client for the given config and\n// panics if there is an error in the config.\nfunc NewForConfigOrDie(c *rest.Config) *SandboxV1alpha1Client {\n\tclient, err := NewForConfig(c)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn client\n}\n\n// New creates a new SandboxV1alpha1Client for the given RESTClient.\nfunc New(c rest.Interface) *SandboxV1alpha1Client {\n\treturn &SandboxV1alpha1Client{c}\n}\n\nfunc setConfigDefaults(config *rest.Config) {\n\tgv := sandboxv1alpha1.SchemeGroupVersion\n\tconfig.GroupVersion = &gv\n\tconfig.APIPath = \"/apis\"\n\tconfig.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion()\n\n\tif config.UserAgent == \"\" {\n\t\tconfig.UserAgent = rest.DefaultKubernetesUserAgent()\n\t}\n}\n\n// RESTClient returns a RESTClient that is used to communicate\n// with API server by this client implementation.\nfunc (c *SandboxV1alpha1Client) RESTClient() rest.Interface {\n\tif c == nil {\n\t\treturn nil\n\t}\n\treturn c.restClient\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/factory.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage externalversions\n\nimport (\n\treflect \"reflect\"\n\tsync \"sync\"\n\ttime \"time\"\n\n\tversioned \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tinternalinterfaces \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/internalinterfaces\"\n\tsandbox \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/sandbox\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\tschema \"k8s.io/apimachinery/pkg/runtime/schema\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// SharedInformerOption defines the functional option type for SharedInformerFactory.\ntype SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory\n\ntype sharedInformerFactory struct {\n\tclient           versioned.Interface\n\tnamespace        string\n\ttweakListOptions internalinterfaces.TweakListOptionsFunc\n\tlock             sync.Mutex\n\tdefaultResync    time.Duration\n\tcustomResync     map[reflect.Type]time.Duration\n\ttransform        cache.TransformFunc\n\n\tinformers map[reflect.Type]cache.SharedIndexInformer\n\t// startedInformers is used for tracking which informers have been started.\n\t// This allows Start() to be called multiple times safely.\n\tstartedInformers map[reflect.Type]bool\n\t// wg tracks how many goroutines were started.\n\twg sync.WaitGroup\n\t// shuttingDown is true when Shutdown has been called. It may still be running\n\t// because it needs to wait for goroutines.\n\tshuttingDown bool\n}\n\n// WithCustomResyncConfig sets a custom resync period for the specified informer types.\nfunc WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption {\n\treturn func(factory *sharedInformerFactory) *sharedInformerFactory {\n\t\tfor k, v := range resyncConfig {\n\t\t\tfactory.customResync[reflect.TypeOf(k)] = v\n\t\t}\n\t\treturn factory\n\t}\n}\n\n// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory.\nfunc WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption {\n\treturn func(factory *sharedInformerFactory) *sharedInformerFactory {\n\t\tfactory.tweakListOptions = tweakListOptions\n\t\treturn factory\n\t}\n}\n\n// WithNamespace limits the SharedInformerFactory to the specified namespace.\nfunc WithNamespace(namespace string) SharedInformerOption {\n\treturn func(factory *sharedInformerFactory) *sharedInformerFactory {\n\t\tfactory.namespace = namespace\n\t\treturn factory\n\t}\n}\n\n// WithTransform sets a transform on all informers.\nfunc WithTransform(transform cache.TransformFunc) SharedInformerOption {\n\treturn func(factory *sharedInformerFactory) *sharedInformerFactory {\n\t\tfactory.transform = transform\n\t\treturn factory\n\t}\n}\n\n// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces.\nfunc NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory {\n\treturn NewSharedInformerFactoryWithOptions(client, defaultResync)\n}\n\n// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory.\n// Listers obtained via this SharedInformerFactory will be subject to the same filters\n// as specified here.\n// Deprecated: Please use NewSharedInformerFactoryWithOptions instead\nfunc NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory {\n\treturn NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions))\n}\n\n// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options.\nfunc NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory {\n\tfactory := &sharedInformerFactory{\n\t\tclient:           client,\n\t\tnamespace:        v1.NamespaceAll,\n\t\tdefaultResync:    defaultResync,\n\t\tinformers:        make(map[reflect.Type]cache.SharedIndexInformer),\n\t\tstartedInformers: make(map[reflect.Type]bool),\n\t\tcustomResync:     make(map[reflect.Type]time.Duration),\n\t}\n\n\t// Apply all options\n\tfor _, opt := range options {\n\t\tfactory = opt(factory)\n\t}\n\n\treturn factory\n}\n\nfunc (f *sharedInformerFactory) Start(stopCh <-chan struct{}) {\n\tf.lock.Lock()\n\tdefer f.lock.Unlock()\n\n\tif f.shuttingDown {\n\t\treturn\n\t}\n\n\tfor informerType, informer := range f.informers {\n\t\tif !f.startedInformers[informerType] {\n\t\t\tf.wg.Add(1)\n\t\t\t// We need a new variable in each loop iteration,\n\t\t\t// otherwise the goroutine would use the loop variable\n\t\t\t// and that keeps changing.\n\t\t\tinformer := informer\n\t\t\tgo func() {\n\t\t\t\tdefer f.wg.Done()\n\t\t\t\tinformer.Run(stopCh)\n\t\t\t}()\n\t\t\tf.startedInformers[informerType] = true\n\t\t}\n\t}\n}\n\nfunc (f *sharedInformerFactory) Shutdown() {\n\tf.lock.Lock()\n\tf.shuttingDown = true\n\tf.lock.Unlock()\n\n\t// Will return immediately if there is nothing to wait for.\n\tf.wg.Wait()\n}\n\nfunc (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool {\n\tinformers := func() map[reflect.Type]cache.SharedIndexInformer {\n\t\tf.lock.Lock()\n\t\tdefer f.lock.Unlock()\n\n\t\tinformers := map[reflect.Type]cache.SharedIndexInformer{}\n\t\tfor informerType, informer := range f.informers {\n\t\t\tif f.startedInformers[informerType] {\n\t\t\t\tinformers[informerType] = informer\n\t\t\t}\n\t\t}\n\t\treturn informers\n\t}()\n\n\tres := map[reflect.Type]bool{}\n\tfor informType, informer := range informers {\n\t\tres[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced)\n\t}\n\treturn res\n}\n\n// InformerFor returns the SharedIndexInformer for obj using an internal\n// client.\nfunc (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer {\n\tf.lock.Lock()\n\tdefer f.lock.Unlock()\n\n\tinformerType := reflect.TypeOf(obj)\n\tinformer, exists := f.informers[informerType]\n\tif exists {\n\t\treturn informer\n\t}\n\n\tresyncPeriod, exists := f.customResync[informerType]\n\tif !exists {\n\t\tresyncPeriod = f.defaultResync\n\t}\n\n\tinformer = newFunc(f.client, resyncPeriod)\n\tinformer.SetTransform(f.transform)\n\tf.informers[informerType] = informer\n\n\treturn informer\n}\n\n// SharedInformerFactory provides shared informers for resources in all known\n// API group versions.\n//\n// It is typically used like this:\n//\n//\tctx, cancel := context.Background()\n//\tdefer cancel()\n//\tfactory := NewSharedInformerFactory(client, resyncPeriod)\n//\tdefer factory.WaitForStop()    // Returns immediately if nothing was started.\n//\tgenericInformer := factory.ForResource(resource)\n//\ttypedInformer := factory.SomeAPIGroup().V1().SomeType()\n//\tfactory.Start(ctx.Done())          // Start processing these informers.\n//\tsynced := factory.WaitForCacheSync(ctx.Done())\n//\tfor v, ok := range synced {\n//\t    if !ok {\n//\t        fmt.Fprintf(os.Stderr, \"caches failed to sync: %v\", v)\n//\t        return\n//\t    }\n//\t}\n//\n//\t// Creating informers can also be created after Start, but then\n//\t// Start must be called again:\n//\tanotherGenericInformer := factory.ForResource(resource)\n//\tfactory.Start(ctx.Done())\ntype SharedInformerFactory interface {\n\tinternalinterfaces.SharedInformerFactory\n\n\t// Start initializes all requested informers. They are handled in goroutines\n\t// which run until the stop channel gets closed.\n\t// Warning: Start does not block. When run in a go-routine, it will race with a later WaitForCacheSync.\n\tStart(stopCh <-chan struct{})\n\n\t// Shutdown marks a factory as shutting down. At that point no new\n\t// informers can be started anymore and Start will return without\n\t// doing anything.\n\t//\n\t// In addition, Shutdown blocks until all goroutines have terminated. For that\n\t// to happen, the close channel(s) that they were started with must be closed,\n\t// either before Shutdown gets called or while it is waiting.\n\t//\n\t// Shutdown may be called multiple times, even concurrently. All such calls will\n\t// block until all goroutines have terminated.\n\tShutdown()\n\n\t// WaitForCacheSync blocks until all started informers' caches were synced\n\t// or the stop channel gets closed.\n\tWaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool\n\n\t// ForResource gives generic access to a shared informer of the matching type.\n\tForResource(resource schema.GroupVersionResource) (GenericInformer, error)\n\n\t// InformerFor returns the SharedIndexInformer for obj using an internal\n\t// client.\n\tInformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer\n\n\tSandbox() sandbox.Interface\n}\n\nfunc (f *sharedInformerFactory) Sandbox() sandbox.Interface {\n\treturn sandbox.New(f, f.namespace, f.tweakListOptions)\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/generic.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage externalversions\n\nimport (\n\tfmt \"fmt\"\n\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tschema \"k8s.io/apimachinery/pkg/runtime/schema\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// GenericInformer is type of SharedIndexInformer which will locate and delegate to other\n// sharedInformers based on type\ntype GenericInformer interface {\n\tInformer() cache.SharedIndexInformer\n\tLister() cache.GenericLister\n}\n\ntype genericInformer struct {\n\tinformer cache.SharedIndexInformer\n\tresource schema.GroupResource\n}\n\n// Informer returns the SharedIndexInformer.\nfunc (f *genericInformer) Informer() cache.SharedIndexInformer {\n\treturn f.informer\n}\n\n// Lister returns the GenericLister.\nfunc (f *genericInformer) Lister() cache.GenericLister {\n\treturn cache.NewGenericLister(f.Informer().GetIndexer(), f.resource)\n}\n\n// ForResource gives generic access to a shared informer of the matching type\n// TODO extend this to unknown resources with a client pool\nfunc (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {\n\tswitch resource {\n\t// Group=sandbox.opensandbox.io, Version=v1alpha1\n\tcase v1alpha1.SchemeGroupVersion.WithResource(\"batchsandboxes\"):\n\t\treturn &genericInformer{resource: resource.GroupResource(), informer: f.Sandbox().V1alpha1().BatchSandboxes().Informer()}, nil\n\tcase v1alpha1.SchemeGroupVersion.WithResource(\"pools\"):\n\t\treturn &genericInformer{resource: resource.GroupResource(), informer: f.Sandbox().V1alpha1().Pools().Informer()}, nil\n\n\t}\n\n\treturn nil, fmt.Errorf(\"no informer found for %v\", resource)\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/internalinterfaces/factory_interfaces.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage internalinterfaces\n\nimport (\n\ttime \"time\"\n\n\tversioned \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer.\ntype NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer\n\n// SharedInformerFactory a small interface to allow for adding an informer without an import cycle\ntype SharedInformerFactory interface {\n\tStart(stopCh <-chan struct{})\n\tInformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer\n}\n\n// TweakListOptionsFunc is a function that transforms a v1.ListOptions.\ntype TweakListOptionsFunc func(*v1.ListOptions)\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/sandbox/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage sandbox\n\nimport (\n\tinternalinterfaces \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/internalinterfaces\"\n\tv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/sandbox/v1alpha1\"\n)\n\n// Interface provides access to each of this group's versions.\ntype Interface interface {\n\t// V1alpha1 provides access to shared informers for resources in V1alpha1.\n\tV1alpha1() v1alpha1.Interface\n}\n\ntype group struct {\n\tfactory          internalinterfaces.SharedInformerFactory\n\tnamespace        string\n\ttweakListOptions internalinterfaces.TweakListOptionsFunc\n}\n\n// New returns a new Interface.\nfunc New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {\n\treturn &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}\n}\n\n// V1alpha1 returns a new v1alpha1.Interface.\nfunc (g *group) V1alpha1() v1alpha1.Interface {\n\treturn v1alpha1.New(g.factory, g.namespace, g.tweakListOptions)\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/sandbox/v1alpha1/batchsandbox.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tcontext \"context\"\n\ttime \"time\"\n\n\tapissandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tversioned \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tinternalinterfaces \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/internalinterfaces\"\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\twatch \"k8s.io/apimachinery/pkg/watch\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// BatchSandboxInformer provides access to a shared informer and lister for\n// BatchSandboxes.\ntype BatchSandboxInformer interface {\n\tInformer() cache.SharedIndexInformer\n\tLister() sandboxv1alpha1.BatchSandboxLister\n}\n\ntype batchSandboxInformer struct {\n\tfactory          internalinterfaces.SharedInformerFactory\n\ttweakListOptions internalinterfaces.TweakListOptionsFunc\n\tnamespace        string\n}\n\n// NewBatchSandboxInformer constructs a new informer for BatchSandbox type.\n// Always prefer using an informer factory to get a shared informer instead of getting an independent\n// one. This reduces memory footprint and number of connections to the server.\nfunc NewBatchSandboxInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {\n\treturn NewFilteredBatchSandboxInformer(client, namespace, resyncPeriod, indexers, nil)\n}\n\n// NewFilteredBatchSandboxInformer constructs a new informer for BatchSandbox type.\n// Always prefer using an informer factory to get a shared informer instead of getting an independent\n// one. This reduces memory footprint and number of connections to the server.\nfunc NewFilteredBatchSandboxInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {\n\treturn cache.NewSharedIndexInformer(\n\t\t&cache.ListWatch{\n\t\t\tListFunc: func(options v1.ListOptions) (runtime.Object, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().BatchSandboxes(namespace).List(context.Background(), options)\n\t\t\t},\n\t\t\tWatchFunc: func(options v1.ListOptions) (watch.Interface, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().BatchSandboxes(namespace).Watch(context.Background(), options)\n\t\t\t},\n\t\t\tListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().BatchSandboxes(namespace).List(ctx, options)\n\t\t\t},\n\t\t\tWatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().BatchSandboxes(namespace).Watch(ctx, options)\n\t\t\t},\n\t\t},\n\t\t&apissandboxv1alpha1.BatchSandbox{},\n\t\tresyncPeriod,\n\t\tindexers,\n\t)\n}\n\nfunc (f *batchSandboxInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {\n\treturn NewFilteredBatchSandboxInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)\n}\n\nfunc (f *batchSandboxInformer) Informer() cache.SharedIndexInformer {\n\treturn f.factory.InformerFor(&apissandboxv1alpha1.BatchSandbox{}, f.defaultInformer)\n}\n\nfunc (f *batchSandboxInformer) Lister() sandboxv1alpha1.BatchSandboxLister {\n\treturn sandboxv1alpha1.NewBatchSandboxLister(f.Informer().GetIndexer())\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/sandbox/v1alpha1/interface.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tinternalinterfaces \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/internalinterfaces\"\n)\n\n// Interface provides access to all the informers in this group version.\ntype Interface interface {\n\t// BatchSandboxes returns a BatchSandboxInformer.\n\tBatchSandboxes() BatchSandboxInformer\n\t// Pools returns a PoolInformer.\n\tPools() PoolInformer\n}\n\ntype version struct {\n\tfactory          internalinterfaces.SharedInformerFactory\n\tnamespace        string\n\ttweakListOptions internalinterfaces.TweakListOptionsFunc\n}\n\n// New returns a new Interface.\nfunc New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface {\n\treturn &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}\n}\n\n// BatchSandboxes returns a BatchSandboxInformer.\nfunc (v *version) BatchSandboxes() BatchSandboxInformer {\n\treturn &batchSandboxInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}\n}\n\n// Pools returns a PoolInformer.\nfunc (v *version) Pools() PoolInformer {\n\treturn &poolInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/informers/externalversions/sandbox/v1alpha1/pool.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by informer-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tcontext \"context\"\n\ttime \"time\"\n\n\tapissandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tversioned \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned\"\n\tinternalinterfaces \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions/internalinterfaces\"\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1\"\n\tv1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\twatch \"k8s.io/apimachinery/pkg/watch\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// PoolInformer provides access to a shared informer and lister for\n// Pools.\ntype PoolInformer interface {\n\tInformer() cache.SharedIndexInformer\n\tLister() sandboxv1alpha1.PoolLister\n}\n\ntype poolInformer struct {\n\tfactory          internalinterfaces.SharedInformerFactory\n\ttweakListOptions internalinterfaces.TweakListOptionsFunc\n\tnamespace        string\n}\n\n// NewPoolInformer constructs a new informer for Pool type.\n// Always prefer using an informer factory to get a shared informer instead of getting an independent\n// one. This reduces memory footprint and number of connections to the server.\nfunc NewPoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {\n\treturn NewFilteredPoolInformer(client, namespace, resyncPeriod, indexers, nil)\n}\n\n// NewFilteredPoolInformer constructs a new informer for Pool type.\n// Always prefer using an informer factory to get a shared informer instead of getting an independent\n// one. This reduces memory footprint and number of connections to the server.\nfunc NewFilteredPoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {\n\treturn cache.NewSharedIndexInformer(\n\t\t&cache.ListWatch{\n\t\t\tListFunc: func(options v1.ListOptions) (runtime.Object, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().Pools(namespace).List(context.Background(), options)\n\t\t\t},\n\t\t\tWatchFunc: func(options v1.ListOptions) (watch.Interface, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().Pools(namespace).Watch(context.Background(), options)\n\t\t\t},\n\t\t\tListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().Pools(namespace).List(ctx, options)\n\t\t\t},\n\t\t\tWatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) {\n\t\t\t\tif tweakListOptions != nil {\n\t\t\t\t\ttweakListOptions(&options)\n\t\t\t\t}\n\t\t\t\treturn client.SandboxV1alpha1().Pools(namespace).Watch(ctx, options)\n\t\t\t},\n\t\t},\n\t\t&apissandboxv1alpha1.Pool{},\n\t\tresyncPeriod,\n\t\tindexers,\n\t)\n}\n\nfunc (f *poolInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {\n\treturn NewFilteredPoolInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)\n}\n\nfunc (f *poolInformer) Informer() cache.SharedIndexInformer {\n\treturn f.factory.InformerFor(&apissandboxv1alpha1.Pool{}, f.defaultInformer)\n}\n\nfunc (f *poolInformer) Lister() sandboxv1alpha1.PoolLister {\n\treturn sandboxv1alpha1.NewPoolLister(f.Informer().GetIndexer())\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/listers/sandbox/v1alpha1/batchsandbox.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by lister-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tlabels \"k8s.io/apimachinery/pkg/labels\"\n\tlisters \"k8s.io/client-go/listers\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// BatchSandboxLister helps list BatchSandboxes.\n// All objects returned here must be treated as read-only.\ntype BatchSandboxLister interface {\n\t// List lists all BatchSandboxes in the indexer.\n\t// Objects returned here must be treated as read-only.\n\tList(selector labels.Selector) (ret []*sandboxv1alpha1.BatchSandbox, err error)\n\t// BatchSandboxes returns an object that can list and get BatchSandboxes.\n\tBatchSandboxes(namespace string) BatchSandboxNamespaceLister\n\tBatchSandboxListerExpansion\n}\n\n// batchSandboxLister implements the BatchSandboxLister interface.\ntype batchSandboxLister struct {\n\tlisters.ResourceIndexer[*sandboxv1alpha1.BatchSandbox]\n}\n\n// NewBatchSandboxLister returns a new BatchSandboxLister.\nfunc NewBatchSandboxLister(indexer cache.Indexer) BatchSandboxLister {\n\treturn &batchSandboxLister{listers.New[*sandboxv1alpha1.BatchSandbox](indexer, sandboxv1alpha1.Resource(\"batchsandbox\"))}\n}\n\n// BatchSandboxes returns an object that can list and get BatchSandboxes.\nfunc (s *batchSandboxLister) BatchSandboxes(namespace string) BatchSandboxNamespaceLister {\n\treturn batchSandboxNamespaceLister{listers.NewNamespaced[*sandboxv1alpha1.BatchSandbox](s.ResourceIndexer, namespace)}\n}\n\n// BatchSandboxNamespaceLister helps list and get BatchSandboxes.\n// All objects returned here must be treated as read-only.\ntype BatchSandboxNamespaceLister interface {\n\t// List lists all BatchSandboxes in the indexer for a given namespace.\n\t// Objects returned here must be treated as read-only.\n\tList(selector labels.Selector) (ret []*sandboxv1alpha1.BatchSandbox, err error)\n\t// Get retrieves the BatchSandbox from the indexer for a given namespace and name.\n\t// Objects returned here must be treated as read-only.\n\tGet(name string) (*sandboxv1alpha1.BatchSandbox, error)\n\tBatchSandboxNamespaceListerExpansion\n}\n\n// batchSandboxNamespaceLister implements the BatchSandboxNamespaceLister\n// interface.\ntype batchSandboxNamespaceLister struct {\n\tlisters.ResourceIndexer[*sandboxv1alpha1.BatchSandbox]\n}\n"
  },
  {
    "path": "kubernetes/pkg/client/listers/sandbox/v1alpha1/expansion_generated.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by lister-gen. DO NOT EDIT.\n\npackage v1alpha1\n\n// BatchSandboxListerExpansion allows custom methods to be added to\n// BatchSandboxLister.\ntype BatchSandboxListerExpansion interface{}\n\n// BatchSandboxNamespaceListerExpansion allows custom methods to be added to\n// BatchSandboxNamespaceLister.\ntype BatchSandboxNamespaceListerExpansion interface{}\n\n// PoolListerExpansion allows custom methods to be added to\n// PoolLister.\ntype PoolListerExpansion interface{}\n\n// PoolNamespaceListerExpansion allows custom methods to be added to\n// PoolNamespaceLister.\ntype PoolNamespaceListerExpansion interface{}\n"
  },
  {
    "path": "kubernetes/pkg/client/listers/sandbox/v1alpha1/pool.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// Code generated by lister-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tlabels \"k8s.io/apimachinery/pkg/labels\"\n\tlisters \"k8s.io/client-go/listers\"\n\tcache \"k8s.io/client-go/tools/cache\"\n)\n\n// PoolLister helps list Pools.\n// All objects returned here must be treated as read-only.\ntype PoolLister interface {\n\t// List lists all Pools in the indexer.\n\t// Objects returned here must be treated as read-only.\n\tList(selector labels.Selector) (ret []*sandboxv1alpha1.Pool, err error)\n\t// Pools returns an object that can list and get Pools.\n\tPools(namespace string) PoolNamespaceLister\n\tPoolListerExpansion\n}\n\n// poolLister implements the PoolLister interface.\ntype poolLister struct {\n\tlisters.ResourceIndexer[*sandboxv1alpha1.Pool]\n}\n\n// NewPoolLister returns a new PoolLister.\nfunc NewPoolLister(indexer cache.Indexer) PoolLister {\n\treturn &poolLister{listers.New[*sandboxv1alpha1.Pool](indexer, sandboxv1alpha1.Resource(\"pool\"))}\n}\n\n// Pools returns an object that can list and get Pools.\nfunc (s *poolLister) Pools(namespace string) PoolNamespaceLister {\n\treturn poolNamespaceLister{listers.NewNamespaced[*sandboxv1alpha1.Pool](s.ResourceIndexer, namespace)}\n}\n\n// PoolNamespaceLister helps list and get Pools.\n// All objects returned here must be treated as read-only.\ntype PoolNamespaceLister interface {\n\t// List lists all Pools in the indexer for a given namespace.\n\t// Objects returned here must be treated as read-only.\n\tList(selector labels.Selector) (ret []*sandboxv1alpha1.Pool, err error)\n\t// Get retrieves the Pool from the indexer for a given namespace and name.\n\t// Objects returned here must be treated as read-only.\n\tGet(name string) (*sandboxv1alpha1.Pool, error)\n\tPoolNamespaceListerExpansion\n}\n\n// poolNamespaceLister implements the PoolNamespaceLister\n// interface.\ntype poolNamespaceLister struct {\n\tlisters.ResourceIndexer[*sandboxv1alpha1.Pool]\n}\n"
  },
  {
    "path": "kubernetes/pkg/task-executor/client.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage task_executor\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n)\n\ntype Client struct {\n\tbaseURL    string\n\thttpClient *http.Client\n}\n\nfunc NewClient(baseURL string) *Client {\n\tif baseURL == \"\" {\n\t\tklog.Warning(\"baseURL is empty, client may not work properly\")\n\t}\n\treturn &Client{\n\t\tbaseURL: baseURL,\n\t\thttpClient: &http.Client{\n\t\t\tTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// Set creates or updates a task on the remote server.\n// If task is nil, it sends a delete request.\nfunc (c *Client) Set(ctx context.Context, task *Task) (*Task, error) {\n\tif c == nil {\n\t\treturn nil, fmt.Errorf(\"client is nil\")\n\t}\n\n\tvar req *http.Request\n\tvar err error\n\n\tif task == nil {\n\t\t// Delete request - send nil to clear tasks\n\t\treq, err = http.NewRequestWithContext(ctx, \"POST\", c.baseURL+\"/setTasks\", bytes.NewReader([]byte(\"[]\")))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\t} else {\n\t\t// Create/Update request\n\t\tdata, err := json.Marshal([]Task{*task})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to marshal task: %w\", err)\n\t\t}\n\t\treq, err = http.NewRequestWithContext(ctx, \"POST\", c.baseURL+\"/setTasks\", bytes.NewReader(data))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t\t}\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// Send request with retry\n\tvar resp *http.Response\n\tresp, err = c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"network error after retries: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"server error: status=%d, body=%s\", resp.StatusCode, string(body))\n\t}\n\n\t// Parse response - expect array of tasks\n\tvar tasks []Task\n\tif err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\tif task != nil && len(tasks) > 0 {\n\t\t// Find the task we just set\n\t\tfor i := range tasks {\n\t\t\tif tasks[i].Name == task.Name {\n\t\t\t\treturn &tasks[i], nil\n\t\t\t}\n\t\t}\n\t}\n\n\tif task == nil {\n\t\t// Delete succeeded\n\t\treturn nil, nil\n\t}\n\n\treturn task, nil\n}\n\n// Get retrieves the current task list from the remote server.\nfunc (c *Client) Get(ctx context.Context) (*Task, error) {\n\tif c == nil {\n\t\treturn nil, fmt.Errorf(\"client is nil\")\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, \"GET\", c.baseURL+\"/getTasks\", nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create request: %w\", err)\n\t}\n\n\tresp, err := c.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"network error: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"server error: status=%d, body=%s\", resp.StatusCode, string(body))\n\t}\n\n\t// Parse response - expect array of tasks\n\tvar tasks []Task\n\tif err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to decode response: %w\", err)\n\t}\n\n\t// Return the first task (single task mode)\n\tif len(tasks) > 0 {\n\t\treturn &tasks[0], nil\n\t}\n\n\t// No tasks\n\treturn nil, nil\n}\n"
  },
  {
    "path": "kubernetes/pkg/task-executor/types.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage task_executor\n\nimport (\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\n// Task represents the internal local task resource (LocalTask)\n// It follows the Kubernetes resource model with Metadata, Spec, and Status.\ntype Task struct {\n\tName              string       `json:\"name\"`\n\tDeletionTimestamp *metav1.Time `json:\"deletionTimestamp,omitempty\"`\n\n\tProcess         *Process                `json:\"process,omitempty\"`\n\tPodTemplateSpec *corev1.PodTemplateSpec `json:\"podTemplateSpec,omitempty\"`\n\n\tProcessStatus *ProcessStatus    `json:\"processStatus,omitempty\"`\n\tPodStatus     *corev1.PodStatus `json:\"podStatus,omitempty\"`\n}\n\ntype Process struct {\n\t// Command command\n\tCommand []string `json:\"command\"`\n\t// Arguments to the entrypoint.\n\tArgs []string `json:\"args,omitempty\"`\n\t// List of environment variables to set in the process.\n\tEnv []corev1.EnvVar `json:\"env,omitempty\"`\n\t// WorkingDir process working directory.\n\tWorkingDir string `json:\"workingDir,omitempty\"`\n\t// TimeoutSeconds process timeout seconds.\n\tTimeoutSeconds *int64 `json:\"timeoutSeconds,omitempty\"`\n}\n\n// ProcessStatus holds a possible state of process.\n// Only one of its members may be specified.\n// If none of them is specified, the default one is Waiting.\ntype ProcessStatus struct {\n\t// Details about a waiting process\n\t// +optional\n\tWaiting *Waiting `json:\"waiting,omitempty\"`\n\t// Details about a running process\n\t// +optional\n\tRunning *Running `json:\"running,omitempty\"`\n\t// Details about a terminated process\n\t// +optional\n\tTerminated *Terminated `json:\"terminated,omitempty\"`\n}\n\n// Waiting is a waiting state of a process.\ntype Waiting struct {\n\t// (brief) reason the process is not yet running.\n\t// +optional\n\tReason string `json:\"reason,omitempty\"`\n\t// Message regarding why the process is not yet running.\n\t// +optional\n\tMessage string `json:\"message,omitempty\"`\n}\n\n// Running is a running state of a process.\ntype Running struct {\n\t// Time at which the process was last (re-)started\n\t// +optional\n\tStartedAt metav1.Time `json:\"startedAt\"`\n}\n\n// Terminated is a terminated state of a process.\ntype Terminated struct {\n\t// Exit status from the last termination of the process\n\tExitCode int32 `json:\"exitCode\"`\n\t// Signal from the last termination of the process\n\t// +optional\n\tSignal int32 `json:\"signal,omitempty\"`\n\t// (brief) reason from the last termination of the process\n\t// +optional\n\tReason string `json:\"reason,omitempty\"`\n\t// Message regarding the last termination of the process\n\t// +optional\n\tMessage string `json:\"message,omitempty\"`\n\t// Time at which previous execution of the process started\n\t// +optional\n\tStartedAt metav1.Time `json:\"startedAt,omitempty\"`\n\t// Time at which the process last terminated\n\t// +optional\n\tFinishedAt metav1.Time `json:\"finishedAt,omitempty\"`\n}\n"
  },
  {
    "path": "kubernetes/pkg/utils/endpoints.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n)\n\nconst (\n\t// AnnotationEndpoints is the annotation key for storing BatchSandbox endpoints\n\tAnnotationEndpoints = \"sandbox.opensandbox.io/endpoints\"\n)\n\n// GetEndpoints extracts endpoint IPs from BatchSandbox annotations\n// Returns a slice of IP addresses parsed from the endpoints annotation\n// The annotation format is a JSON array: [\"10.244.1.5\", \"10.244.1.6\"]\nfunc GetEndpoints(bs *sandboxv1alpha1.BatchSandbox) ([]string, error) {\n\tif bs == nil {\n\t\treturn nil, fmt.Errorf(\"BatchSandbox is nil\")\n\t}\n\n\tif bs.Annotations == nil {\n\t\treturn nil, fmt.Errorf(\"BatchSandbox has no annotations\")\n\t}\n\n\tendpointsAnnotation := bs.Annotations[AnnotationEndpoints]\n\tif endpointsAnnotation == \"\" {\n\t\treturn nil, fmt.Errorf(\"missing %s annotation\", AnnotationEndpoints)\n\t}\n\n\tvar endpoints []string\n\tif err := json.Unmarshal([]byte(endpointsAnnotation), &endpoints); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse endpoints annotation: %w\", err)\n\t}\n\n\tif len(endpoints) == 0 {\n\t\treturn nil, fmt.Errorf(\"endpoints annotation contains no IPs\")\n\t}\n\n\treturn endpoints, nil\n}\n"
  },
  {
    "path": "kubernetes/pkg/utils/endpoints_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"testing\"\n\n\tsandboxv1alpha1 \"github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\nfunc TestGetEndpoints(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tbs            *sandboxv1alpha1.BatchSandbox\n\t\texpectedIPs   []string\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname:          \"nil BatchSandbox\",\n\t\t\tbs:            nil,\n\t\t\texpectedIPs:   nil,\n\t\t\texpectedError: \"BatchSandbox is nil\",\n\t\t},\n\t\t{\n\t\t\tname: \"no annotations\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   nil,\n\t\t\texpectedError: \"has no annotations\",\n\t\t},\n\t\t{\n\t\t\tname: \"missing endpoints annotation\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"other-key\": \"other-value\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   nil,\n\t\t\texpectedError: \"missing sandbox.opensandbox.io/endpoints annotation\",\n\t\t},\n\t\t{\n\t\t\tname: \"invalid JSON annotation\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tAnnotationEndpoints: \"invalid-json\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   nil,\n\t\t\texpectedError: \"failed to parse endpoints annotation\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty endpoints array\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tAnnotationEndpoints: \"[]\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   nil,\n\t\t\texpectedError: \"contains no IPs\",\n\t\t},\n\t\t{\n\t\t\tname: \"single endpoint\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tAnnotationEndpoints: `[\"10.244.1.5\"]`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   []string{\"10.244.1.5\"},\n\t\t\texpectedError: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"multiple endpoints\",\n\t\t\tbs: &sandboxv1alpha1.BatchSandbox{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-sandbox\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tAnnotationEndpoints: `[\"10.244.1.5\", \"10.244.1.6\", \"10.244.1.7\"]`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIPs:   []string{\"10.244.1.5\", \"10.244.1.6\", \"10.244.1.7\"},\n\t\t\texpectedError: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tips, err := GetEndpoints(tt.bs)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error containing %q, got nil\", tt.expectedError)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif err.Error() == \"\" || !contains(err.Error(), tt.expectedError) {\n\t\t\t\t\tt.Errorf(\"expected error containing %q, got %q\", tt.expectedError, err.Error())\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(ips) != len(tt.expectedIPs) {\n\t\t\t\tt.Errorf(\"expected %d IPs, got %d\", len(tt.expectedIPs), len(ips))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfor i, ip := range ips {\n\t\t\t\tif ip != tt.expectedIPs[i] {\n\t\t\t\t\tt.Errorf(\"expected IP[%d]=%s, got %s\", i, tt.expectedIPs[i], ip)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc contains(s, substr string) bool {\n\treturn len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&\n\t\t(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||\n\t\t\tlen(s) > len(substr) && findSubstr(s, substr)))\n}\n\nfunc findSubstr(s, substr string) bool {\n\tfor i := 0; i <= len(s)-len(substr); i++ {\n\t\tif s[i:i+len(substr)] == substr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "kubernetes/test/e2e/e2e_suite_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage e2e\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/test/utils\"\n)\n\n// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated,\n// temporary environment to validate project changes with the purposed to be used in CI jobs.\n// The default setup requires Kind, builds/loads the Manager Docker image locally.\nfunc TestE2E(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\t_, _ = fmt.Fprintf(GinkgoWriter, \"Starting sandbox-k8s integration test suite\\n\")\n\tRunSpecs(t, \"e2e suite\")\n}\n\nvar _ = BeforeSuite(func() {\n\tdockerBuildArgs := os.Getenv(\"DOCKER_BUILD_ARGS\")\n\n\tBy(\"building the manager(Operator) image\")\n\tmakeArgs := []string{\"docker-build\", fmt.Sprintf(\"CONTROLLER_IMG=%s\", utils.ControllerImage)}\n\tif dockerBuildArgs != \"\" {\n\t\tmakeArgs = append(makeArgs, fmt.Sprintf(\"DOCKER_BUILD_ARGS=%s\", dockerBuildArgs))\n\t}\n\tcmd := exec.Command(\"make\", makeArgs...)\n\t_, err := utils.Run(cmd)\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to build the manager(Operator) image\")\n\n\tBy(\"building the task-executor image\")\n\tmakeArgs = []string{\"docker-build-task-executor\", fmt.Sprintf(\"TASK_EXECUTOR_IMG=%s\", utils.TaskExecutorImage)}\n\tif dockerBuildArgs != \"\" {\n\t\tmakeArgs = append(makeArgs, fmt.Sprintf(\"DOCKER_BUILD_ARGS=%s\", dockerBuildArgs))\n\t}\n\tcmd = exec.Command(\"make\", makeArgs...)\n\t_, err = utils.Run(cmd)\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to build the task-executor image\")\n\n\t// If you want to change the e2e test vendor from Kind, ensure the image is\n\t// built and available before running the tests. Also, remove the following block.\n\tBy(\"loading the manager(Operator) image on Kind\")\n\terr = utils.LoadImageToKindClusterWithName(utils.ControllerImage)\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to load the manager(Operator) image into Kind\")\n\n\tBy(\"loading the task-executor image on Kind\")\n\terr = utils.LoadImageToKindClusterWithName(utils.TaskExecutorImage)\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to load the task-executor image into Kind\")\n})\n\nvar _ = AfterSuite(func() {\n})\n"
  },
  {
    "path": "kubernetes/test/e2e/e2e_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage e2e\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/test/utils\"\n)\n\n// namespace where the project is deployed in\nconst namespace = \"opensandbox-system\"\n\nvar _ = Describe(\"Manager\", Ordered, func() {\n\tvar controllerPodName string\n\n\t// Before running the tests, set up the environment by creating the namespace,\n\t// enforce the restricted security policy to the namespace, installing CRDs,\n\t// and deploying the controller.\n\tBeforeAll(func() {\n\t\tBy(\"creating manager namespace\")\n\t\tcmd := exec.Command(\"kubectl\", \"create\", \"ns\", namespace)\n\t\t_, err := utils.Run(cmd)\n\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create namespace\")\n\n\t\tBy(\"labeling the namespace to enforce the restricted security policy\")\n\t\tcmd = exec.Command(\"kubectl\", \"label\", \"--overwrite\", \"ns\", namespace,\n\t\t\t\"pod-security.kubernetes.io/enforce=restricted\")\n\t\t_, err = utils.Run(cmd)\n\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to label namespace with restricted policy\")\n\n\t\tBy(\"installing CRDs\")\n\t\tcmd = exec.Command(\"make\", \"install\")\n\t\t_, err = utils.Run(cmd)\n\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to install CRDs\")\n\n\t\tBy(\"deploying the controller-manager\")\n\t\tcmd = exec.Command(\"make\", \"deploy\", fmt.Sprintf(\"CONTROLLER_IMG=%s\", utils.ControllerImage))\n\t\t_, err = utils.Run(cmd)\n\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to deploy the controller-manager\")\n\t})\n\n\t// After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,\n\t// and deleting the namespace.\n\tAfterAll(func() {\n\t\tBy(\"cleaning up the curl pod for metrics\")\n\t\tcmd := exec.Command(\"kubectl\", \"delete\", \"pod\", \"curl-metrics\", \"-n\", namespace)\n\t\t_, _ = utils.Run(cmd)\n\n\t\tBy(\"undeploying the controller-manager\")\n\t\tcmd = exec.Command(\"make\", \"undeploy\")\n\t\t_, _ = utils.Run(cmd)\n\n\t\tBy(\"uninstalling CRDs\")\n\t\tcmd = exec.Command(\"make\", \"uninstall\")\n\t\t_, _ = utils.Run(cmd)\n\n\t\tBy(\"removing manager namespace\")\n\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"ns\", namespace)\n\t\t_, _ = utils.Run(cmd)\n\t})\n\n\t// After each test, check for failures and collect logs, events,\n\t// and pod descriptions for debugging.\n\tAfterEach(func() {\n\t\tspecReport := CurrentSpecReport()\n\t\tif specReport.Failed() {\n\t\t\tBy(\"Fetching controller manager pod logs\")\n\t\t\tcmd := exec.Command(\"kubectl\", \"logs\", controllerPodName, \"-n\", namespace)\n\t\t\tcontrollerLogs, err := utils.Run(cmd)\n\t\t\tif err == nil {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Controller logs:\\n %s\", controllerLogs)\n\t\t\t} else {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Failed to get Controller logs: %s\", err)\n\t\t\t}\n\n\t\t\tBy(\"Fetching Kubernetes events\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"events\", \"-n\", namespace, \"--sort-by=.lastTimestamp\")\n\t\t\teventsOutput, err := utils.Run(cmd)\n\t\t\tif err == nil {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Kubernetes events:\\n%s\", eventsOutput)\n\t\t\t} else {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Failed to get Kubernetes events: %s\", err)\n\t\t\t}\n\n\t\t\tBy(\"Fetching curl-metrics logs\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"logs\", \"curl-metrics\", \"-n\", namespace)\n\t\t\tmetricsOutput, err := utils.Run(cmd)\n\t\t\tif err == nil {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Metrics logs:\\n %s\", metricsOutput)\n\t\t\t} else {\n\t\t\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"Failed to get curl-metrics logs: %s\", err)\n\t\t\t}\n\n\t\t\tBy(\"Fetching controller manager pod description\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"describe\", \"pod\", controllerPodName, \"-n\", namespace)\n\t\t\tpodDescription, err := utils.Run(cmd)\n\t\t\tif err == nil {\n\t\t\t\tfmt.Println(\"Pod description:\\n\", podDescription)\n\t\t\t} else {\n\t\t\t\tfmt.Println(\"Failed to describe controller pod\")\n\t\t\t}\n\t\t}\n\t})\n\n\tSetDefaultEventuallyTimeout(2 * time.Minute)\n\tSetDefaultEventuallyPollingInterval(time.Second)\n\n\tContext(\"Manager\", func() {\n\t\tIt(\"should run successfully\", func() {\n\t\t\tBy(\"validating that the controller-manager pod is running as expected\")\n\t\t\tverifyControllerUp := func(g Gomega) {\n\t\t\t\t// Get the name of the controller-manager pod\n\t\t\t\tgoTemplate := `{{ range .items }}` +\n\t\t\t\t\t`{{ if not .metadata.deletionTimestamp }}` +\n\t\t\t\t\t`{{ .metadata.name }}` +\n\t\t\t\t\t`{{ \"\\n\" }}{{ end }}{{ end }}`\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\",\n\t\t\t\t\t\"pods\", \"-l\", \"control-plane=controller-manager\",\n\t\t\t\t\t\"-o\", \"go-template=\"+goTemplate,\n\t\t\t\t\t\"-n\", namespace,\n\t\t\t\t)\n\n\t\t\t\tpodOutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred(), \"Failed to retrieve controller-manager pod information\")\n\t\t\t\tpodNames := utils.GetNonEmptyLines(podOutput)\n\t\t\t\tg.Expect(podNames).To(HaveLen(1), \"expected 1 controller pod running\")\n\t\t\t\tcontrollerPodName = podNames[0]\n\t\t\t\tg.Expect(controllerPodName).To(ContainSubstring(\"controller-manager\"))\n\n\t\t\t\t// Validate the pod's status\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\",\n\t\t\t\t\t\"pods\", controllerPodName, \"-o\", \"jsonpath={.status.phase}\",\n\t\t\t\t\t\"-n\", namespace,\n\t\t\t\t)\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"), \"Incorrect controller-manager pod status\")\n\t\t\t}\n\t\t\tEventually(verifyControllerUp).Should(Succeed())\n\t\t})\n\t})\n\n\tContext(\"Pool\", func() {\n\t\tBeforeAll(func() {\n\t\t\tBy(\"waiting for controller to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-l\", \"control-plane=controller-manager\",\n\t\t\t\t\t\"-n\", namespace, \"-o\", \"jsonpath={.items[0].status.phase}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should correctly create pods and maintain pool status\", func() {\n\t\t\tconst poolName = \"test-pool-basic\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst poolMin = 2\n\t\t\tconst poolMax = 5\n\t\t\tconst bufferMin = 1\n\t\t\tconst bufferMax = 3\n\n\t\t\tBy(\"creating a basic Pool\")\n\t\t\tpoolYAML, err := renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    bufferMax,\n\t\t\t\t\"BufferMin\":    bufferMin,\n\t\t\t\t\"PoolMax\":      poolMax,\n\t\t\t\t\"PoolMin\":      poolMin,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool-basic.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create Pool\")\n\n\t\t\tBy(\"verifying Pool creates pods and maintains correct status\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status}\")\n\t\t\t\tstatusOutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(`\"total\":`), \"Pool status should have total field\")\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(`\"allocated\":`), \"Pool status should have allocated field\")\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(`\"available\":`), \"Pool status should have available field\")\n\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\ttotal := 0\n\t\t\t\tif totalStr != \"\" {\n\t\t\t\t\tfmt.Sscanf(totalStr, \"%d\", &total)\n\t\t\t\t}\n\t\t\t\tg.Expect(total).To(BeNumerically(\">=\", poolMin), \"Pool total should be >= poolMin\")\n\t\t\t\tg.Expect(total).To(BeNumerically(\"<=\", poolMax), \"Pool total should be <= poolMax\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying pods are created\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\t\"-l\", fmt.Sprintf(\"sandbox.opensandbox.io/pool-name=%s\", poolName),\n\t\t\t\t\t\"-o\", \"jsonpath={.items[*].metadata.name}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty(), \"Pool should create pods\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"cleaning up the Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t})\n\n\t\tIt(\"should correctly manage capacity when poolMin and poolMax change\", func() {\n\t\t\tconst poolName = \"test-pool-capacity\"\n\t\t\tconst testNamespace = \"default\"\n\n\t\t\tBy(\"creating a Pool with initial capacity\")\n\t\t\tpoolYAML, err := renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    1,\n\t\t\t\t\"PoolMax\":      5,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool-capacity.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"waiting for initial Pool to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\ttotal := 0\n\t\t\t\tif totalStr != \"\" {\n\t\t\t\t\tfmt.Sscanf(totalStr, \"%d\", &total)\n\t\t\t\t}\n\t\t\t\tg.Expect(total).To(BeNumerically(\">=\", 2))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"increasing poolMin to trigger scale up\")\n\t\t\tpoolYAML, err = renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    1,\n\t\t\t\t\"PoolMax\":      10,\n\t\t\t\t\"PoolMin\":      5,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying Pool scales up to meet new poolMin\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\ttotal := 0\n\t\t\t\tif totalStr != \"\" {\n\t\t\t\t\tfmt.Sscanf(totalStr, \"%d\", &total)\n\t\t\t\t}\n\t\t\t\tg.Expect(total).To(BeNumerically(\">=\", 5), \"Pool should scale up to meet poolMin=5\")\n\t\t\t\tg.Expect(total).To(BeNumerically(\"<=\", 10), \"Pool should not exceed poolMax=10\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"decreasing poolMax to below current total\")\n\t\t\tpoolYAML, err = renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    2,\n\t\t\t\t\"BufferMin\":    1,\n\t\t\t\t\"PoolMax\":      3,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying Pool respects new poolMax constraint\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\ttotal := 0\n\t\t\t\tif totalStr != \"\" {\n\t\t\t\t\tfmt.Sscanf(totalStr, \"%d\", &total)\n\t\t\t\t}\n\t\t\t\tg.Expect(total).To(BeNumerically(\"<=\", 3), \"Pool should scale down to meet poolMax=3\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"cleaning up the Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t})\n\n\t\tIt(\"should upgrade pool template correctly\", func() {\n\t\t\tconst poolName = \"test-pool-upgrade\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst batchSandboxName = \"test-bs-for-upgrade\"\n\n\t\t\tBy(\"creating a Pool with initial template\")\n\t\t\tpoolYAML, err := renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    2,\n\t\t\t\t\"PoolMax\":      5,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool-upgrade.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"waiting for Pool to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(totalStr).NotTo(BeEmpty())\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"allocating a pod from the pool via BatchSandbox\")\n\t\t\tbatchSandboxYAML, err := renderTemplate(\"testdata/batchsandbox-pooled-no-expire.yaml\", map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\": batchSandboxName,\n\t\t\t\t\"Namespace\":        testNamespace,\n\t\t\t\t\"Replicas\":         1,\n\t\t\t\t\"PoolName\":         poolName,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbsFile := filepath.Join(\"/tmp\", \"test-bs-upgrade.yaml\")\n\t\t\terr = os.WriteFile(bsFile, []byte(batchSandboxYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsFile)\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", bsFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"waiting for BatchSandbox to allocate pod\")\n\t\t\tvar allocatedPodNames []string\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"1\"))\n\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/alloc-status}\")\n\t\t\t\tallocStatusJSON, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(allocStatusJSON).NotTo(BeEmpty(), \"alloc-status annotation should exist\")\n\n\t\t\t\tvar allocStatus struct {\n\t\t\t\t\tPods []string `json:\"pods\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(allocStatusJSON), &allocStatus)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tallocatedPodNames = allocStatus.Pods\n\t\t\t\tg.Expect(len(allocatedPodNames)).To(Equal(1), \"Should have 1 allocated pod\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"getting all pool pods\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\"-l\", fmt.Sprintf(\"sandbox.opensandbox.io/pool-name=%s\", poolName),\n\t\t\t\t\"-o\", \"jsonpath={.items[*].metadata.name}\")\n\t\t\tallPoolPodsStr, err := utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tallPoolPods := strings.Fields(allPoolPodsStr)\n\n\t\t\tBy(\"calculating available pods (all pool pods - allocated pods)\")\n\t\t\tavailablePodsBeforeUpgrade := []string{}\n\t\t\tallocatedPodMap := make(map[string]bool)\n\t\t\tfor _, podName := range allocatedPodNames {\n\t\t\t\tallocatedPodMap[podName] = true\n\t\t\t}\n\t\t\tfor _, podName := range allPoolPods {\n\t\t\t\tif !allocatedPodMap[podName] {\n\t\t\t\t\tavailablePodsBeforeUpgrade = append(availablePodsBeforeUpgrade, podName)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tBy(\"updating Pool template with new environment variable\")\n\t\t\tupdatedPoolYAML, err := renderTemplate(\"testdata/pool-with-env.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    2,\n\t\t\t\t\"PoolMax\":      5,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\terr = os.WriteFile(poolFile, []byte(updatedPoolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying allocated pod is NOT upgraded\")\n\t\t\tConsistently(func(g Gomega) {\n\t\t\t\tfor _, allocatedPod := range allocatedPodNames {\n\t\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pod\", allocatedPod, \"-n\", testNamespace,\n\t\t\t\t\t\t\"-o\", \"jsonpath={.metadata.name}\")\n\t\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\t\tg.Expect(output).To(Equal(allocatedPod), \"Allocated pod should not be recreated\")\n\t\t\t\t}\n\t\t\t}, 30*time.Second, 3*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying available pods are recreated with new template\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\t\"-l\", fmt.Sprintf(\"sandbox.opensandbox.io/pool-name=%s\", poolName),\n\t\t\t\t\t\"-o\", \"jsonpath={.items[*].metadata.name}\")\n\t\t\t\tallPodsAfterStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tallPodsAfter := strings.Fields(allPodsAfterStr)\n\n\t\t\t\t// Get currently allocated pods\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/alloc-status}\")\n\t\t\t\tallocStatusJSON, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar allocStatus struct {\n\t\t\t\t\tPods []string `json:\"pods\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(allocStatusJSON), &allocStatus)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tcurrentAllocatedPods := make(map[string]bool)\n\t\t\t\tfor _, podName := range allocStatus.Pods {\n\t\t\t\t\tcurrentAllocatedPods[podName] = true\n\t\t\t\t}\n\n\t\t\t\t// Calculate available pods after upgrade\n\t\t\t\tavailablePodsAfterUpgrade := []string{}\n\t\t\t\tfor _, podName := range allPodsAfter {\n\t\t\t\t\tif !currentAllocatedPods[podName] {\n\t\t\t\t\t\tavailablePodsAfterUpgrade = append(availablePodsAfterUpgrade, podName)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Check if at least one available pod was recreated\n\t\t\t\trecreated := false\n\t\t\t\tfor _, oldPod := range availablePodsBeforeUpgrade {\n\t\t\t\t\tfound := false\n\t\t\t\t\tfor _, newPod := range availablePodsAfterUpgrade {\n\t\t\t\t\t\tif oldPod == newPod {\n\t\t\t\t\t\t\tfound = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !found {\n\t\t\t\t\t\trecreated = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(recreated).To(BeTrue(), \"At least one available pod should be recreated\")\n\t\t\t}, 3*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying new pods have the upgraded environment variable\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\t\"-l\", fmt.Sprintf(\"sandbox.opensandbox.io/pool-name=%s\", poolName),\n\t\t\t\t\t\"-o\", \"json\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar podList struct {\n\t\t\t\t\tItems []struct {\n\t\t\t\t\t\tMetadata struct {\n\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t} `json:\"metadata\"`\n\t\t\t\t\t\tSpec struct {\n\t\t\t\t\t\t\tContainers []struct {\n\t\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t\t\tEnv  []struct {\n\t\t\t\t\t\t\t\t\tName  string `json:\"name\"`\n\t\t\t\t\t\t\t\t\tValue string `json:\"value\"`\n\t\t\t\t\t\t\t\t} `json:\"env\"`\n\t\t\t\t\t\t\t} `json:\"containers\"`\n\t\t\t\t\t\t} `json:\"spec\"`\n\t\t\t\t\t} `json:\"items\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &podList)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\t// Get currently allocated pods\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/alloc-status}\")\n\t\t\t\tallocStatusJSON, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar allocStatus struct {\n\t\t\t\t\tPods []string `json:\"pods\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(allocStatusJSON), &allocStatus)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tallocatedPodMap := make(map[string]bool)\n\t\t\t\tfor _, podName := range allocStatus.Pods {\n\t\t\t\t\tallocatedPodMap[podName] = true\n\t\t\t\t}\n\n\t\t\t\t// Find at least one available pod with UPGRADED=true\n\t\t\t\tfoundUpgraded := false\n\t\t\t\tfor _, pod := range podList.Items {\n\t\t\t\t\tif !allocatedPodMap[pod.Metadata.Name] {\n\t\t\t\t\t\t// This is an available pod\n\t\t\t\t\t\tfor _, container := range pod.Spec.Containers {\n\t\t\t\t\t\t\tif container.Name == \"sandbox-container\" {\n\t\t\t\t\t\t\t\tfor _, env := range container.Env {\n\t\t\t\t\t\t\t\t\tif env.Name == \"UPGRADED\" && env.Value == \"true\" {\n\t\t\t\t\t\t\t\t\t\tfoundUpgraded = true\n\t\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(foundUpgraded).To(BeTrue(), \"At least one available pod should have UPGRADED=true env var\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"cleaning up BatchSandbox and Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t_, _ = utils.Run(cmd)\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t})\n\t})\n\n\tContext(\"BatchSandbox\", func() {\n\t\tBeforeAll(func() {\n\t\t\tBy(\"waiting for controller to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-l\", \"control-plane=controller-manager\",\n\t\t\t\t\t\"-n\", namespace, \"-o\", \"jsonpath={.items[0].status.phase}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should work correctly in non-pooled mode\", func() {\n\t\t\tconst batchSandboxName = \"test-bs-non-pooled\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst replicas = 2\n\n\t\t\tBy(\"creating a non-pooled BatchSandbox\")\n\t\t\tbsYAML, err := renderTemplate(\"testdata/batchsandbox-non-pooled.yaml\", map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\": batchSandboxName,\n\t\t\t\t\"SandboxImage\":     utils.SandboxImage,\n\t\t\t\t\"Namespace\":        testNamespace,\n\t\t\t\t\"Replicas\":         replicas,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbsFile := filepath.Join(\"/tmp\", \"test-bs-non-pooled.yaml\")\n\t\t\terr = os.WriteFile(bsFile, []byte(bsYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", bsFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying pods are created directly from template\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"json\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar podList struct {\n\t\t\t\t\tItems []struct {\n\t\t\t\t\t\tMetadata struct {\n\t\t\t\t\t\t\tName            string `json:\"name\"`\n\t\t\t\t\t\t\tOwnerReferences []struct {\n\t\t\t\t\t\t\t\tKind string `json:\"kind\"`\n\t\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t\t\tUID  string `json:\"uid\"`\n\t\t\t\t\t\t\t} `json:\"ownerReferences\"`\n\t\t\t\t\t\t} `json:\"metadata\"`\n\t\t\t\t\t} `json:\"items\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &podList)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\t// Find pods owned by this BatchSandbox\n\t\t\t\townedPods := []string{}\n\t\t\t\tfor _, pod := range podList.Items {\n\t\t\t\t\tfor _, owner := range pod.Metadata.OwnerReferences {\n\t\t\t\t\t\tif owner.Kind == \"BatchSandbox\" && owner.Name == batchSandboxName {\n\t\t\t\t\t\t\townedPods = append(ownedPods, pod.Metadata.Name)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tg.Expect(len(ownedPods)).To(Equal(replicas), \"Should create %d pods\", replicas)\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying BatchSandbox status is correctly updated\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status}\")\n\t\t\t\tstatusOutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"replicas\":%d`, replicas)))\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"allocated\":%d`, replicas)))\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"ready\":%d`, replicas)))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying endpoint annotation is set\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/endpoints}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty())\n\t\t\t\tendpoints := strings.Split(output, \",\")\n\t\t\t\tg.Expect(len(endpoints)).To(Equal(replicas))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"cleaning up BatchSandbox\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying pods are deleted\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace, \"-o\", \"json\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar podList struct {\n\t\t\t\t\tItems []struct {\n\t\t\t\t\t\tMetadata struct {\n\t\t\t\t\t\t\tName              string  `json:\"name\"`\n\t\t\t\t\t\t\tDeletionTimestamp *string `json:\"deletionTimestamp\"`\n\t\t\t\t\t\t\tOwnerReferences   []struct {\n\t\t\t\t\t\t\t\tKind string `json:\"kind\"`\n\t\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t\t} `json:\"ownerReferences\"`\n\t\t\t\t\t\t} `json:\"metadata\"`\n\t\t\t\t\t} `json:\"items\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &podList)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\t// Check no pods are owned by this BatchSandbox or they have deletionTimestamp\n\t\t\t\tfor _, pod := range podList.Items {\n\t\t\t\t\tfor _, owner := range pod.Metadata.OwnerReferences {\n\t\t\t\t\t\tif owner.Kind == \"BatchSandbox\" && owner.Name == batchSandboxName {\n\t\t\t\t\t\t\tg.Expect(pod.Metadata.DeletionTimestamp).NotTo(BeNil(),\n\t\t\t\t\t\t\t\t\"Pod %s owned by BatchSandbox should have deletionTimestamp set\", pod.Metadata.Name)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should work correctly in pooled mode\", func() {\n\t\t\tconst poolName = \"test-pool-for-bs\"\n\t\t\tconst batchSandboxName = \"test-bs-pooled\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst replicas = 2\n\n\t\t\tBy(\"creating a Pool\")\n\t\t\tpoolYAML, err := renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    2,\n\t\t\t\t\"PoolMax\":      5,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool-for-bs.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"waiting for Pool to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(totalStr).NotTo(BeEmpty())\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"creating a pooled BatchSandbox\")\n\t\t\tbsYAML, err := renderTemplate(\"testdata/batchsandbox-pooled-no-expire.yaml\", map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\": batchSandboxName,\n\t\t\t\t\"SandboxImage\":     utils.SandboxImage,\n\t\t\t\t\"Namespace\":        testNamespace,\n\t\t\t\t\"Replicas\":         replicas,\n\t\t\t\t\"PoolName\":         poolName,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbsFile := filepath.Join(\"/tmp\", \"test-bs-pooled.yaml\")\n\t\t\terr = os.WriteFile(bsFile, []byte(bsYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsFile)\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", bsFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying BatchSandbox allocates pods from pool\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\t// Verify alloc-status annotation contains pool pod names\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/alloc-status}\")\n\t\t\t\tallocStatusJSON, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(allocStatusJSON).NotTo(BeEmpty(), \"alloc-status annotation should exist\")\n\n\t\t\t\tvar allocStatus struct {\n\t\t\t\t\tPods []string `json:\"pods\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(allocStatusJSON), &allocStatus)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(len(allocStatus.Pods)).To(Equal(replicas), \"Should have %d pods in alloc-status\", replicas)\n\n\t\t\t\t// Verify the pods in alloc-status are from the pool\n\t\t\t\tfor _, podName := range allocStatus.Pods {\n\t\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\t\"-o\", \"jsonpath={.metadata.labels.sandbox\\\\.opensandbox\\\\.io/pool-name}\")\n\t\t\t\t\tpoolLabel, err := utils.Run(cmd)\n\t\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\t\tg.Expect(poolLabel).To(Equal(poolName), \"Pod %s should be from pool %s\", podName, poolName)\n\t\t\t\t}\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying BatchSandbox status is correctly updated\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status}\")\n\t\t\t\tstatusOutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"replicas\":%d`, replicas)))\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"ready\":%d`, replicas)))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying endpoint annotation is set\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/endpoints}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty())\n\t\t\t\tendpoints := strings.Split(output, \",\")\n\t\t\t\tg.Expect(len(endpoints)).To(Equal(replicas))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"recording Pool allocated count\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\tallocatedBefore, err := utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"cleaning up BatchSandbox\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying pods are returned to pool\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\tallocatedAfter, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tbefore := 0\n\t\t\t\tif allocatedBefore != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedBefore, \"%d\", &before)\n\t\t\t\t}\n\t\t\t\tafter := 0\n\t\t\t\tif allocatedAfter != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedAfter, \"%d\", &after)\n\t\t\t\t}\n\t\t\t\tg.Expect(after).To(BeNumerically(\"<\", before), \"Allocated count should decrease\")\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"cleaning up Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t})\n\n\t\tIt(\"should expire and delete non-pooled BatchSandbox correctly\", func() {\n\t\t\tconst batchSandboxName = \"test-bs-expire-non-pooled\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst replicas = 1\n\n\t\t\tBy(\"creating a non-pooled BatchSandbox with expireTime\")\n\t\t\texpireTime := time.Now().Add(45 * time.Second).UTC().Format(time.RFC3339)\n\n\t\t\tbsYAML, err := renderTemplate(\"testdata/batchsandbox-non-pooled-expire.yaml\", map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\": batchSandboxName,\n\t\t\t\t\"Namespace\":        testNamespace,\n\t\t\t\t\"Replicas\":         replicas,\n\t\t\t\t\"ExpireTime\":       expireTime,\n\t\t\t\t\"SandboxImage\":     utils.SandboxImage,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbsFile := filepath.Join(\"/tmp\", \"test-bs-expire-non-pooled.yaml\")\n\t\t\terr = os.WriteFile(bsFile, []byte(bsYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", bsFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"verifying BatchSandbox is created\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(fmt.Sprintf(\"%d\", replicas)))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"recording pod names\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace, \"-o\", \"json\")\n\t\t\toutput, err := utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tvar podList struct {\n\t\t\t\tItems []struct {\n\t\t\t\t\tMetadata struct {\n\t\t\t\t\t\tName            string `json:\"name\"`\n\t\t\t\t\t\tOwnerReferences []struct {\n\t\t\t\t\t\t\tKind string `json:\"kind\"`\n\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t} `json:\"ownerReferences\"`\n\t\t\t\t\t} `json:\"metadata\"`\n\t\t\t\t} `json:\"items\"`\n\t\t\t}\n\t\t\terr = json.Unmarshal([]byte(output), &podList)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpodNamesList := []string{}\n\t\t\tfor _, pod := range podList.Items {\n\t\t\t\tfor _, owner := range pod.Metadata.OwnerReferences {\n\t\t\t\t\tif owner.Kind == \"BatchSandbox\" && owner.Name == batchSandboxName {\n\t\t\t\t\t\tpodNamesList = append(podNamesList, pod.Metadata.Name)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tExpect(len(podNamesList)).To(BeNumerically(\">\", 0), \"Should have pods owned by BatchSandbox\")\n\n\t\t\tBy(\"waiting for BatchSandbox to expire and be deleted\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t\t_, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).To(HaveOccurred())\n\t\t\t\tg.Expect(err.Error()).To(ContainSubstring(\"not found\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying pods are deleted\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-n\", testNamespace, \"-o\", \"json\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar currentPodList struct {\n\t\t\t\t\tItems []struct {\n\t\t\t\t\t\tMetadata struct {\n\t\t\t\t\t\t\tName              string  `json:\"name\"`\n\t\t\t\t\t\t\tDeletionTimestamp *string `json:\"deletionTimestamp\"`\n\t\t\t\t\t\t\tOwnerReferences   []struct {\n\t\t\t\t\t\t\t\tKind string `json:\"kind\"`\n\t\t\t\t\t\t\t\tName string `json:\"name\"`\n\t\t\t\t\t\t\t} `json:\"ownerReferences\"`\n\t\t\t\t\t\t} `json:\"metadata\"`\n\t\t\t\t\t} `json:\"items\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &currentPodList)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\t// Verify no pods are owned by the deleted BatchSandbox or they have deletionTimestamp\n\t\t\t\tfor _, pod := range currentPodList.Items {\n\t\t\t\t\tfor _, owner := range pod.Metadata.OwnerReferences {\n\t\t\t\t\t\tif owner.Kind == \"BatchSandbox\" && owner.Name == batchSandboxName {\n\t\t\t\t\t\t\tg.Expect(pod.Metadata.DeletionTimestamp).NotTo(BeNil(),\n\t\t\t\t\t\t\t\t\"Pod %s owned by BatchSandbox should have deletionTimestamp set\", pod.Metadata.Name)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}, 30*time.Second).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should expire and return pooled BatchSandbox pods to pool\", func() {\n\t\t\tconst poolName = \"test-pool-for-expire\"\n\t\t\tconst batchSandboxName = \"test-bs-expire-pooled\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst replicas = 1\n\n\t\t\tBy(\"creating a Pool\")\n\t\t\tpoolYAML, err := renderTemplate(\"testdata/pool-basic.yaml\", map[string]interface{}{\n\t\t\t\t\"PoolName\":     poolName,\n\t\t\t\t\"SandboxImage\": utils.SandboxImage,\n\t\t\t\t\"Namespace\":    testNamespace,\n\t\t\t\t\"BufferMax\":    3,\n\t\t\t\t\"BufferMin\":    2,\n\t\t\t\t\"PoolMax\":      5,\n\t\t\t\t\"PoolMin\":      2,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool-for-expire.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"waiting for Pool to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\ttotalStr, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(totalStr).NotTo(BeEmpty())\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"recording Pool allocated count before BatchSandbox creation\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\tallocatedBeforeBS, err := utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"creating a pooled BatchSandbox with expireTime\")\n\t\t\texpireTime := time.Now().Add(45 * time.Second).UTC().Format(time.RFC3339)\n\t\t\tbsYAML, err := renderTemplate(\"testdata/batchsandbox-pooled.yaml\", map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\": batchSandboxName,\n\t\t\t\t\"SandboxImage\":     utils.SandboxImage,\n\t\t\t\t\"Namespace\":        testNamespace,\n\t\t\t\t\"Replicas\":         replicas,\n\t\t\t\t\"PoolName\":         poolName,\n\t\t\t\t\"ExpireTime\":       expireTime,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbsFile := filepath.Join(\"/tmp\", \"test-bs-expire-pooled.yaml\")\n\t\t\terr = os.WriteFile(bsFile, []byte(bsYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsFile)\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", bsFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"recording pod names from alloc-status\")\n\t\t\tvar podNamesList []string\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/alloc-status}\")\n\t\t\t\tallocStatusJSON, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(allocStatusJSON).NotTo(BeEmpty())\n\n\t\t\t\tvar allocStatus struct {\n\t\t\t\t\tPods []string `json:\"pods\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(allocStatusJSON), &allocStatus)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tpodNamesList = allocStatus.Pods\n\t\t\t\tg.Expect(len(podNamesList)).To(BeNumerically(\">\", 0), \"Should have allocated pods\")\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tallocatedAfterBS := \"\"\n\t\t\tBy(\"verifying Pool allocated count increased after BatchSandbox allocation\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\t_allocatedAfterBS, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tallocatedAfterBS = _allocatedAfterBS\n\n\t\t\t\tbefore := 0\n\t\t\t\tif allocatedBeforeBS != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedBeforeBS, \"%d\", &before)\n\t\t\t\t}\n\n\t\t\t\tafter := 0\n\t\t\t\tif _allocatedAfterBS != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedAfterBS, \"%d\", &after)\n\t\t\t\t}\n\n\t\t\t\tg.Expect(after).To(BeNumerically(\">\", before), \"Pool allocated count should increase after BatchSandbox allocation\")\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"waiting for BatchSandbox to expire and be deleted\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t\t_, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).To(HaveOccurred())\n\t\t\t\tg.Expect(err.Error()).To(ContainSubstring(\"not found\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying pods still exist and are returned to pool\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tfor _, podName := range podNamesList {\n\t\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\t\"-o\", \"jsonpath={.metadata.name}\")\n\t\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\t\tg.Expect(output).To(Equal(podName), \"Pod should still exist\")\n\t\t\t\t}\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying Pool allocated count decreased after BatchSandbox expiration\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\tallocatedAfterExpiration, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tbefore := 0\n\t\t\t\tif allocatedAfterBS != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedAfterBS, \"%d\", &before)\n\t\t\t\t}\n\t\t\t\tafter := 0\n\t\t\t\tif allocatedAfterExpiration != \"\" {\n\t\t\t\t\tfmt.Sscanf(allocatedAfterExpiration, \"%d\", &after)\n\t\t\t\t}\n\t\t\t\tg.Expect(after).To(BeNumerically(\"<\", before), \"Allocated count should decrease\")\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"cleaning up Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t})\n\t})\n\n\tContext(\"Task\", func() {\n\t\tBeforeAll(func() {\n\t\t\tBy(\"waiting for controller to be ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pods\", \"-l\", \"control-plane=controller-manager\",\n\t\t\t\t\t\"-n\", namespace, \"-o\", \"jsonpath={.items[0].status.phase}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should successfully manage Pool with task scheduling\", func() {\n\t\t\tconst poolName = \"test-pool\"\n\t\t\tconst batchSandboxName = \"test-batchsandbox-with-task\"\n\t\t\tconst testNamespace = \"default\"\n\t\t\tconst replicas = 2\n\n\t\t\tBy(\"creating a Pool with task-executor sidecar\")\n\t\t\tpoolTemplateFile := filepath.Join(\"testdata\", \"pool-with-task-executor.yaml\")\n\t\t\tpoolYAML, err := renderTemplate(poolTemplateFile, map[string]interface{}{\n\t\t\t\t\"PoolName\":          poolName,\n\t\t\t\t\"Namespace\":         testNamespace,\n\t\t\t\t\"TaskExecutorImage\": utils.TaskExecutorImage,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", \"test-pool.yaml\")\n\t\t\terr = os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", poolFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create Pool\")\n\n\t\t\tBy(\"waiting for Pool to be ready\")\n\t\t\tverifyPoolReady := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.total}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tBy(fmt.Sprintf(\"waiting for Pool to be ready, output %s\", output))\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty(), \"Pool status.total should not be empty\")\n\t\t\t}\n\t\t\tEventually(verifyPoolReady, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"creating a BatchSandbox with process-based tasks using the Pool\")\n\t\t\tbatchSandboxTemplateFile := filepath.Join(\"testdata\", \"batchsandbox-with-process-task.yaml\")\n\t\t\tbatchSandboxYAML, err := renderTemplate(batchSandboxTemplateFile, map[string]interface{}{\n\t\t\t\t\"BatchSandboxName\":  batchSandboxName,\n\t\t\t\t\"Namespace\":         testNamespace,\n\t\t\t\t\"Replicas\":          replicas,\n\t\t\t\t\"PoolName\":          poolName,\n\t\t\t\t\"TaskExecutorImage\": utils.TaskExecutorImage,\n\t\t\t})\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tbatchSandboxFile := filepath.Join(\"/tmp\", \"test-batchsandbox.yaml\")\n\t\t\terr = os.WriteFile(batchSandboxFile, []byte(batchSandboxYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tcmd = exec.Command(\"kubectl\", \"apply\", \"-f\", batchSandboxFile)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create BatchSandbox\")\n\n\t\t\tBy(\"verifying BatchSandbox successfully allocated endpoints\")\n\t\t\tverifyBatchSandboxAllocated := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(fmt.Sprintf(\"%d\", replicas)), \"BatchSandbox should allocate %d replicas\", replicas)\n\t\t\t}\n\t\t\tEventually(verifyBatchSandboxAllocated, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying BatchSandbox endpoints are available\")\n\t\t\tverifyEndpoints := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.metadata.annotations.sandbox\\\\.opensandbox\\\\.io/endpoints}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty(), \"BatchSandbox should have sandbox.opensandbox.io/endpoints annotation\")\n\t\t\t\tendpoints := strings.Split(output, \",\")\n\t\t\t\tg.Expect(len(endpoints)).To(Equal(replicas), \"Should have %d endpoints\", replicas)\n\t\t\t}\n\t\t\tEventually(verifyEndpoints, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying BatchSandbox status is as expected\")\n\t\t\tverifyBatchSandboxStatus := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status}\")\n\t\t\t\tstatusOutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"replicas\":%d`, replicas)))\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"allocated\":%d`, replicas)))\n\t\t\t\tg.Expect(statusOutput).To(ContainSubstring(fmt.Sprintf(`\"ready\":%d`, replicas)))\n\t\t\t}\n\t\t\tEventually(verifyBatchSandboxStatus, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying all tasks are successfully scheduled and succeeded\")\n\t\t\tverifyTasksSucceeded := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.taskSucceed}\")\n\t\t\t\toutput, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(fmt.Sprintf(\"%d\", replicas)), \"All tasks should succeed\")\n\n\t\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.taskFailed}\")\n\t\t\t\toutput, err = utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"0\"), \"No tasks should fail\")\n\t\t\t}\n\t\t\tEventually(verifyTasksSucceeded, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"recording Pool status before deletion\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\tpoolAllocatedBefore, err := utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"deleting the BatchSandbox\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to delete BatchSandbox\")\n\n\t\t\tBy(\"verifying all tasks are unloaded and BatchSandbox is deleted\")\n\t\t\tverifyBatchSandboxDeleted := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace)\n\t\t\t\t_, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).To(HaveOccurred(), \"BatchSandbox should be deleted\")\n\t\t\t\tg.Expect(err.Error()).To(ContainSubstring(\"not found\"))\n\t\t\t}\n\t\t\tEventually(verifyBatchSandboxDeleted, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying pods are returned to the Pool\")\n\t\t\tverifyPodsReturnedToPool := func(g Gomega) {\n\t\t\t\tcmd := exec.Command(\"kubectl\", \"get\", \"pool\", poolName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.allocated}\")\n\t\t\t\tpoolAllocatedAfter, err := utils.Run(cmd)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tbeforeCount := 0\n\t\t\t\tif poolAllocatedBefore != \"\" {\n\t\t\t\t\tfmt.Sscanf(poolAllocatedBefore, \"%d\", &beforeCount)\n\t\t\t\t}\n\t\t\t\tafterCount := 0\n\t\t\t\tif poolAllocatedAfter != \"\" {\n\t\t\t\t\tfmt.Sscanf(poolAllocatedAfter, \"%d\", &afterCount)\n\t\t\t\t}\n\t\t\t\tg.Expect(afterCount).To(BeNumerically(\"<=\", beforeCount),\n\t\t\t\t\t\"Pool allocated count should decrease or stay same after BatchSandbox deletion\")\n\t\t\t}\n\t\t\tEventually(verifyPodsReturnedToPool, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"cleaning up the Pool\")\n\t\t\tcmd = exec.Command(\"kubectl\", \"delete\", \"pool\", poolName, \"-n\", testNamespace)\n\t\t\t_, err = utils.Run(cmd)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to delete Pool\")\n\n\t\t\tBy(\"cleaning up temporary files\")\n\t\t\tos.Remove(poolFile)\n\t\t\tos.Remove(batchSandboxFile)\n\t\t})\n\t})\n\n})\n\n// renderTemplate renders a YAML template file with the given data.\nfunc renderTemplate(templateFile string, data map[string]interface{}) (string, error) {\n\tdir, err := utils.GetProjectDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfullPath := filepath.Join(dir, \"test\", \"e2e\", templateFile)\n\ttmplContent, err := os.ReadFile(fullPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to read template file %s: %w\", fullPath, err)\n\t}\n\n\ttmpl, err := template.New(\"yaml\").Parse(string(tmplContent))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse template: %w\", err)\n\t}\n\n\tvar buf bytes.Buffer\n\terr = tmpl.Execute(&buf, data)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to execute template: %w\", err)\n\t}\n\n\treturn buf.String(), nil\n}\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/batchsandbox-non-pooled-expire.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: {{.BatchSandboxName}}\n  namespace: {{.Namespace}}\nspec:\n  replicas: {{.Replicas}}\n  expireTime: \"{{.ExpireTime}}\"\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: {{.SandboxImage}}\n        command: [\"sleep\", \"3600\"]\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/batchsandbox-non-pooled.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: {{.BatchSandboxName}}\n  namespace: {{.Namespace}}\nspec:\n  replicas: {{.Replicas}}\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: {{.SandboxImage}}\n        command: [\"sleep\", \"3600\"]"
  },
  {
    "path": "kubernetes/test/e2e/testdata/batchsandbox-pooled-no-expire.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: {{.BatchSandboxName}}\n  namespace: {{.Namespace}}\nspec:\n  replicas: {{.Replicas}}\n  poolRef: {{.PoolName}}\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/batchsandbox-pooled.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: {{.BatchSandboxName}}\n  namespace: {{.Namespace}}\nspec:\n  replicas: {{.Replicas}}\n  poolRef: {{.PoolName}}\n  expireTime: \"{{.ExpireTime}}\""
  },
  {
    "path": "kubernetes/test/e2e/testdata/batchsandbox-with-process-task.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: {{.BatchSandboxName}}\n  namespace: {{.Namespace}}\nspec:\n  replicas: {{.Replicas}}\n  poolRef: {{.PoolName}}\n  taskTemplate:\n    spec:\n      process:\n        command: [\"echo\"]\n        args: [\"Hello from task\"]\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/pool-basic.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: {{.PoolName}}\n  namespace: {{.Namespace}}\nspec:\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: {{.SandboxImage}}\n        command: [\"sleep\", \"3600\"]\n  capacitySpec:\n    bufferMax: {{.BufferMax}}\n    bufferMin: {{.BufferMin}}\n    poolMax: {{.PoolMax}}\n    poolMin: {{.PoolMin}}"
  },
  {
    "path": "kubernetes/test/e2e/testdata/pool-with-env.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: {{.PoolName}}\n  namespace: {{.Namespace}}\nspec:\n  template:\n    spec:\n      containers:\n      - name: sandbox-container\n        image: {{.SandboxImage}}\n        command: [\"sleep\", \"3600\"]\n        env:\n        - name: UPGRADED\n          value: \"true\"\n  capacitySpec:\n    bufferMax: {{.BufferMax}}\n    bufferMin: {{.BufferMin}}\n    poolMax: {{.PoolMax}}\n    poolMin: {{.PoolMin}}\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/pool-with-task-executor.yaml",
    "content": "apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: {{.PoolName}}\n  namespace: {{.Namespace}}\nspec:\n  template:\n    spec:\n      containers:\n      - name: task-executor\n        image: {{.TaskExecutorImage}}\n  capacitySpec:\n    bufferMax: 0\n    bufferMin: 0\n    poolMax: 10\n    poolMin: 2\n"
  },
  {
    "path": "kubernetes/test/e2e/testdata/runtimeclass/gvisor.yaml",
    "content": "apiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc\n"
  },
  {
    "path": "kubernetes/test/e2e_runtime/gvisor/gvisor_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gvisor\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/test/utils\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\n// runKubectl executes a kubectl command from the project root directory\nfunc runKubectl(args ...string) (string, error) {\n\tcmd := exec.Command(\"kubectl\", args...)\n\tcmd.Dir = \"../../..\" // Navigate from test/e2e_runtime/gvisor to project root\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn string(output), fmt.Errorf(\"kubectl %v failed: %w\", args, err)\n\t}\n\treturn string(output), nil\n}\n\nvar _ = Describe(\"gVisor RuntimeClass\", Ordered, func() {\n\tconst testNamespace = \"default\"\n\n\tBeforeAll(func() {\n\t\tBy(\"installing gVisor RuntimeClass\")\n\t\t_, err := runKubectl(\"apply\", \"-f\", \"test/e2e_runtime/gvisor/testdata/runtimeclass.yaml\")\n\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create gVisor RuntimeClass\")\n\t})\n\n\tAfterAll(func() {\n\t\tBy(\"cleaning up RuntimeClass\")\n\t\t_, _ = runKubectl(\"delete\", \"runtimeclass\", RuntimeClassName, \"--ignore-not-found=true\")\n\t})\n\n\tContext(\"RuntimeClass API\", func() {\n\t\tIt(\"should create RuntimeClass resources\", func() {\n\t\t\tBy(\"verifying RuntimeClass exists\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"runtimeclass\", RuntimeClassName, \"-o\", \"json\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar rcObj struct {\n\t\t\t\t\tHandler string `json:\"handler\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &rcObj)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(rcObj.Handler).To(Equal(\"runsc\"))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\t\t})\n\t})\n\n\tContext(\"Pod with runtimeClassName\", func() {\n\t\tvar podName string\n\n\t\tBeforeEach(func() {\n\t\t\tpodName = fmt.Sprintf(\"test-pod-gvisor-%d\", time.Now().UnixNano())\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tBy(\"cleaning up Pod\")\n\t\t\tif podName != \"\" {\n\t\t\t\t_, _ = runKubectl(\"delete\", \"pod\", podName, \"-n\", testNamespace, \"--ignore-not-found=true\")\n\t\t\t}\n\t\t})\n\n\t\tIt(\"should create Pod with runtimeClassName\", func() {\n\t\t\tBy(\"creating a Pod with runtimeClassName\")\n\t\t\tpodYAML := fmt.Sprintf(`apiVersion: v1\nkind: Pod\nmetadata:\n  name: %s\n  namespace: %s\nspec:\n  runtimeClassName: %s\n  containers:\n  - name: test-container\n    image: %s\n    command: [\"sleep\", \"3600\"]\n`, podName, testNamespace, RuntimeClassName, utils.SandboxImage)\n\n\t\t\tpodFile := filepath.Join(\"/tmp\", fmt.Sprintf(\"test-pod-%s.yaml\", podName))\n\t\t\terr := os.WriteFile(podFile, []byte(podYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(podFile)\n\n\t\t\t_, err = runKubectl(\"apply\", \"-f\", podFile)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create Pod\")\n\n\t\t\tBy(\"verifying Pod has runtimeClassName set\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.spec.runtimeClassName}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(RuntimeClassName))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying Pod is running with gVisor\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.phase}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\t})\n\n\tContext(\"Pool with gVisor RuntimeClass\", func() {\n\t\tvar poolName string\n\t\tvar batchSandboxName string\n\n\t\tBeforeEach(func() {\n\t\t\tpoolName = fmt.Sprintf(\"gvisor-pool-%d\", time.Now().UnixNano())\n\t\t\tbatchSandboxName = fmt.Sprintf(\"gvisor-bsbx-%d\", time.Now().UnixNano())\n\t\t})\n\n\t\tAfterEach(func() {\n\t\t\tBy(\"cleaning up BatchSandbox\")\n\t\t\tif batchSandboxName != \"\" {\n\t\t\t\t_, _ = runKubectl(\"delete\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace, \"--ignore-not-found=true\")\n\t\t\t}\n\t\t\tBy(\"cleaning up Pool\")\n\t\t\tif poolName != \"\" {\n\t\t\t\t_, _ = runKubectl(\"delete\", \"pool\", poolName, \"-n\", testNamespace, \"--ignore-not-found=true\")\n\t\t\t}\n\t\t})\n\n\t\tIt(\"should create Pool and allocate Pod with gVisor runtime\", func() {\n\t\t\tBy(\"creating a Pool with gVisor runtimeClassName\")\n\t\t\tpoolYAML := fmt.Sprintf(`apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: %s\n  namespace: %s\nspec:\n  template:\n    spec:\n      runtimeClassName: %s\n      containers:\n        - name: sandbox-container\n          image: %s\n          command: [\"sleep\", \"3600\"]\n  capacitySpec:\n    bufferMax: 2\n    bufferMin: 1\n    poolMax: 5\n    poolMin: 1\n`, poolName, testNamespace, RuntimeClassName, utils.SandboxImage)\n\n\t\t\tpoolFile := filepath.Join(\"/tmp\", fmt.Sprintf(\"test-pool-%s.yaml\", poolName))\n\t\t\terr := os.WriteFile(poolFile, []byte(poolYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(poolFile)\n\n\t\t\t_, err = runKubectl(\"apply\", \"-f\", poolFile)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create Pool\")\n\n\t\t\tBy(\"waiting for Pool to have available pods\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pool\", poolName, \"-n\", testNamespace, \"-o\", \"json\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\n\t\t\t\tvar poolObj struct {\n\t\t\t\t\tStatus struct {\n\t\t\t\t\t\tAvailable int32 `json:\"available\"`\n\t\t\t\t\t} `json:\"status\"`\n\t\t\t\t}\n\t\t\t\terr = json.Unmarshal([]byte(output), &poolObj)\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(poolObj.Status.Available).To(BeNumerically(\">\", 0))\n\t\t\t}, 3*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"creating BatchSandbox with poolRef\")\n\t\t\tbsbxYAML := fmt.Sprintf(`apiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: %s\n  namespace: %s\nspec:\n  replicas: 1\n  poolRef: %s\n`, batchSandboxName, testNamespace, poolName)\n\n\t\t\tbsbxFile := filepath.Join(\"/tmp\", fmt.Sprintf(\"test-bsbx-%s.yaml\", batchSandboxName))\n\t\t\terr = os.WriteFile(bsbxFile, []byte(bsbxYAML), 0644)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tdefer os.Remove(bsbxFile)\n\n\t\t\t_, err = runKubectl(\"apply\", \"-f\", bsbxFile)\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to create BatchSandbox\")\n\n\t\t\tBy(\"waiting for BatchSandbox to allocate a Pod\")\n\t\t\tvar podName string\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pods\", \"-n\", testNamespace,\n\t\t\t\t\t\"-l\", fmt.Sprintf(\"sandbox.opensandbox.io/pool-name=%s\", poolName),\n\t\t\t\t\t\"-o\", \"jsonpath={.items[0].metadata.name}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).NotTo(BeEmpty())\n\t\t\t\tpodName = output\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying allocated Pod has runtimeClassName set to gVisor\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.spec.runtimeClassName}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(RuntimeClassName))\n\t\t\t}, 30*time.Second).Should(Succeed())\n\n\t\t\tBy(\"verifying Pod is running with gVisor\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"pod\", podName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.phase}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"Running\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\n\t\t\tBy(\"verifying BatchSandbox status is ready\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\toutput, err := runKubectl(\"get\", \"batchsandbox\", batchSandboxName, \"-n\", testNamespace,\n\t\t\t\t\t\"-o\", \"jsonpath={.status.ready}\")\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(output).To(Equal(\"1\"))\n\t\t\t}, 2*time.Minute).Should(Succeed())\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "kubernetes/test/e2e_runtime/gvisor/suite_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gvisor\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"testing\"\n\n\t\"github.com/alibaba/OpenSandbox/sandbox-k8s/test/utils\"\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nconst (\n\t// RuntimeClassName is the name of the RuntimeClass for gVisor\n\tRuntimeClassName = \"gvisor\"\n)\n\n// KindCluster is the name of the Kind cluster for gVisor tests.\n// It reads from KIND_CLUSTER environment variable, defaulting to \"gvisor-test\".\nvar KindCluster = getKindCluster()\n\nfunc getKindCluster() string {\n\tif v, ok := os.LookupEnv(\"KIND_CLUSTER\"); ok {\n\t\treturn v\n\t}\n\treturn \"gvisor-test\"\n}\n\n// TestGVisorRuntimeClass runs the gVisor RuntimeClass end-to-end tests.\n// These tests validate gVisor functionality with the Kind cluster\n// configured specifically for gVisor (runsc) runtime.\nfunc TestGVisorRuntimeClass(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\t_, _ = fmt.Fprintf(GinkgoWriter, \"Starting gVisor RuntimeClass E2E test suite\\n\")\n\tRunSpecs(t, \"gVisor runtimeclass suite\")\n}\n\nvar _ = BeforeSuite(func() {\n\tdockerBuildArgs := os.Getenv(\"DOCKER_BUILD_ARGS\")\n\n\tBy(\"building task-executor image\")\n\tmakeArgs := []string{\"docker-build-task-executor\", fmt.Sprintf(\"TASK_EXECUTOR_IMG=%s\", utils.TaskExecutorImage)}\n\tif dockerBuildArgs != \"\" {\n\t\tmakeArgs = append(makeArgs, fmt.Sprintf(\"DOCKER_BUILD_ARGS=%s\", dockerBuildArgs))\n\t}\n\tcmd := exec.Command(\"make\", makeArgs...)\n\tcmd.Dir = \"../../..\" // Navigate from test/e2e_runtime/gvisor to project root\n\toutput, err := cmd.CombinedOutput()\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to build task-executor image: %s\", string(output))\n\n\tBy(\"loading task-executor image on Kind\")\n\t// Use kind command directly to load image, avoiding utils.GetProjectDir() path issues\n\tcmd = exec.Command(\"kind\", \"load\", \"docker-image\", \"--name\", KindCluster, utils.TaskExecutorImage)\n\tcmd.Dir = \"../../..\" // Navigate from test/e2e_runtime/gvisor to project root\n\toutput, err = cmd.CombinedOutput()\n\tExpectWithOffset(1, err).NotTo(HaveOccurred(), \"Failed to load task-executor image into Kind: %s\", string(output))\n})\n\nvar _ = AfterSuite(func() {\n})\n"
  },
  {
    "path": "kubernetes/test/e2e_runtime/gvisor/testdata/gvisor.yaml.tmpl",
    "content": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nname: ${GVISOR_KIND_CLUSTER}\n# Configure containerd to use gVisor runsc runtime with containerd-shim-runsc-v1\ncontainerdConfigPatches:\n  - |-\n    [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc]\n      runtime_type = \"io.containerd.runsc.v1\"\n      [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc.options]\n        TypeUrl = \"io.containerd.runsc.v1.options\"\n        ConfigPath = \"/etc/containerd/runsc.toml\"\nnodes:\n  - role: control-plane\n    image: ${GVISOR_KIND_IMAGE}\n    extraMounts:\n      - hostPath: ${PWD}/test/kind/gvisor/runsc\n        containerPath: /usr/local/bin/runsc\n        readOnly: true\n      - hostPath: ${PWD}/test/kind/gvisor/containerd-shim-runsc-v1\n        containerPath: /usr/local/bin/containerd-shim-runsc-v1\n        readOnly: true\n  - role: worker\n    image: ${GVISOR_KIND_IMAGE}\n    extraMounts:\n      - hostPath: ${PWD}/test/kind/gvisor/runsc\n        containerPath: /usr/local/bin/runsc\n        readOnly: true\n      - hostPath: ${PWD}/test/kind/gvisor/containerd-shim-runsc-v1\n        containerPath: /usr/local/bin/containerd-shim-runsc-v1\n        readOnly: true\n"
  },
  {
    "path": "kubernetes/test/e2e_runtime/gvisor/testdata/runtimeclass.yaml",
    "content": "apiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc\nscheduling:\n  nodeSelector:\n    kubernetes.io/arch: amd64\n"
  },
  {
    "path": "kubernetes/test/e2e_task/suite_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage e2e_task\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n)\n\nfunc TestE2E(t *testing.T) {\n\tRegisterFailHandler(Fail)\n\tRunSpecs(t, \"Task Executor E2E Suite\")\n}\n"
  },
  {
    "path": "kubernetes/test/e2e_task/task_e2e_test.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage e2e_task\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"time\"\n\n\t. \"github.com/onsi/ginkgo/v2\"\n\t. \"github.com/onsi/gomega\"\n\n\tapi \"github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor\"\n)\n\nconst (\n\tImageName         = \"task-executor-e2e\"\n\tTargetContainer   = \"task-e2e-target\"\n\tExecutorContainer = \"task-e2e-executor\"\n\tVolumeName        = \"task-e2e-vol\"\n\tHostPort          = \"5758\"\n)\n\nvar _ = Describe(\"Task Executor E2E\", Ordered, func() {\n\tvar client *api.Client\n\n\tBeforeAll(func() {\n\t\t// Check docker\n\t\t_, err := exec.LookPath(\"docker\")\n\t\tExpect(err).NotTo(HaveOccurred(), \"Docker not found, skipping E2E test\")\n\n\t\tBy(\"Building image\")\n\t\tcmd := exec.Command(\"docker\", \"build\",\n\t\t\t\"--build-arg\", \"PACKAGE=cmd/task-executor/main.go\",\n\t\t\t\"-t\", ImageName, \"-f\", \"../../Dockerfile\", \"../../\")\n\t\tcmd.Stdout = os.Stdout\n\t\tcmd.Stderr = os.Stderr\n\t\tExpect(cmd.Run()).To(Succeed())\n\n\t\tBy(\"Cleaning up previous runs\")\n\t\texec.Command(\"docker\", \"rm\", \"-f\", TargetContainer, ExecutorContainer).Run()\n\t\texec.Command(\"docker\", \"volume\", \"rm\", VolumeName).Run()\n\n\t\tBy(\"Creating shared volume\")\n\t\tExpect(exec.Command(\"docker\", \"volume\", \"create\", VolumeName).Run()).To(Succeed())\n\n\t\tBy(\"Starting target container\")\n\t\ttargetCmd := exec.Command(\"docker\", \"run\", \"-d\", \"--name\", TargetContainer,\n\t\t\t\"-v\", fmt.Sprintf(\"%s:/tmp/tasks\", VolumeName),\n\t\t\t\"-e\", \"SANDBOX_MAIN_CONTAINER=main\",\n\t\t\t\"-e\", \"TARGET_VAR=hello-from-target\",\n\t\t\t\"golang:1.24\", \"sleep\", \"infinity\")\n\t\ttargetCmd.Stdout = os.Stdout\n\t\ttargetCmd.Stderr = os.Stderr\n\t\tExpect(targetCmd.Run()).To(Succeed())\n\n\t\tBy(\"Starting executor container in Sidecar Mode\")\n\t\texecCmd := exec.Command(\"docker\", \"run\", \"-d\", \"--name\", ExecutorContainer,\n\t\t\t\"-v\", fmt.Sprintf(\"%s:/tmp/tasks\", VolumeName),\n\t\t\t\"--privileged\",\n\t\t\t\"-u\", \"0\",\n\t\t\t\"--pid=container:\"+TargetContainer,\n\t\t\t\"-p\", HostPort+\":5758\",\n\t\t\tImageName,\n\t\t\t\"-enable-sidecar-mode=true\",\n\t\t\t\"-main-container-name=main\",\n\t\t\t\"-data-dir=/tmp/tasks\")\n\t\texecCmd.Stdout = os.Stdout\n\t\texecCmd.Stderr = os.Stderr\n\t\tExpect(execCmd.Run()).To(Succeed())\n\n\t\tBy(\"Waiting for executor to be ready\")\n\t\tclient = api.NewClient(fmt.Sprintf(\"http://127.0.0.1:%s\", HostPort))\n\t\tEventually(func() error {\n\t\t\t_, err := client.Get(context.Background())\n\t\t\treturn err\n\t\t}, 10*time.Second, 500*time.Millisecond).Should(Succeed(), \"Executor failed to become ready\")\n\t})\n\n\tAfterAll(func() {\n\t\tBy(\"Cleaning up containers\")\n\t\tif CurrentSpecReport().Failed() {\n\t\t\tBy(\"Dumping logs\")\n\t\t\tout, _ := exec.Command(\"docker\", \"logs\", ExecutorContainer).CombinedOutput()\n\t\t\tfmt.Printf(\"Executor Logs:\\n%s\\n\", string(out))\n\t\t}\n\t\texec.Command(\"docker\", \"rm\", \"-f\", TargetContainer, ExecutorContainer).Run()\n\t\texec.Command(\"docker\", \"volume\", \"rm\", VolumeName).Run()\n\t})\n\n\tContext(\"When creating a short-lived task\", func() {\n\t\ttaskName := \"e2e-test-1\"\n\n\t\tIt(\"should run and succeed\", func() {\n\t\t\tBy(\"Creating task\")\n\t\t\ttask := &api.Task{\n\t\t\t\tName: taskName,\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"sleep\", \"2\"},\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err := client.Set(context.Background(), task)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Waiting for task to succeed\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tgot, err := client.Get(context.Background())\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(got).NotTo(BeNil())\n\t\t\t\tg.Expect(got.Name).To(Equal(taskName))\n\n\t\t\t\t// Verify state\n\t\t\t\tif got.ProcessStatus != nil && got.ProcessStatus.Terminated != nil {\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated.ExitCode).To(BeZero())\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated.Reason).To(Equal(\"Succeeded\"))\n\t\t\t\t} else {\n\t\t\t\t\t// Fail if not terminated yet (so Eventually retries)\n\t\t\t\t\tg.Expect(got.ProcessStatus).NotTo(BeNil(), \"Task ProcessStatus is nil\")\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated).NotTo(BeNil(), \"Task status: %v\", got.ProcessStatus)\n\t\t\t\t}\n\t\t\t}, 10*time.Second, 1*time.Second).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should be deletable\", func() {\n\t\t\tBy(\"Deleting task\")\n\t\t\t_, err := client.Set(context.Background(), nil)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Verifying deletion\")\n\t\t\tEventually(func() *api.Task {\n\t\t\t\tgot, _ := client.Get(context.Background())\n\t\t\t\treturn got\n\t\t\t}, 5*time.Second, 500*time.Millisecond).Should(BeNil())\n\t\t})\n\t})\n\n\tContext(\"When creating a task checking environment variables\", func() {\n\t\ttaskName := \"e2e-env-test\"\n\n\t\tIt(\"should inherit environment variables from target container\", func() {\n\t\t\tBy(\"Creating task running 'env'\")\n\t\t\ttask := &api.Task{\n\t\t\t\tName: taskName,\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand: []string{\"env\"},\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err := client.Set(context.Background(), task)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Waiting for task to succeed\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tgot, err := client.Get(context.Background())\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(got).NotTo(BeNil())\n\t\t\t\tg.Expect(got.Name).To(Equal(taskName))\n\t\t\t\tg.Expect(got.ProcessStatus.Terminated).NotTo(BeNil())\n\t\t\t\tg.Expect(got.ProcessStatus.Terminated.ExitCode).To(BeZero())\n\t\t\t}, 10*time.Second, 1*time.Second).Should(Succeed())\n\n\t\t\tBy(\"Verifying stdout contains target container env\")\n\t\t\t// Read stdout.log from the executor container (which shares the volume)\n\t\t\tout, err := exec.Command(\"docker\", \"exec\", ExecutorContainer, \"cat\", fmt.Sprintf(\"/tmp/tasks/%s/stdout.log\", taskName)).CombinedOutput()\n\t\t\tExpect(err).NotTo(HaveOccurred(), \"Failed to read stdout.log: %s\", string(out))\n\n\t\t\toutputStr := string(out)\n\t\t\tExpect(outputStr).To(ContainSubstring(\"TARGET_VAR=hello-from-target\"), \"Task environment should inherit from target container\")\n\t\t})\n\n\t\tIt(\"should be deletable\", func() {\n\t\t\tBy(\"Deleting task\")\n\t\t\t_, err := client.Set(context.Background(), nil)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Verifying deletion\")\n\t\t\tEventually(func() *api.Task {\n\t\t\t\tgot, _ := client.Get(context.Background())\n\t\t\t\treturn got\n\t\t\t}, 5*time.Second, 500*time.Millisecond).Should(BeNil())\n\t\t})\n\t})\n\n\tContext(\"When creating a task with timeout\", func() {\n\t\ttaskName := \"e2e-timeout-test\"\n\n\t\tIt(\"should timeout and be terminated\", func() {\n\t\t\tBy(\"Creating task with 5 second timeout that runs for 30 seconds\")\n\t\t\ttimeoutSec := int64(5)\n\t\t\ttask := &api.Task{\n\t\t\t\tName: taskName,\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand:        []string{\"sleep\", \"30\"},\n\t\t\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err := client.Set(context.Background(), task)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Waiting for task to be terminated (within 15 seconds)\")\n\t\t\t// After timeout detection, Stop is called and the process is killed.\n\t\t\t// Once Stop completes, the exit file is written and state becomes Failed.\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tgot, err := client.Get(context.Background())\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(got).NotTo(BeNil())\n\t\t\t\tg.Expect(got.Name).To(Equal(taskName))\n\n\t\t\t\t// Should be Terminated with exit code 137 (SIGKILL) or 143 (SIGTERM)\n\t\t\t\t// sleep responds to SIGTERM quickly, so we usually get 143\n\t\t\t\t// The state will be \"Failed\" after exit file is written\n\t\t\t\tif got.ProcessStatus != nil && got.ProcessStatus.Terminated != nil {\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated.ExitCode).To(SatisfyAny(\n\t\t\t\t\t\tEqual(int32(137)), // SIGKILL\n\t\t\t\t\t\tEqual(int32(143)), // SIGTERM\n\t\t\t\t\t))\n\t\t\t\t} else {\n\t\t\t\t\t// Fail if not terminated yet\n\t\t\t\t\tg.Expect(got.ProcessStatus).NotTo(BeNil(), \"Task ProcessStatus is nil\")\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated).NotTo(BeNil(), \"Task status: %v\", got.ProcessStatus)\n\t\t\t\t}\n\t\t\t}, 15*time.Second, 1*time.Second).Should(Succeed())\n\n\t\t\tBy(\"Verifying the task was terminated\")\n\t\t\tgot, err := client.Get(context.Background())\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\t\t\tExpect(got.ProcessStatus.Terminated).NotTo(BeNil())\n\t\t\tExpect(got.ProcessStatus.Terminated.ExitCode).To(SatisfyAny(\n\t\t\t\tEqual(int32(137)), // SIGKILL\n\t\t\t\tEqual(int32(143)), // SIGTERM\n\t\t\t))\n\t\t\t// State could be \"Failed\" (after exit file written) or \"Timeout\" (during stop)\n\t\t\tExpect(got.ProcessStatus.Terminated.Reason).To(SatisfyAny(\n\t\t\t\tEqual(\"Failed\"),\n\t\t\t\tEqual(\"TaskTimeout\"),\n\t\t\t))\n\t\t})\n\n\t\tIt(\"should be deletable after timeout\", func() {\n\t\t\tBy(\"Deleting task\")\n\t\t\t_, err := client.Set(context.Background(), nil)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Verifying deletion\")\n\t\t\tEventually(func() *api.Task {\n\t\t\t\tgot, _ := client.Get(context.Background())\n\t\t\t\treturn got\n\t\t\t}, 5*time.Second, 500*time.Millisecond).Should(BeNil())\n\t\t})\n\t})\n\n\tContext(\"When creating a task that completes before timeout\", func() {\n\t\ttaskName := \"e2e-no-timeout-test\"\n\n\t\tIt(\"should succeed without timeout\", func() {\n\t\t\tBy(\"Creating task with 60 second timeout that completes in 2 seconds\")\n\t\t\ttimeoutSec := int64(60)\n\t\t\ttask := &api.Task{\n\t\t\t\tName: taskName,\n\t\t\t\tProcess: &api.Process{\n\t\t\t\t\tCommand:        []string{\"sleep\", \"2\"},\n\t\t\t\t\tTimeoutSeconds: &timeoutSec,\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err := client.Set(context.Background(), task)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Waiting for task to succeed\")\n\t\t\tEventually(func(g Gomega) {\n\t\t\t\tgot, err := client.Get(context.Background())\n\t\t\t\tg.Expect(err).NotTo(HaveOccurred())\n\t\t\t\tg.Expect(got).NotTo(BeNil())\n\t\t\t\tg.Expect(got.Name).To(Equal(taskName))\n\n\t\t\t\t// Should succeed with exit code 0\n\t\t\t\tif got.ProcessStatus != nil && got.ProcessStatus.Terminated != nil {\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated.ExitCode).To(BeZero())\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated.Reason).To(Equal(\"Succeeded\"))\n\t\t\t\t} else {\n\t\t\t\t\tg.Expect(got.ProcessStatus).NotTo(BeNil(), \"Task ProcessStatus is nil\")\n\t\t\t\t\tg.Expect(got.ProcessStatus.Terminated).NotTo(BeNil(), \"Task status: %v\", got.ProcessStatus)\n\t\t\t\t}\n\t\t\t}, 10*time.Second, 1*time.Second).Should(Succeed())\n\t\t})\n\n\t\tIt(\"should be deletable\", func() {\n\t\t\tBy(\"Deleting task\")\n\t\t\t_, err := client.Set(context.Background(), nil)\n\t\t\tExpect(err).NotTo(HaveOccurred())\n\n\t\t\tBy(\"Verifying deletion\")\n\t\t\tEventually(func() *api.Task {\n\t\t\t\tgot, _ := client.Get(context.Background())\n\t\t\t\treturn got\n\t\t\t}, 5*time.Second, 500*time.Millisecond).Should(BeNil())\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "kubernetes/test/utils/image.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport \"os\"\n\nvar (\n\t// ControllerImage is the controller manager image\n\t// Can be overridden via CONTROLLER_IMG env var\n\tControllerImage = getEnv(\"CONTROLLER_IMG\", \"controller:dev\")\n\n\t// TaskExecutorImage is the task-executor image\n\t// Can be overridden via TASK_EXECUTOR_IMG env var\n\tTaskExecutorImage = getEnv(\"TASK_EXECUTOR_IMG\", \"task-executor:dev\")\n\n\t// SandboxImage is the image used for sandbox containers in tests\n\t// Always uses TaskExecutorImage to ensure the image is available in Kind\n\tSandboxImage = TaskExecutorImage\n)\n\nfunc getEnv(key, defaultValue string) string {\n\tif v := os.Getenv(key); v != \"\" {\n\t\treturn v\n\t}\n\treturn defaultValue\n}\n"
  },
  {
    "path": "kubernetes/test/utils/utils.go",
    "content": "// Copyright 2025 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage utils\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t. \"github.com/onsi/ginkgo/v2\" // nolint:revive,staticcheck\n)\n\nconst (\n\tprometheusOperatorVersion = \"v0.77.1\"\n\tprometheusOperatorURL     = \"https://github.com/prometheus-operator/prometheus-operator/\" +\n\t\t\"releases/download/%s/bundle.yaml\"\n\n\tcertmanagerVersion = \"v1.16.3\"\n\tcertmanagerURLTmpl = \"https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml\"\n)\n\nfunc warnError(err error) {\n\t_, _ = fmt.Fprintf(GinkgoWriter, \"warning: %v\\n\", err)\n}\n\n// Run executes the provided command within this context\nfunc Run(cmd *exec.Cmd) (string, error) {\n\tdir, _ := GetProjectDir()\n\tcmd.Dir = dir\n\n\tif err := os.Chdir(cmd.Dir); err != nil {\n\t\t_, _ = fmt.Fprintf(GinkgoWriter, \"chdir dir: %q\\n\", err)\n\t}\n\n\tcmd.Env = append(os.Environ(), \"GO111MODULE=on\")\n\tcommand := strings.Join(cmd.Args, \" \")\n\t_, _ = fmt.Fprintf(GinkgoWriter, \"running: %q\\n\", command)\n\toutput, err := cmd.CombinedOutput()\n\tif err != nil {\n\t\treturn string(output), fmt.Errorf(\"%q failed with error %q: %w\", command, string(output), err)\n\t}\n\n\treturn string(output), nil\n}\n\n// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics.\nfunc InstallPrometheusOperator() error {\n\turl := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion)\n\tcmd := exec.Command(\"kubectl\", \"create\", \"-f\", url)\n\t_, err := Run(cmd)\n\treturn err\n}\n\n// UninstallPrometheusOperator uninstalls the prometheus\nfunc UninstallPrometheusOperator() {\n\turl := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion)\n\tcmd := exec.Command(\"kubectl\", \"delete\", \"-f\", url)\n\tif _, err := Run(cmd); err != nil {\n\t\twarnError(err)\n\t}\n}\n\n// IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed\n// by verifying the existence of key CRDs related to Prometheus.\nfunc IsPrometheusCRDsInstalled() bool {\n\t// List of common Prometheus CRDs\n\tprometheusCRDs := []string{\n\t\t\"prometheuses.monitoring.coreos.com\",\n\t\t\"prometheusrules.monitoring.coreos.com\",\n\t\t\"prometheusagents.monitoring.coreos.com\",\n\t}\n\n\tcmd := exec.Command(\"kubectl\", \"get\", \"crds\", \"-o\", \"custom-columns=NAME:.metadata.name\")\n\toutput, err := Run(cmd)\n\tif err != nil {\n\t\treturn false\n\t}\n\tcrdList := GetNonEmptyLines(output)\n\tfor _, crd := range prometheusCRDs {\n\t\tfor _, line := range crdList {\n\t\t\tif strings.Contains(line, crd) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\n// UninstallCertManager uninstalls the cert manager\nfunc UninstallCertManager() {\n\turl := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)\n\tcmd := exec.Command(\"kubectl\", \"delete\", \"-f\", url)\n\tif _, err := Run(cmd); err != nil {\n\t\twarnError(err)\n\t}\n}\n\n// InstallCertManager installs the cert manager bundle.\nfunc InstallCertManager() error {\n\turl := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion)\n\tcmd := exec.Command(\"kubectl\", \"apply\", \"-f\", url)\n\tif _, err := Run(cmd); err != nil {\n\t\treturn err\n\t}\n\t// Wait for cert-manager-webhook to be ready, which can take time if cert-manager\n\t// was re-installed after uninstalling on a cluster.\n\tcmd = exec.Command(\"kubectl\", \"wait\", \"deployment.apps/cert-manager-webhook\",\n\t\t\"--for\", \"condition=Available\",\n\t\t\"--namespace\", \"cert-manager\",\n\t\t\"--timeout\", \"5m\",\n\t)\n\n\t_, err := Run(cmd)\n\treturn err\n}\n\n// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed\n// by verifying the existence of key CRDs related to Cert Manager.\nfunc IsCertManagerCRDsInstalled() bool {\n\t// List of common Cert Manager CRDs\n\tcertManagerCRDs := []string{\n\t\t\"certificates.cert-manager.io\",\n\t\t\"issuers.cert-manager.io\",\n\t\t\"clusterissuers.cert-manager.io\",\n\t\t\"certificaterequests.cert-manager.io\",\n\t\t\"orders.acme.cert-manager.io\",\n\t\t\"challenges.acme.cert-manager.io\",\n\t}\n\n\t// Execute the kubectl command to get all CRDs\n\tcmd := exec.Command(\"kubectl\", \"get\", \"crds\")\n\toutput, err := Run(cmd)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\t// Check if any of the Cert Manager CRDs are present\n\tcrdList := GetNonEmptyLines(output)\n\tfor _, crd := range certManagerCRDs {\n\t\tfor _, line := range crdList {\n\t\t\tif strings.Contains(line, crd) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\n// LoadImageToKindClusterWithName loads a local docker image to the kind cluster\nfunc LoadImageToKindClusterWithName(name string) error {\n\tcluster := \"kind\"\n\tif v, ok := os.LookupEnv(\"KIND_CLUSTER\"); ok {\n\t\tcluster = v\n\t}\n\tkindOptions := []string{\"load\", \"docker-image\", name, \"--name\", cluster}\n\tcmd := exec.Command(\"kind\", kindOptions...)\n\t_, err := Run(cmd)\n\treturn err\n}\n\n// GetNonEmptyLines converts given command output string into individual objects\n// according to line breakers, and ignores the empty elements in it.\nfunc GetNonEmptyLines(output string) []string {\n\tvar res []string\n\telements := strings.Split(output, \"\\n\")\n\tfor _, element := range elements {\n\t\tif element != \"\" {\n\t\t\tres = append(res, element)\n\t\t}\n\t}\n\n\treturn res\n}\n\n// GetProjectDir will return the directory where the project is\nfunc GetProjectDir() (string, error) {\n\twd, err := os.Getwd()\n\tif err != nil {\n\t\treturn wd, fmt.Errorf(\"failed to get current working directory: %w\", err)\n\t}\n\twd = strings.ReplaceAll(wd, \"/test/e2e\", \"\")\n\treturn wd, nil\n}\n\n// UncommentCode searches for target in the file and remove the comment prefix\n// of the target content. The target content may span multiple lines.\nfunc UncommentCode(filename, target, prefix string) error {\n\t// false positive\n\t// nolint:gosec\n\tcontent, err := os.ReadFile(filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read file %q: %w\", filename, err)\n\t}\n\tstrContent := string(content)\n\n\tidx := strings.Index(strContent, target)\n\tif idx < 0 {\n\t\treturn fmt.Errorf(\"unable to find the code %q to be uncomment\", target)\n\t}\n\n\tout := new(bytes.Buffer)\n\t_, err = out.Write(content[:idx])\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to write to output: %w\", err)\n\t}\n\n\tscanner := bufio.NewScanner(bytes.NewBufferString(target))\n\tif !scanner.Scan() {\n\t\treturn nil\n\t}\n\tfor {\n\t\tif _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write to output: %w\", err)\n\t\t}\n\t\t// Avoid writing a newline in case the previous line was the last in target.\n\t\tif !scanner.Scan() {\n\t\t\tbreak\n\t\t}\n\t\tif _, err = out.WriteString(\"\\n\"); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to write to output: %w\", err)\n\t\t}\n\t}\n\n\tif _, err = out.Write(content[idx+len(target):]); err != nil {\n\t\treturn fmt.Errorf(\"failed to write to output: %w\", err)\n\t}\n\n\t// false positive\n\t// nolint:gosec\n\tif err = os.WriteFile(filename, out.Bytes(), 0644); err != nil {\n\t\treturn fmt.Errorf(\"failed to write file %q: %w\", filename, err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "oseps/0001-fqdn-based-egress-control.md",
    "content": "---\ntitle: FQDN-based Egress Control\nauthors:\n  - \"@hittyt\"\n  - \"@Pangjiping\"\ncreation-date: 2025-12-27\nlast-updated: 2026-01-22\nstatus: implemented\n---\n\n# OSEP-0001: FQDN-based Egress Control\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [API Schema](#api-schema)\n  - [Architecture Overview](#architecture-overview)\n  - [Layer 1: DNS Proxy](#layer-1-dns-proxy)\n  - [Layer 2: Network Filter](#layer-2-network-filter)\n  - [Capability Detection and Graceful Degradation](#capability-detection-and-graceful-degradation)\n  - [Enforcement Modes](#enforcement-modes)\n  - [Component Changes](#component-changes)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nThis proposal introduces domain-based (FQDN) egress control for OpenSandbox. It enables users to declaratively specify which external domains a sandbox can access, using a `network_policy` field in the Sandbox Lifecycle API. The implementation uses a two-layer approach (DNS-level filtering plus optional network-layer enforcement) delivered via a **sidecar** that shares the sandbox network namespace; the application container itself does not receive extra privileges.\n\n## Motivation\n\nIn AI Agent scenarios (e.g., Coding Agents, Data Analysis Agents), sandboxes frequently need controlled access to external services such as `api.github.com`, `pypi.org`, or `api.openai.com`. Currently, OpenSandbox lacks fine-grained network egress control.\n\nExisting industry solutions like E2B and Modal primarily rely on IP addresses or CIDR blocks for egress control. However, this approach has critical limitations:\n\n**Dynamic IP Challenges**: Modern cloud services and CDNs frequently change their underlying IP addresses. Manually maintaining an IP allowlist for domains like `api.github.com` is operationally expensive and error-prone.\n\n**Security Gaps**: IP-based rules can be bypassed if multiple services share the same IP address (common in virtual hosting). Without domain-level (L7) awareness, a sandbox allowed to access one service might inadvertently access others on the same host.\n\n**Developer Experience (DX)**: It is much more intuitive for developers to declare \"allow access to `openai.com`\" than to perform DNS lookups and input CIDR ranges during sandbox creation.\n\nOpenSandbox aims to be a universal AI sandbox platform. To meet enterprise-grade security and production requirements, it must support Domain-based (FQDN) Egress Control.\n\n### Goals\n\n1. **Declarative API**: Provide a `network_policy.egress` field in the Sandbox Lifecycle API that accepts domain-based allow/deny rules.\n2. **Wildcard Support**: Support wildcard patterns (e.g., `*.pypi.org`) for flexible policy definition.\n3. **Transparent to Applications**: Sandbox applications should not require any modification to work with egress policies.\n4. **Graceful Degradation**: The system should work across different privilege levels, degrading gracefully when kernel-level enforcement is unavailable.\n5. **Observable**: Provide clear visibility into the current enforcement mode and policy violations.\n6. **Runtime Agnostic**: Sidecar-based implementation works identically for Docker (shared network namespace) and Kubernetes (same Pod). No application-container privilege elevation; NET_ADMIN is isolated to the sidecar.\n\n### Non-Goals\n\n1. **L7 Deep Packet Inspection**: This proposal does not include HTTPS content inspection or TLS termination (mitmproxy-style).\n2. **Ingress Control**: This proposal focuses on egress (outbound) traffic only.\n3. **Rate Limiting**: Traffic rate limiting or bandwidth control is out of scope.\n4. **Per-Process Policies**: Policies apply to the entire sandbox, not individual processes.\n5. **IPv6-first**: Initial implementation focuses on IPv4; IPv6 support is a future enhancement.\n6. **External CRD Dependencies**: This proposal intentionally avoids depending on Kubernetes NetworkPolicy, CiliumNetworkPolicy, or other external resources. All enforcement happens inside a sidecar that shares the sandbox network namespace.\n7. **eBPF-based Filtering**: While eBPF offers performance benefits, nftables provides sufficient functionality for Layer 2. eBPF support may be added as a future performance optimization.\n\n## Requirements\n\n| ID | Requirement | Priority |\n|----|-------------|----------|\n| R1 | Users can specify allowed domains via SDK/API | Must Have |\n| R2 | Wildcard domain matching (e.g., `*.example.com`) | Must Have |\n| R3 | Default deny policy when `network_policy` is specified | Must Have |\n| R4 | NET_ADMIN confined to sidecar; application container runs without added privileges; if NET_ADMIN unavailable, policy disables with warning | Must Have |\n| R5 | Full network isolation when `CAP_NET_ADMIN` is available | Should Have |\n| R6 | Enforcement mode is observable via API | Should Have |\n| R7 | Policy violations are logged | Should Have |\n| R8 | IPv6 support | Could Have |\n\n## Proposal\n\nWe propose a **two-layer architecture** for FQDN egress control:\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│                     Sandbox Pod / Net Namespace                     │\n│                                                                     │\n│   ┌───────────────────┐            ┌────────────────────────────┐   │\n│   │ Application       │            │ Egress Sidecar             │   │\n│   │ Container         │            │ (NET_ADMIN)                │   │\n│   │ (no NET_ADMIN)    │            │ - Layer 1: DNS Proxy       │   │\n│   │                   │            │   · Intercepts all DNS     │   │\n│   └─────────┬─────────┘            │   · Applies policy         │   │\n│             │ DNS Query            │   · Learns domain→IP       │   │\n│             ▼                      │ - Layer 2: Network Filter  │   │\n│      (shared network namespace)    │   · nftables allowlist     │   │\n│                                    │   · Blocks others / DoH    │   │\n│                                    └─────────────┬──────────────┘   │\n│                                                  │                  │\n│                                                  ▼                  │\n│                                            External Network         │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n**Layer 1 (DNS Proxy)** provides the user experience benefit and relies on `CAP_NET_ADMIN` for transparent iptables REDIRECT. If the capability is missing, DNS interception cannot be installed; the policy is skipped with a warning.\n\n**Layer 2 (Network Filter)** provides true network isolation by enforcing that only IPs learned from Layer 1 are reachable. This layer requires `CAP_NET_ADMIN` and is optional.\n\n### Notes/Constraints/Caveats\n\n1. **DNS-only mode is a soft limit**: Without Layer 2 (nftables), applications can bypass DNS filtering by using direct IP connections (e.g., `curl http://140.82.114.6`) or DNS-over-HTTPS/TLS (DoH/DoT). Note: hardcoded DNS servers are NOT a bypass vector because iptables REDIRECT intercepts all port 53 traffic (when `CAP_NET_ADMIN` is present).\n\n2. **Container startup order**: The DNS proxy must be ready before any application process starts to avoid race conditions.\n\n3. **Localhost exemption**: `localhost`, `127.0.0.1`, and container-internal communication should always be allowed.\n\n5. **Cross-platform considerations**: The two-layer architecture uses platform abstraction:\n   - **Layer 1 (DNS Proxy)**: Core logic is cross-platform (pure Go). System resolver configuration requires platform-specific code (`/etc/resolv.conf` on Linux, `netsh` on Windows).\n   - **Layer 2 (Network Filter)**: Requires platform-specific implementations (nftables on Linux, WFP on Windows, pf on macOS). The system gracefully degrades to DNS-only mode on platforms without Layer 2 support.\n\n6. **No resolv.conf modification needed**: With the simplified CAP_NET_ADMIN approach, we use `iptables REDIRECT` to intercept DNS traffic. This avoids all resolv.conf-related issues:\n   - Works in read-only `/etc/resolv.conf` scenarios (Kubernetes, hardened containers)\n   - More powerful interception (catches applications that hardcode DNS servers)\n   - Consistent behavior across all deployment modes\n- **Graceful degradation**: If iptables setup fails (e.g., missing `CAP_NET_ADMIN`), logs warning and continues without enforcement (network policy disabled)\n\n7. **Simplified privilege model with CAP_NET_ADMIN**: When `network_policy` is specified, the runtime grants `CAP_NET_ADMIN` capability to the container:\n   - **No user switching required**: Container runs as the image's original user (root or non-root)\n   - **iptables REDIRECT**: DNS traffic is intercepted via iptables, which works with CAP_NET_ADMIN regardless of user\n   - **No resolv.conf modification needed**: iptables redirects port 53 traffic to DNS Proxy on a non-privileged port\n   - **Unified Docker/K8s behavior**: Same simple logic for both runtimes\n\n8. **CAP_NET_ADMIN security considerations**:\n   - CAP_NET_ADMIN allows network configuration within the container's network namespace\n   - Container network namespace isolation limits the impact (cannot affect host or other containers)\n   - This is acceptable because the sandbox itself is the primary security boundary\n   - For K8s clusters with `restricted` Pod Security Standards (which prohibit any capabilities), network_policy enforcement will degrade gracefully with a warning\n9. **HostNetwork is unsupported when network_policy is enabled**:\n   - K8s: if `hostNetwork=true`, the server MUST reject sandbox creation when `network_policy` is set, because NET_ADMIN in hostNetwork would affect the node.\n   - Docker: if `--network host` is requested with `network_policy`, the request MUST be rejected.\n   - Sidecar SHOULD self-check and refuse to start (logging a warning) if it detects host network mode, to avoid touching host iptables/nftables.\n9. **No resolv.conf fallback**: We intentionally avoid rewriting `/etc/resolv.conf`. If `CAP_NET_ADMIN` is unavailable and iptables REDIRECT cannot be installed, DNS interception is not possible; network_policy is disabled and a warning is logged.\n\n### Risks and Mitigations\n\n| Risk | Impact | Mitigation |\n|------|--------|------------|\n| DNS-only bypass via direct IP | Security | Document limitation clearly; recommend `CAP_NET_ADMIN` for security-critical use cases |\n| DoH/DoT bypass | Security | Layer 2 blocks ports 443 to known DoH providers and port 853 (DoT) |\n| Performance overhead | Reliability | DNS proxy adds <1ms latency; nftables is kernel-native with negligible overhead |\n| Kernel compatibility | Compatibility | Runtime capability detection with graceful degradation |\n| Application breaks due to DNS filtering | Usability | Clear error messages; policy validation at creation time |\n| CAP_NET_ADMIN required | Privilege | Clear documentation; graceful degradation with warning when capability not available |\n| K8s restricted PSS | Compatibility | Clusters with `restricted` Pod Security Standards prohibit capabilities; network_policy will degrade with warning |\n| Malicious code with CAP_NET_ADMIN | Security | Container network namespace isolation limits impact; cannot affect host or other containers |\n\n## Design Details\n\n### Design Principle: Sidecar as the Egress Controller\n\nA key design decision is that **all egress control logic resides in a dedicated sidecar** that shares the sandbox network namespace. The application container keeps its default privileges (no NET_ADMIN). This approach provides:\n\n1. **Runtime Agnostic**: The same sidecar pattern works for Docker (network_mode: container) and Kubernetes (same Pod).\n2. **Zero App-Container Privilege Elevation**: NET_ADMIN is confined to the sidecar; the application container runs unprivileged.\n3. **Consistent Behavior**: Users get identical egress control behavior regardless of runtime when they opt into the sidecar.\n4. **Operational Separation**: Network policy configuration, logging, and debugging are isolated in the sidecar; application image remains unchanged.\n\n```\n┌──────────────────────────────────────────────────────────────────┐\n│                     OpenSandbox Server                           │\n│  ┌─────────────────────┐    ┌───────────────────┐                │\n│  │ DockerSandboxService│    │ K8sSandboxService │                │\n│  └─────────┬───────────┘    └────────┬──────────┘                │\n│            │                         │                           │\n│            │ Pass network_policy     │ Pass network_policy       │\n│            │ via env/config          │ via env/config            │\n│            └───────────┬─────────────┘                           │\n└────────────────────────┼─────────────────────────────────────────┘\n                         ▼\n┌──────────────────────────────────────────────────────────────────┐\n│                    Sandbox (shared net namespace)                │\n│                                                                  │\n│  ┌───────────────────┐      ┌────────────────────────────────┐   │\n│  │ Application       │      │ Egress Sidecar (NET_ADMIN)     │   │\n│  │ Container         │      │ - DNS Proxy (Layer 1)          │   │\n│  │ (no NET_ADMIN)    │      │ - Network Filter (Layer 2)     │   │\n│  └─────────┬─────────┘      │ - Capability Detection         │   │\n│            │ DNS Query      └────────────────────────────────┘   │\n│            ▼                                                     │\n│        (shared netns)                                            │\n│                                                                  │\n└──────────────────────────────────────────────────────────────────┘\n```\n\n### API Schema\n\nExtension to `specs/sandbox-lifecycle.yml`:\n\n```yaml\ncomponents:\n  schemas:\n    NetworkPolicy:\n      type: object\n      properties:\n        egress:\n          type: array\n          items:\n            $ref: '#/components/schemas/EgressRule'\n        defaultAction:\n          type: string\n          enum: [allow, deny]\n          default: deny\n          description: Default action when no rules match\n        require_full_isolation:\n          type: boolean\n          default: false\n          description: If true, sandbox creation fails when network-layer enforcement is unavailable\n\n    EgressRule:\n      type: object\n      required:\n        - action\n        - target\n      properties:\n        action:\n          type: string\n          enum: [allow, deny]\n        target:\n          type: string\n          description: |\n            Destination specification. Supports multiple formats:\n            - FQDN: \"api.github.com\"\n            - Wildcard domain: \"*.pypi.org\"\n            - IP address: \"10.0.0.5\"\n            - CIDR block: \"10.0.0.0/8\"\n            \n            Note: IP/CIDR rules require Layer 2 (nftables) to be effective.\n            In dns-only mode, IP/CIDR rules will be ignored with a warning.\n\n    CreateSandboxRequest:\n      # ... existing fields ...\n      properties:\n        network_policy:\n          $ref: '#/components/schemas/NetworkPolicy'\n```\n\n**SDK Usage Example (Python)**:\n\n```python\nfrom opensandbox import Sandbox, NetworkPolicy, EgressRule\n\nsandbox = await Sandbox.create(\n    image=\"python:3.11\",\n    network_policy=NetworkPolicy(\n        egress=[\n            # Domain rules (handled by DNS Proxy)\n            EgressRule(action=\"allow\", target=\"api.github.com\"),\n            EgressRule(action=\"allow\", target=\"*.pypi.org\"),\n            \n            # IP/CIDR rules (handled by nftables directly)\n            EgressRule(action=\"allow\", target=\"10.0.0.5\"),       # Single IP\n            EgressRule(action=\"allow\", target=\"10.96.0.0/12\"),   # K8s Service CIDR\n        ],\n        defaultAction=\"deny\",\n    ),\n)\n```\n\n### Architecture Overview\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                              Server (Python)                                │\n│  ┌──────────────────────────────────────────────────────────────────────┐   │\n│  │ CreateSandboxRequest                                                 │   │\n│  │   network_policy:                                                    │   │\n│  │     egress:                                                          │   │\n│  │       - {action: allow, target: \"api.github.com\"}                    │   │\n│  │       - {action: allow, target: \"*.pypi.org\"}                        │   │\n│  └───────────────────────────────┬──────────────────────────────────────┘   │\n│                                  │                                          │\n│                                  ▼                                          │\n│  ┌──────────────────────────────────────────────────────────────────────┐   │\n│  │ DockerSandboxService / K8sSandboxService                             │   │\n│  │   1. Start egress sidecar (CAP_NET_ADMIN) + app container (shared ns) │   │\n│  │   2. Inject OPENSANDBOX_EGRESS_TOKEN into sidecar                    │   │\n│  │   3. (Optional) Seed policy from env OPENSANDBOX_EGRESS_RULES        │   │\n│  │   4. Wait for sidecar /healthz = 200                                 │   │\n│  │   5. POST network_policy to /policy with header                      │   │\n│  │      \"OPENSANDBOX-EGRESS-AUTH: <token>\"                              │   │\n│  └───────────────────────────────┬──────────────────────────────────────┘   │\n└──────────────────────────────────┼──────────────────────────────────────────┘\n                                   │\n                                   ▼\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                           Sandbox (shared netns)                             │\n│  ┌──────────────────────────────────────────────────────────────────────┐   │\n│  │ Egress Sidecar (NET_ADMIN)                                           │   │\n│  │   1. Load optional bootstrap from OPENSANDBOX_EGRESS_RULES (else deny-all)│   │\n│  │   2. Accept updates via HTTP /policy (with auth header)              │   │\n│  │   3. Start DNS Proxy on 127.0.0.1:15353 (non-privileged port)        │   │\n│  │   4. Setup iptables REDIRECT 53→15353 (CAP_NET_ADMIN)                │   │\n│  │   5. Probe nftables capability (fallback to dns-only)                │   │\n│  │   6. Initialize network filter if available                          │   │\n│  └──────────────────────────────────────────────────────────────────────┘   │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Layer 1: DNS Proxy\n\nThe DNS proxy runs inside the egress sidecar and handles all DNS queries from the sandbox shared network namespace.\n\n#### Listening Address Selection\n\nWith the iptables REDIRECT approach, the DNS proxy binds to a **non-privileged port** (15353) and iptables redirects traffic from port 53:\n\n| Approach | Port | Privilege Needed | Notes |\n|----------|------|-----------------|-------|\n| iptables REDIRECT | `127.0.0.1:15353` | CAP_NET_ADMIN | ✅ **Recommended** - works without root |\n| Direct binding | `127.0.0.1:53` | root user | ❌ Requires root to bind privileged port |\n| Modify resolv.conf | `127.0.0.1:53` | writable resolv.conf | ❌ Not always writable (K8s, hardened) |\n\n> **Note**: By using iptables REDIRECT, we avoid needing root to bind to port 53, and avoid needing to modify `/etc/resolv.conf`. All DNS traffic to port 53 is transparently redirected to our proxy on port 15353.\n\n#### Startup Sequence\n\n```go\nfunc (p *DNSProxy) Start() error {\n    // 1. Bind to non-privileged port (doesn't require root)\n    addr := \"127.0.0.1:15353\"\n    server := &dns.Server{Addr: addr, Net: \"udp\", Handler: p}\n    \n    go func() {\n        if err := server.ListenAndServe(); err != nil {\n            logs.Error(\"[dns] proxy server error: %v\", err)\n        }\n    }()\n    \n    p.server = server\n    return nil\n}\n\nfunc (c *Controller) setupIptablesRedirect() error {\n    // 2. Setup iptables REDIRECT (requires CAP_NET_ADMIN, NOT root)\n    rules := [][]string{\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", \"15353\"},\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", \"15353\"},\n    }\n    \n    for _, args := range rules {\n        if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {\n            return fmt.Errorf(\"%v failed: %w (output: %s)\", args, err, output)\n        }\n    }\n    return nil\n}\n```\n\n#### Discovering Upstream DNS Servers\n\nThe DNS Proxy needs to know where to forward allowed queries. It reads the container's original `/etc/resolv.conf` to discover upstream DNS servers:\n\n```go\nfunc (p *DNSProxy) initUpstream() error {\n    // Read resolv.conf to discover upstream DNS servers\n    content, err := os.ReadFile(\"/etc/resolv.conf\")\n    if err != nil {\n        // Use fallback DNS servers if resolv.conf is unreadable\n        p.upstream = []string{\"8.8.8.8:53\", \"1.1.1.1:53\"}\n        return nil\n    }\n    \n    // Parse nameserver entries\n    p.upstream = parseNameservers(content)\n    if len(p.upstream) == 0 {\n        p.upstream = []string{\"8.8.8.8:53\", \"1.1.1.1:53\"}\n    }\n    return nil\n}\n```\n\n> **Note**: With iptables REDIRECT, we don't modify `/etc/resolv.conf`. We only read it to discover upstream servers.\n\n#### DNS Interception via iptables\n\n**Simplified Approach (CAP_NET_ADMIN only)**:\n\nWith the simplified design, we only use iptables REDIRECT. The logic is straightforward:\n\n```go\nfunc (c *Controller) setupNetworkPolicy() error {\n    // Setup iptables REDIRECT (requires CAP_NET_ADMIN)\n    if err := c.setupIptablesRedirect(); err != nil {\n        // Graceful degradation: log warning but don't fail\n        logs.Warn(\"[egress] iptables setup failed: %v\", err)\n        logs.Warn(\"[egress] network_policy will NOT be enforced\")\n        logs.Warn(\"[egress] ensure container has CAP_NET_ADMIN capability\")\n        \n        // Continue running sidecar (other functionality still works)\n        // The sandbox still works, just without network policy enforcement\n        return nil\n    }\n    \n    logs.Info(\"[egress] network policy active (iptables REDIRECT mode)\")\n    return nil\n}\n```\n\n**Key Design Decision**: Always graceful degradation. If iptables fails:\n- Log clear warning messages\n- Continue running sidecar (other functionality still works)\n- User can see via logs/status that policy is not enforced\n- No error thrown, no container crash\n\n> **Note**: The `require_full_isolation` field in the API schema allows users to **opt-in** to strict mode where sandbox creation fails if policy cannot be enforced. But the default is graceful degradation.\n\n**Implementation Details**:\n\n```go\n// pkg/egress/dns/proxy.go\n\npackage dns\n\nimport (\n    \"net\"\n    \"strings\"\n    \"sync\"\n    \"time\"\n\n    \"github.com/miekg/dns\"\n)\n\ntype DNSProxy struct {\n    policy       *NetworkPolicy\n    upstream     string              // e.g., \"8.8.8.8:53\"\n    server       *dns.Server\n    resolvedIPs  sync.Map            // domain -> []ResolvedIP\n    onIPLearned  func(domain string, ips []net.IP)\n}\n\n// ResolvedIP tracks IPs learned from DNS queries\ntype ResolvedIP struct {\n    IP net.IP\n}\n\nfunc (p *DNSProxy) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {\n    if len(r.Question) == 0 {\n        p.refuseQuery(w, r)\n        return\n    }\n\n    domain := strings.TrimSuffix(r.Question[0].Name, \".\")\n    \n    // Always allow localhost\n    if p.isLocalhost(domain) {\n        p.forwardQuery(w, r)\n        return\n    }\n\n    // Check policy\n    action := p.policy.Evaluate(domain)\n    if action == ActionDeny {\n        p.logDenied(domain)\n        p.respondNXDomain(w, r)\n        return\n    }\n\n    // Forward to upstream and learn IPs\n    resp, err := p.forwardAndLearn(r, domain)\n    if err != nil {\n        p.respondServerFailure(w, r)\n        return\n    }\n\n    w.WriteMsg(resp)\n}\n\nfunc (p *DNSProxy) forwardAndLearn(r *dns.Msg, domain string) (*dns.Msg, error) {\n    client := &dns.Client{Timeout: 5 * time.Second}\n    resp, _, err := client.Exchange(r, p.upstream)\n    if err != nil {\n        return nil, err\n    }\n\n    // Extract IPs from response\n    var ips []net.IP\n    for _, rr := range resp.Answer {\n        switch v := rr.(type) {\n        case *dns.A:\n            ips = append(ips, v.A)\n        case *dns.AAAA:\n            ips = append(ips, v.AAAA)\n        }\n    }\n\n    // Store resolved IPs and notify network filter\n    if len(ips) > 0 {\n        p.storeResolvedIPs(domain, ips)\n        \n        // Notify network filter layer\n        if p.onIPLearned != nil {\n            p.onIPLearned(domain, ips)\n        }\n    }\n\n    return resp, nil\n}\n\nfunc (p *DNSProxy) respondNXDomain(w dns.ResponseWriter, r *dns.Msg) {\n    m := new(dns.Msg)\n    m.SetRcode(r, dns.RcodeNameError)\n    m.Authoritative = true\n    w.WriteMsg(m)\n}\n```\n\n**Policy Matching**:\n\n```go\n// pkg/egress/policy.go\n\ntype NetworkPolicy struct {\n    Egress        []EgressRule `json:\"egress\"`\n    DefaultAction Action       `json:\"defaultAction\"`\n}\n\ntype TargetType int\n\nconst (\n    TargetTypeDomain TargetType = iota  // FQDN or wildcard\n    TargetTypeIP                         // Single IP address\n    TargetTypeCIDR                       // CIDR block\n)\n\ntype EgressRule struct {\n    Action Action `json:\"action\"`\n    Target string `json:\"target\"`\n    \n    // Parsed target (internal)\n    targetType  TargetType\n    domainRegex *regexp.Regexp  // for TargetTypeDomain\n    ip          net.IP          // for TargetTypeIP\n    cidr        *net.IPNet      // for TargetTypeCIDR\n}\n\nfunc (r *EgressRule) Parse() error {\n    // Try CIDR first\n    if _, cidr, err := net.ParseCIDR(r.Target); err == nil {\n        r.targetType = TargetTypeCIDR\n        r.cidr = cidr\n        return nil\n    }\n    \n    // Try single IP\n    if ip := net.ParseIP(r.Target); ip != nil {\n        r.targetType = TargetTypeIP\n        r.ip = ip\n        return nil\n    }\n    \n    // Treat as domain (FQDN or wildcard)\n    r.targetType = TargetTypeDomain\n    return nil\n}\n\nfunc (p *NetworkPolicy) Evaluate(domain string) Action {\n    domain = strings.ToLower(domain)\n    \n    for _, rule := range p.Egress {\n        if rule.MatchesDomain(domain) {\n            return rule.Action\n        }\n    }\n    \n    return p.DefaultAction\n}\n\nfunc (r *EgressRule) MatchesDomain(domain string) bool {\n    if r.targetType != TargetTypeDomain {\n        return false\n    }\n    \n    pattern := strings.ToLower(r.Target)\n    domain = strings.ToLower(domain)\n    \n    // Exact match\n    if pattern == domain {\n        return true\n    }\n    \n    // Wildcard match: *.example.com matches foo.example.com, bar.example.com\n    if strings.HasPrefix(pattern, \"*.\") {\n        suffix := pattern[1:] // \".example.com\"\n        return strings.HasSuffix(domain, suffix) || domain == pattern[2:]\n    }\n    \n    return false\n}\n\nfunc (r *EgressRule) MatchesIP(ip net.IP) bool {\n    switch r.targetType {\n    case TargetTypeIP:\n        return r.ip.Equal(ip)\n    case TargetTypeCIDR:\n        return r.cidr.Contains(ip)\n    default:\n        return false\n    }\n}\n```\n\n**Static IP/CIDR Rules Initialization**:\n\nAt startup, the controller parses all rules and adds static IP/CIDR entries directly to nftables:\n\n```go\nfunc (c *Controller) initializeStaticRules() error {\n    for _, rule := range c.policy.Egress {\n        if err := rule.Parse(); err != nil {\n            return err\n        }\n        \n        if rule.Action != ActionAllow {\n            continue\n        }\n        \n        switch rule.targetType {\n        case TargetTypeIP:\n            if c.netFilter != nil {\n                c.netFilter.AddAllowedIPs([]net.IP{rule.ip})\n                logs.Info(\"[egress] static IP allowed: %s\", rule.ip)\n            } else {\n                logs.Warn(\"[egress] IP rule %s ignored (nftables unavailable)\", rule.Target)\n            }\n            \n        case TargetTypeCIDR:\n            if c.netFilter != nil {\n                c.netFilter.AddAllowedCIDR(rule.cidr)\n                logs.Info(\"[egress] static CIDR allowed: %s\", rule.cidr)\n            } else {\n                logs.Warn(\"[egress] CIDR rule %s ignored (nftables unavailable)\", rule.Target)\n            }\n        }\n    }\n    return nil\n}\n```\n\n### Layer 2: Network Filter\n\nWhen `CAP_NET_ADMIN` is available, the sidecar sets up kernel-level packet filtering.\n\n**nftables Implementation**:\n\n```go\n// pkg/egress/netfilter/nftables.go\n\npackage netfilter\n\nimport (\n    \"net\"\n    \"sync\"\n\n    \"github.com/google/nftables\"\n    \"github.com/google/nftables/expr\"\n)\n\ntype NftablesFilter struct {\n    conn       *nftables.Conn\n    table      *nftables.Table\n    chain      *nftables.Chain\n    allowedSet *nftables.Set\n    mu         sync.Mutex\n}\n\nfunc NewNftablesFilter() (*NftablesFilter, error) {\n    conn, err := nftables.New()\n    if err != nil {\n        return nil, err\n    }\n\n    f := &NftablesFilter{conn: conn}\n    if err := f.initialize(); err != nil {\n        conn.CloseLasting()\n        return nil, err\n    }\n\n    return f, nil\n}\n\nfunc (f *NftablesFilter) initialize() error {\n    // Create table\n    f.table = &nftables.Table{\n        Family: nftables.TableFamilyIPv4,\n        Name:   \"opensandbox_egress\",\n    }\n    f.conn.AddTable(f.table)\n\n    // Create set for allowed IPs\n    f.allowedSet = &nftables.Set{\n        Table:   f.table,\n        Name:    \"allowed_ips\",\n        KeyType: nftables.TypeIPAddr,\n    }\n    if err := f.conn.AddSet(f.allowedSet, nil); err != nil {\n        return err\n    }\n\n    // Create output chain with default drop\n    f.chain = &nftables.Chain{\n        Name:     \"output\",\n        Table:    f.table,\n        Type:     nftables.ChainTypeFilter,\n        Hooknum:  nftables.ChainHookOutput,\n        Priority: nftables.ChainPriorityFilter,\n        Policy:   nftables.ChainPolicyPtr(nftables.ChainPolicyDrop),\n    }\n    f.conn.AddChain(f.chain)\n\n    // Allow localhost\n    f.addLocalhostRules()\n\n    // Allow established connections\n    f.addEstablishedRule()\n\n    // Allow IPs in the allowed set\n    f.conn.AddRule(&nftables.Rule{\n        Table: f.table,\n        Chain: f.chain,\n        Exprs: []expr.Any{\n            // Match destination IP in allowed_ips set\n            &expr.Payload{\n                DestRegister: 1,\n                Base:         expr.PayloadBaseNetworkHeader,\n                Offset:       16, // dst IP offset in IPv4\n                Len:          4,\n            },\n            &expr.Lookup{\n                SourceRegister: 1,\n                SetName:        f.allowedSet.Name,\n            },\n            &expr.Verdict{Kind: expr.VerdictAccept},\n        },\n    })\n\n    // Block DoH (known providers on port 443)\n    f.blockDoHProviders()\n\n    // Block DoT (port 853)\n    f.blockPort(853)\n\n    return f.conn.Flush()\n}\n\nfunc (f *NftablesFilter) AddAllowedIPs(ips []net.IP) error {\n    f.mu.Lock()\n    defer f.mu.Unlock()\n\n    elements := make([]nftables.SetElement, 0, len(ips))\n    for _, ip := range ips {\n        if ipv4 := ip.To4(); ipv4 != nil {\n            elements = append(elements, nftables.SetElement{Key: ipv4})\n        }\n    }\n\n    if len(elements) == 0 {\n        return nil\n    }\n\n    if err := f.conn.SetAddElements(f.allowedSet, elements); err != nil {\n        return err\n    }\n\n    return f.conn.Flush()\n}\n\nfunc (f *NftablesFilter) AddAllowedCIDR(cidr *net.IPNet) error {\n    f.mu.Lock()\n    defer f.mu.Unlock()\n\n    // nftables supports prefix matching via interval sets\n    // Add the CIDR as a prefix rule\n    f.conn.AddRule(&nftables.Rule{\n        Table: f.table,\n        Chain: f.chain,\n        Exprs: []expr.Any{\n            // Match destination IP in CIDR range\n            &expr.Payload{\n                DestRegister: 1,\n                Base:         expr.PayloadBaseNetworkHeader,\n                Offset:       16, // dst IP offset in IPv4\n                Len:          4,\n            },\n            &expr.Bitwise{\n                SourceRegister: 1,\n                DestRegister:   1,\n                Len:            4,\n                Mask:           cidr.Mask,\n                Xor:            []byte{0, 0, 0, 0},\n            },\n            &expr.Cmp{\n                Op:       expr.CmpOpEq,\n                Register: 1,\n                Data:     cidr.IP.To4(),\n            },\n            &expr.Verdict{Kind: expr.VerdictAccept},\n        },\n    })\n\n    return f.conn.Flush()\n}\n```\n\n### Capability Detection and Graceful Degradation\n\n```go\n// pkg/egress/controller.go\n\npackage egress\n\nimport (\n    \"errors\"\n    \"syscall\"\n\n    \"github.com/beego/beego/v2/core/logs\"\n)\n\ntype EnforcementMode int\n\nconst (\n    ModeDisabled   EnforcementMode = iota // No network_policy configured\n    ModeDNSOnly                           // DNS filtering only (soft limit)\n    ModeNftables                          // DNS + nftables (full isolation)\n)\n\nfunc (m EnforcementMode) String() string {\n    return [...]string{\"disabled\", \"dns-only\", \"dns+nftables\"}[m]\n}\n\nfunc (m EnforcementMode) IsFullIsolation() bool {\n    return m == ModeNftables\n}\n\ntype Controller struct {\n    mode        EnforcementMode\n    policy      *NetworkPolicy\n    dnsProxy    *DNSProxy\n    netFilter   NetFilter\n}\n\ntype NetFilter interface {\n    AddAllowedIPs(ips []net.IP) error\n    AddAllowedCIDR(cidr *net.IPNet) error  // For static CIDR rules\n    Close() error\n}\n\nfunc NewController(policy *NetworkPolicy) (*Controller, error) {\n    ctrl := &Controller{policy: policy}\n\n    // No policy = default deny-all fallback\n    // - DNS proxy still runs with deny-all baseline\n    // - No resolv.conf modification\n    // - No network filtering if nftables unavailable\n    // - External access denied unless rules are provided\n    if policy == nil || len(policy.Egress) == 0 {\n        ctrl.mode = ModeDNSOnly\n        ctrl.policy = &NetworkPolicy{DefaultAction: ActionDeny}\n        logs.Info(\"[egress] no network_policy configured; enforcing default deny-all\")\n    }\n\n    // Probe capabilities in order of preference\n    mode, netFilter := ctrl.probeCapabilities()\n    ctrl.mode = mode\n    ctrl.netFilter = netFilter\n\n    // Fail if full isolation is required but unavailable\n    if policy.RequireFullIsolation && !mode.IsFullIsolation() {\n        return nil, errors.New(\"network_policy.require_full_isolation is true but CAP_NET_ADMIN is not available\")\n    }\n\n    // Start DNS proxy\n    dnsProxy, err := NewDNSProxy(policy)\n    if err != nil {\n        return nil, err\n    }\n    ctrl.dnsProxy = dnsProxy\n\n    // Wire DNS proxy to network filter\n    if netFilter != nil {\n        dnsProxy.onIPLearned = func(domain string, ips []net.IP) {\n            if err := netFilter.AddAllowedIPs(ips); err != nil {\n                logs.Warn(\"[egress] failed to add IPs to filter: %v\", err)\n            }\n        }\n    }\n\n    logs.Info(\"[egress] control mode: %s\", mode)\n    if !mode.IsFullIsolation() {\n        logs.Warn(\"[egress] running in dns-only mode; direct IP connections can bypass policy\")\n        logs.Warn(\"[egress] for full isolation, run container with CAP_NET_ADMIN\")\n    }\n\n    return ctrl, nil\n}\n\nfunc (c *Controller) probeCapabilities() (EnforcementMode, NetFilter) {\n    // Try nftables for Layer 2 network filtering\n    if nft, err := NewNftablesFilter(); err == nil {\n        logs.Debug(\"[egress] nftables probe succeeded\")\n        return ModeNftables, nft\n    } else {\n        logs.Debug(\"[egress] nftables probe failed: %v\", err)\n    }\n\n    // Fallback to DNS-only (no Layer 2 protection)\n    return ModeDNSOnly, nil\n}\n\nfunc isPermissionError(err error) bool {\n    var errno syscall.Errno\n    if errors.As(err, &errno) {\n        return errno == syscall.EPERM || errno == syscall.EACCES\n    }\n    return false\n}\n\nfunc (c *Controller) Mode() EnforcementMode {\n    return c.mode\n}\n\nfunc (c *Controller) Start() error {\n    if c.mode == ModeDisabled {\n        return nil\n    }\n    return c.dnsProxy.Start()\n}\n\nfunc (c *Controller) Stop() error {\n    if c.dnsProxy != nil {\n        c.dnsProxy.Stop()\n    }\n    if c.netFilter != nil {\n        c.netFilter.Close()\n    }\n    return nil\n}\n```\n\n### Enforcement Modes\n\n| Mode | DNS Filtering | Network Filtering | Bypass Possible | Privilege Required |\n|------|--------------|-------------------|-----------------|-------------------|\n| `disabled` | No | No | N/A | None |\n| `dns-only` | Yes (iptables REDIRECT) | No | Yes (direct IP, DoH) | `CAP_NET_ADMIN` (if absent → falls back to `disabled` with warning) |\n| `dns+nftables` | Yes (iptables REDIRECT) | Yes (nftables) | No | `CAP_NET_ADMIN` |\n\n> **Note**: All enforcement modes (except `disabled`) require `CAP_NET_ADMIN` for iptables REDIRECT. The difference is whether nftables-based network filtering is available for full isolation.\n\n### Cross-Platform Support\n\nThe implementation uses Go build tags to provide platform-specific implementations while maintaining a unified interface.\n\n| Component | Linux | Windows | macOS | Notes |\n|-----------|-------|---------|-------|-------|\n| DNS Proxy Server | ✅ `miekg/dns` | ✅ `miekg/dns` | ✅ `miekg/dns` | Pure Go, cross-platform |\n| Policy Matching | ✅ | ✅ | ✅ | Pure logic, no OS deps |\n| DNS Interception | iptables REDIRECT | netsh / WFP (future) | pf (future) | Platform-specific |\n| Network Filter | nftables | WFP (future) | pf (future) | Platform-specific |\n\n**Implementation Strategy**:\n\n```go\n// pkg/egress/interception/interception.go\n// Platform-specific DNS interception via build tags\n\n//go:build linux\nfunc SetupDNSInterception(proxyPort int) error {\n    // iptables REDIRECT - requires CAP_NET_ADMIN, not root\n    rules := [][]string{\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", fmt.Sprintf(\"%d\", proxyPort)},\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", fmt.Sprintf(\"%d\", proxyPort)},\n    }\n    for _, args := range rules {\n        if err := exec.Command(args[0], args[1:]...).Run(); err != nil {\n            return err\n        }\n    }\n    return nil\n}\n\n//go:build windows\nfunc SetupDNSInterception(proxyPort int) error {\n    // Windows: fallback to netsh for now (future: WFP)\n    return exec.Command(\"netsh\", \"interface\", \"ip\", \"set\", \"dns\",\n        \"name=Ethernet\", \"static\", \"127.0.0.1\").Run()\n}\n```\n\n**Phased Platform Support**:\n\n| Phase | Platform | Layer 1 | Layer 2 | Priority |\n|-------|----------|---------|---------|----------|\n| 1 | Linux | ✅ DNS Proxy | ✅ nftables | High (production) |\n| 2 | Windows | ✅ DNS Proxy | ❌ DNS-only | Medium (Windows containers) |\n| 3 | Windows | ✅ DNS Proxy | ✅ WFP | Low (full Windows support) |\n| 4 | macOS | ✅ DNS Proxy | ❌ DNS-only | Low (dev environment) |\n\n**Windows Platform Notes**:\n\nThe simplified CAP_NET_ADMIN approach is Linux-specific. Windows containers require different handling:\n\n| Aspect | Linux | Windows |\n|--------|-------|---------|\n| Network Filter | iptables (CAP_NET_ADMIN) | Windows Filtering Platform (WFP) |\n| DNS Config | Not needed (iptables REDIRECT) | netsh / Registry |\n| Privilege Model | CAP_NET_ADMIN capability | Administrator privilege |\n\n**Windows Strategy** (Future work):\n- Use WFP APIs for network filtering (requires Administrator)\n- DNS proxy with netsh configuration as fallback\n- Windows container support is lower priority (Phase 3+)\n\n### Simplified Privilege Model: CAP_NET_ADMIN confined to Sidecar\n\nThe sidecar holds the only elevated capability (`CAP_NET_ADMIN`) needed for iptables/nftables. The application container runs with its original user and no added capabilities. No `resolv.conf` modification is required; DNS is intercepted transparently in the shared network namespace.\n\n#### Deployment Flow (Docker)\n\n- Create an egress sidecar container with `--cap-add=NET_ADMIN` (no root required).\n- Run the application container with `--network container:<sidecar>` so they share one network namespace.\n- Sidecar starts DNS proxy on `127.0.0.1:15353`, installs iptables REDIRECT 53→15353, probes nftables.\n- Server waits for sidecar `/healthz` 200, then POSTs the declared sandbox network policy to sidecar `/policy`\n  with header `OPENSANDBOX-EGRESS-AUTH: <token>` (token injected via env `OPENSANDBOX_EGRESS_TOKEN`).\n- No `OPENSANDBOX_NETWORK_POLICY` env/config injection path is used anymore.\n\n#### Deployment Flow (Kubernetes)\n\n- Pod spec includes two containers: `egress-sidecar` (with `capabilities.add: [NET_ADMIN]`) and the application container (no extra caps).\n- Both containers share the pod network namespace by default; sidecar listens on `127.0.0.1:15353`.\n- Server (inside cluster) waits for sidecar `/healthz` 200 on the Pod IP, then POSTs the sandbox `networkPolicy`\n  to `/policy` with `OPENSANDBOX-EGRESS-AUTH` header. Token comes from `OPENSANDBOX_EGRESS_TOKEN` env on the sidecar.\n- HostNetwork + network_policy is rejected.\n\n#### Behavior When CAP_NET_ADMIN Is Unavailable\n\n- Sidecar logs a warning and disables enforcement; application container still runs unprivileged.\n- No resolv.conf fallback is attempted.\n\n#### Sidecar Network Setup\n\n```go\n// pkg/egress/controller.go\n\nfunc (c *Controller) Start() error {\n    if c.policy == nil || len(c.policy.Egress) == 0 {\n        logs.Info(\"[egress] no network_policy, enforcing default deny-all\")\n    }\n\n    // Start DNS Proxy on non-privileged port (no root needed)\n    c.dnsProxy = NewDNSProxy(c.policy, \"127.0.0.1:15353\")\n    if err := c.dnsProxy.Start(); err != nil {\n        return fmt.Errorf(\"failed to start DNS proxy: %w\", err)\n    }\n\n    // Setup iptables REDIRECT (requires CAP_NET_ADMIN, NOT root)\n    if err := c.setupIptablesRedirect(); err != nil {\n        logs.Warn(\"[egress] iptables setup failed: %v\", err)\n        logs.Warn(\"[egress] network_policy will NOT be enforced\")\n        logs.Warn(\"[egress] ensure sidecar has CAP_NET_ADMIN capability\")\n        return nil  // Continue running sidecar (other functionality still works)\n    }\n\n    logs.Info(\"[egress] network policy active (iptables REDIRECT mode)\")\n    return nil\n}\n\nfunc (c *Controller) setupIptablesRedirect() error {\n    rules := [][]string{\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"udp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", \"15353\"},\n        {\"iptables\", \"-t\", \"nat\", \"-A\", \"OUTPUT\", \"-p\", \"tcp\",\n         \"--dport\", \"53\", \"-j\", \"REDIRECT\", \"--to-port\", \"15353\"},\n    }\n\n    for _, args := range rules {\n        cmd := exec.Command(args[0], args[1:]...)\n        if output, err := cmd.CombinedOutput(); err != nil {\n            return fmt.Errorf(\"%v failed: %w (output: %s)\", args, err, output)\n        }\n    }\n    return nil\n}\n```\n\n#### Why iptables Works Without Root\n\n| Requirement | Traditional (resolv.conf) | Simplified (iptables) |\n|-------------|--------------------------|----------------------|\n| Root user | ✅ Required | ❌ Not required |\n| CAP_NET_ADMIN | Optional | ✅ Required |\n| Modify filesystem | ✅ /etc/resolv.conf | ❌ No |\n| K8s PSS compatible | ❌ restricted prohibits root | ⚠️ baseline allows capabilities |\n\nThe key insight is that `CAP_NET_ADMIN` grants permission to modify network configuration (including iptables rules) **regardless of the user ID**. A non-root user with CAP_NET_ADMIN can successfully run iptables commands.\n\n### Component Changes\n\n#### 1. Server (`server/`)\n\n**`server/src/api/schema.py`**: Add `NetworkPolicy` schema classes.\n\n**`server/src/services/docker.py`** (sidecar pattern):\n- Create an egress sidecar container when `network_policy` is present.\n- Add `CAP_NET_ADMIN` only to the sidecar.\n- Set `OPENSANDBOX_EGRESS_TOKEN` env (random per-sandbox) and optionally `OPENSANDBOX_EGRESS_HTTP_ADDR`.\n- Run the application container with `network_mode: \"container:<sidecar>\"` (shared netns), no extra caps.\n- Wait for sidecar `/healthz` 200, then POST `networkPolicy` to `/policy` with header `OPENSANDBOX-EGRESS-AUTH: <token>`.\n- Reject `--network host` when `network_policy` is set (hostNetwork not supported).\n\n**`server/src/services/k8s/batchsandbox_provider.py`** (Pod pattern):\n- Pod spec includes `egress-sidecar` with `capabilities.add: [NET_ADMIN]` and the application container without extra caps.\n- Sidecar env includes `OPENSANDBOX_EGRESS_TOKEN` (and `OPENSANDBOX_EGRESS_HTTP_ADDR` if non-default); may optionally seed `OPENSANDBOX_EGRESS_RULES`.\n- Server (inside cluster) waits for `/healthz` on the Pod IP, then POSTs `networkPolicy` to `/policy` with header `OPENSANDBOX-EGRESS-AUTH`.\n- Reject `hostNetwork=true` when `network_policy` is set.\n\n#### 2. Sidecar Implementation\n\nNew packages:\n- `pkg/egress/` - Main controller\n- `pkg/egress/dns/` - DNS proxy implementation\n- `pkg/egress/policy/` - Policy parsing and matching\n- `pkg/egress/netfilter/` - nftables/iptables implementation\n\n**Startup integration** (`main.go` or `bootstrap.sh`):\n\n```go\nfunc main() {\n    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)\n    defer cancel()\n\n    initial, _ := dnsproxy.LoadPolicyFromEnvVar(\"OPENSANDBOX_EGRESS_RULES\")\n    proxy, err := dnsproxy.New(initial, \"\") // start default deny-all if nil\n    if err != nil { log.Fatal(err) }\n    go startPolicyServer(ctx, proxy, os.Getenv(\"OPENSANDBOX_EGRESS_HTTP_ADDR\"), os.Getenv(\"OPENSANDBOX_EGRESS_TOKEN\"))\n\n    if err := proxy.Start(ctx); err != nil { log.Fatal(err) }\n    if err := iptables.SetupRedirect(15353); err != nil { log.Fatal(err) }\n\n    <-ctx.Done()\n}\n```\n\n#### 3. SDKs\n\n**Python SDK** (`sdks/sandbox/python/`):\n\n```python\n# opensandbox/models.py\n\nfrom typing import List, Literal\nfrom pydantic import BaseModel\n\nclass EgressRule(BaseModel):\n    action: Literal[\"allow\", \"deny\"]\n    target: str  # FQDN, wildcard, IP, or CIDR (e.g., \"*.pypi.org\", \"10.0.0.0/8\")\n\nclass NetworkPolicy(BaseModel):\n    egress: List[EgressRule]\n    defaultAction: Literal[\"allow\", \"deny\"] = \"deny\"\n    require_full_isolation: bool = False\n```\n\n#### 4. Specs\n\nUpdate `specs/sandbox-lifecycle.yml` with the schema defined in [API Schema](#api-schema).\n\n## Test Plan\n\n### Unit Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| Policy parsing | Valid/invalid policy JSON parsing |\n| Domain matching | Exact match, wildcard match, case insensitivity |\n| Target parsing | FQDN, wildcard, IP, CIDR format detection |\n| DNS response handling | IP extraction and learning |\n\n### Integration Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| DNS proxy blocks denied domain | Query for denied domain returns NXDOMAIN |\n| DNS proxy allows permitted domain | Query for allowed domain returns real IPs |\n| Network filter blocks direct IP | `curl http://<ip>` fails when domain not allowed |\n| Graceful degradation | System works in dns-only mode without CAP_NET_ADMIN |\n| Enforcement mode observable | `/status` API returns correct mode |\n\n### E2E Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| Python SDK with network_policy | Create sandbox with policy, verify curl behavior |\n| Bypass attempt | Verify DoH/direct IP blocked with full isolation |\n| Localhost access | Internal services (Jupyter, sidecar endpoints) still work |\n\n## Drawbacks\n\n1. **Increased Complexity**: Adds and operates a sidecar with multiple enforcement modes.\n2. **Kernel Dependencies**: Full isolation requires nftables support in the kernel.\n3. **DNS-only Limitations**: Security-conscious users must understand the bypass risks.\n4. **Debugging Difficulty**: Network issues become harder to diagnose with filtering enabled.\n\n## Alternatives\n\n### Alternative 1: Sidecar Proxy (Envoy/mitmproxy)\n\n**Approach**: Run a transparent proxy sidecar that intercepts all egress traffic.\n\n**Pros**:\n- L7 visibility (can inspect HTTP headers, TLS SNI)\n- No kernel dependencies\n\n**Cons**:\n- Performance overhead (user-space proxy)\n- TLS interception requires certificate injection\n- Additional container resource usage\n- Complex configuration\n\n**Decision**: Rejected due to performance overhead and complexity for the common case.\n\n### Alternative 2: External NetworkPolicy Controller (K8s only)\n\n**Approach**: Generate Cilium/Calico NetworkPolicy CRDs instead of in-container enforcement.\n\n**Pros**:\n- Leverages existing K8s network policy infrastructure\n- No container modifications needed\n\n**Cons**:\n- Kubernetes-only; doesn't work for Docker runtime\n- Requires Cilium/Calico CNI with FQDN support\n- Less portable\n- Adds external dependencies and complexity\n- Behavior may differ between runtimes\n\n**Decision**: Rejected. The sidecar already provides a unified path across Docker and Kubernetes; adding an external NetworkPolicy controller would reintroduce runtime-specific dependencies.\n\n### Alternative 3: LD_PRELOAD Hook\n\n**Approach**: Inject a shared library that intercepts DNS-related libc calls.\n\n**Pros**:\n- Works without network privileges\n\n**Cons**:\n- Doesn't work with statically-linked binaries (Go, Rust)\n- Fragile across different libc implementations\n- Can be bypassed by direct syscalls\n\n**Decision**: Rejected due to limited compatibility.\n\n## Infrastructure Needed\n\n- **Go Dependencies**:\n  - `github.com/miekg/dns` - DNS server/client library (cross-platform)\n  - `github.com/google/nftables` - nftables Go bindings (Linux only)\n  - `golang.zx2c4.com/wireguard/windows` (future) - WFP bindings (Windows only)\n\n- **Container Requirements**:\n\n  | Requirement | When Needed | Notes |\n  |-------------|-------------|-------|\n  | `CAP_NET_ADMIN` | When `network_policy` specified | Enables iptables REDIRECT without root |\n  | iptables binary | When `network_policy` specified | Usually present in Linux containers |\n  | No filesystem write needed | N/A | iptables REDIRECT doesn't modify resolv.conf |\n\n- **Build Requirements**:\n  - Go 1.21+ with build tag support\n  - Platform-specific files using `//go:build` tags following existing sidecar patterns\n\n## Upgrade & Migration Strategy\n\n### Backward Compatibility\n\n- **Default baseline is deny-all**: egress sidecar enforces deny-all until explicit policy is provided.\n- **Opt-in rules**: Users specify `network_policy` to open destinations (allow or explicit deny rules).\n- **Graceful degradation**: If `CAP_NET_ADMIN` is unavailable, DNS interception may be skipped but default deny remains at the proxy layer.\n\n**Behavior Matrix**:\n\n| Scenario | DNS Proxy | iptables REDIRECT | CAP_NET_ADMIN | Network Filter | External Access |\n|----------|-----------|-------------------|---------------|----------------|-----------------|\n| No `network_policy` | ✅ On (:15353) | ⚠️ Attempted; warn if unavailable | ⚠️ Required for redirect | ⚠️ If capable | 🔒 Deny-all baseline |\n| `network_policy` specified | ✅ On (:15353) | ✅ 53→15353 | ✅ Added | ⚡ If capable | 🔒 Policy-based |\n\n### Migration Path\n\n1. **Phase 1 (MVP)**: DNS Proxy with iptables REDIRECT for DNS interception\n2. **Phase 2**: Add nftables-based network filtering (Layer 2) for full isolation\n\n> **Note**: The same sidecar implementation works for both Docker and Kubernetes runtimes. No runtime-specific code paths are needed.\n\n### Documentation Updates\n\n- Add egress control section to SDK documentation\n- Add security considerations page explaining enforcement modes\n- Add troubleshooting guide for network policy issues\n"
  },
  {
    "path": "oseps/0002-kubernetes-sigs-agent-sandbox-support.md",
    "content": "---\ntitle: kubernetes-sigs/agent-sandbox Support\nauthors:\n  - \"@jwx0925\"\ncreation-date: 2026-01-23\nlast-updated: 2026-01-23\nstatus: implemented\n---\n\n# OSEP-0002: kubernetes-sigs/agent-sandbox Support\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nAdd first-class support for `kubernetes-sigs/agent-sandbox` as a runtime backend\nfor OpenSandbox. This enables a Kubernetes-native sandbox lifecycle while\nkeeping the existing OpenSandbox SDK and API contract, and introduces a\ndedicated ingress path for direct sandbox access.\n\n## Motivation\n\nOpenSandbox already provides a Kubernetes runtime roadmap and an SDK-first\nexperience, but users running `kubernetes-sigs/agent-sandbox` must currently\nintegrate it manually. A native integration unifies lifecycle management,\nobservability, and routing, letting teams adopt OpenSandbox without changing\ntheir existing Kubernetes operational model.\n\n### Goals\n\n- Support creating, querying, and terminating sandboxes backed by\n  `kubernetes-sigs/agent-sandbox` via the OpenSandbox server API.\n- Provide two supported access paths:\n  1) Biz -> OpenSandbox SDK -> OpenSandbox server -> K8s API server ->\n     `agent-sandbox` pod.\n  2) Biz -> OpenSandbox SDK -> OpenSandbox ingress\n     (`components/ingress`) -> `agent-sandbox` pod.\n- Preserve existing API and SDK behavior for non-agent-sandbox runtimes.\n\n### Non-Goals\n\n- Replacing or removing the existing Docker runtime.\n- Implementing a full Kubernetes operator for OpenSandbox itself.\n- Changing the sandbox lifecycle API or SDKs in a breaking way.\n\n## Requirements\n\n- Must use the existing OpenSandbox lifecycle API and SDKs without breaking\n  changes.\n- Must use the Kubernetes API server as the control plane for provisioning.\n- Must support routing to sandbox pods through the existing ingress component.\n- Must keep security posture aligned with current OpenSandbox auth and\n  isolation requirements.\n\n## Proposal\n\nIntroduce an `agent-sandbox` runtime implementation in the OpenSandbox server\nthat provisions sandboxes by creating and managing\n`kubernetes-sigs/agent-sandbox` resources (and their resulting pods) through the\nKubernetes API server. The server remains the source of truth for sandbox\nlifecycle and uses K8s-native status signals for reconciliation.\n\nImplementation approach: extend the server with a new `agent-sandbox`\n`SandboxService` implementation that reuses the existing Kubernetes helper code\nin `server/services/k8s` as much as possible, since both flows submit resources\nto the Kubernetes API server.\n\nFor access, support two primary chains:\n\n1. Lifecycle API path\n   - Biz -> OpenSandbox SDK -> OpenSandbox server -> K8s API server ->\n     `agent-sandbox` pod\n2. Direct ingress path\n   - Biz -> OpenSandbox SDK -> OpenSandbox ingress\n     (`components/ingress`) -> `agent-sandbox` pod\n\nBoth paths should expose the same sandbox endpoints (exec, file operations,\nmetrics) while allowing ingress routing policies to be configured per cluster.\n\n```mermaid\nflowchart LR\n    A[Biz] --> B[OpenSandbox SDK]\n    B --> C[OpenSandbox Server]\n    C --> D[K8s API Server]\n    D --> E[agent-sandbox Controller]\n    E --> F[Sandbox Pod]\n```\n\n```mermaid\nflowchart LR\n    A[Biz] --> B[OpenSandbox SDK]\n    B --> C[OpenSandbox Ingress]\n    C --> D[Sandbox Pod]\n```\n\n### Notes/Constraints/Caveats\n\n- The `agent-sandbox` controller lifecycle and CRD schema are external; the\n  integration must track upstream changes.\n- Sandbox pod images must include `execd` (or use an init/sidecar injection\n  strategy consistent with existing runtimes).\n\n### Risks and Mitigations\n\n- Risk: K8s API latency or controller reconciliation delays cause slower\n  sandbox readiness. Mitigation: asynchronous provisioning with explicit\n  readiness checks and timeouts.\n- Risk: CRD or API changes in `kubernetes-sigs/agent-sandbox` break integration.\n  Mitigation: versioned runtime adapter and compatibility matrix in docs.\n- Risk: ingress routing misconfiguration exposes pods. Mitigation: enforce\n  namespace scoping, label selectors, and explicit port allowlists.\n\n## Design Details\n\n### Runtime Type and Configuration\n- Add a new runtime type in server config, e.g. `runtime.type = agent-sandbox`.\n- New config fields:\n  - `runtime.kubernetes.kubeconfig` (optional; in-cluster supported)\n  - `runtime.kubernetes.namespace`\n  - `runtime.agent_sandbox.template` (CRD spec template or defaults)\n  - `runtime.agent_sandbox.execd_mode` (embedded image vs init/sidecar)\n  - `runtime.agent_sandbox.ingress_enabled` (default true)\n\n### Lifecycle Flow\n1. `POST /sandboxes`:\n   - Validate request and build `agent-sandbox` CR or pod spec.\n   - Create resource via K8s API server.\n   - Persist sandbox record with runtime metadata and labels.\n2. `GET /sandboxes/{id}`:\n   - Read resource status and pod phase.\n   - Map to OpenSandbox lifecycle states.\n3. `DELETE /sandboxes/{id}`:\n   - Delete `agent-sandbox` resource and cleanup related objects.\n\n### Ingress Routing\n- Extend `components/ingress` to recognize `agent-sandbox` pods through labels\n  (e.g., `opensandbox.io/sandbox-id`).\n- Map sandbox IDs and ports to ingress routes following existing router\n  semantics.\n\n### Observability and Metrics\n- Surface pod readiness, node placement, and resource usage in server logs and\n  metrics for troubleshooting.\n\n### Implementation Plan\n- Add a new `agent_sandbox` runtime module and a `SandboxService` implementation\n  in the server layer.\n- Reuse shared Kubernetes client setup, apply/delete helpers, and watch/status\n  utilities from `server/services/k8s` to avoid duplicating API plumbing.\n- Add a runtime adapter that maps OpenSandbox lifecycle states to\n  `agent-sandbox` CRD/pod status, including readiness/termination conditions.\n- Store the created resource name/namespace and labels in the sandbox metadata\n  for reconciliation and cleanup.\n- Extend server configuration to enable `agent-sandbox`, including CRD template\n  or spec defaults and `execd` injection strategy (image vs init/sidecar).\n- Add routing integration in `components/ingress` to discover pods by labels and\n  publish routes for sandbox ports.\n- Provide an example under `examples/` that creates a sandbox, executes a\n  command, and tears it down using the SDK against the `agent-sandbox` runtime.\n\n```mermaid\nsequenceDiagram\n    participant Biz\n    participant SDK as OpenSandbox SDK\n    participant Srv as OpenSandbox Server\n    participant K8s as K8s API Server\n    participant Ctrl as agent-sandbox Controller\n    participant Pod as Sandbox Pod\n\n    Biz->>SDK: create sandbox\n    SDK->>Srv: POST /sandboxes\n    Srv->>K8s: create agent-sandbox resource\n    K8s->>Ctrl: reconcile CRD\n    Ctrl->>Pod: create pod\n    Srv->>K8s: watch status\n    Srv-->>SDK: sandbox ready\n```\n\n## Test Plan\n\n- Unit tests for runtime adapter: spec generation, status mapping, cleanup.\n- Integration tests with a local K8s cluster and `agent-sandbox` installed:\n  create/list/delete sandbox, exec command, file ops, metrics.\n- Ingress tests: ensure routing to the correct sandbox pod and port.\n\n## Drawbacks\n\n- Adds dependency on `agent-sandbox` CRD stability and controller behavior.\n- Operational complexity for teams without existing Kubernetes expertise.\n\n## Alternatives\n\n- Continue with a native OpenSandbox Kubernetes runtime only. Rejected because\n  it does not meet users already standardized on `agent-sandbox`.\n- Provide an external adapter service instead of embedding in the server.\n  Rejected due to added operational components and split observability.\n\n## Infrastructure Needed\n\n- Kubernetes cluster with `kubernetes-sigs/agent-sandbox` installed for CI/E2E.\n- Optional: test images that bundle `execd` for sandbox pods.\n\n## Upgrade & Migration Strategy\n\n- Backwards compatible; default runtime remains unchanged.\n- Enable by configuration; no migration required for existing Docker runtime\n  users.\n"
  },
  {
    "path": "oseps/0003-volume-and-volumebinding-support.md",
    "content": "---\ntitle: Volume Support\nauthors:\n  - \"@hittyt\"\ncreation-date: 2026-01-29\nlast-updated: 2026-02-11\nstatus: implementing\n---\n\n# OSEP-0003: Volume Support\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nIntroduce a runtime-neutral volume model in the Lifecycle API to enable persistent storage mounts across Docker and Kubernetes sandboxes. The proposal adds explicit volume definitions, mount semantics, and security constraints so that artifacts can persist beyond sandbox lifecycles without relying on file transfers.\n\nThis proposal focuses on file persistence via filesystem mounts. It is not a general-purpose storage abstraction (e.g., block or object storage APIs); those are only supported indirectly when exposed as a filesystem by the runtime or host.\n\n```text\nTime --------------------------------------------------------------->\n\nVolume lifecycle:  [provisioned]-------------------------[retained]--->\nSandbox lifecycle:           [create]---[running]---[stop/delete]\n                              |                         |\n                          bind volume              unbind volume\n```\n\n## Motivation\n\nOpenSandbox users running long-lived agents need artifacts (web pages, images, reports) to persist after a sandbox is terminated or restarted. Today, the API only supports transient filesystem operations via upload/download and provides no mount semantics; as a result, users must move large outputs out-of-band. This proposal adds first-class storage semantics while maintaining runtime portability and security boundaries.\n\n### Goals\n\n- Add a volume mount field to the Lifecycle API without breaking existing clients.\n- Support Docker bind mounts (local path), Docker named volumes, and OSS mounts as the initial MVP.\n- Provide a runtime-neutral `pvc` backend that maps to Docker named volumes and Kubernetes PersistentVolumeClaims, enabling portable cross-container data sharing.\n- Provide secure, explicit controls for read/write access and path isolation.\n- Keep runtime-specific details out of the core API where possible.\n\n### Non-Goals\n\n- Full-featured storage orchestration (auto-provisioning, snapshots, backups).\n- Automatic cross-sandbox sharing or locking semantics are out of scope; only explicit volume mounts are supported.\n- Guaranteeing portability for every storage backend in every runtime.\n- Managing backend storage lifecycle (provisioning, resizing, and cleanup) is out of scope; users own and manage underlying storage resources independently.\n\n## Requirements\n\n- Backward compatible with existing sandbox creation requests.\n- Works with both Docker and Kubernetes runtimes.\n- Enforces path safety and explicit read/write permissions.\n- Supports per-sandbox isolation (via subPath or equivalent).\n- Clear error messages when a runtime does not support a requested backend.\n\n## Proposal\n\nAdd a new optional field to the Lifecycle API:\n- `volumes[]`: defines storage mounts for the sandbox. Each entry includes a named backend-specific struct (e.g., `host`, `ossfs`, `pvc`, `nfs`) and common mount settings (`name`, `mountPath`, `readOnly`, `subPath`).\n\nThe core API describes what storage is required using strongly-typed backend definitions. Each backend type has its own dedicated struct with explicit fields, making the schema self-documenting and enabling compile-time validation in typed SDKs. Runtime providers translate the model into platform-specific mounts.\n\n### Notes/Constraints/Caveats\n\n- Sandbox runtime (Docker/Kubernetes) and storage backend (host/ossfs/pvc) are independent dimensions. The API is designed so the same SDK request can target different runtimes; if a runtime cannot support a backend, it must return a clear validation error.\n- OSS/S3/GitFS are popular production backends; this proposal keeps the model extensible so these can be supported early by adding new backend structs.\n- The MVP targets Docker with `host`, `pvc`, and `ossfs` backends, and Kubernetes with `host`, `ossfs`, and `pvc` backends. The `pvc` backend is runtime-neutral: it maps to Docker named volumes in Docker and PersistentVolumeClaims in Kubernetes. Other backends (e.g., `nfs`) are described for future extension and may be unsupported initially.\n- Kubernetes template merging currently replaces lists; this proposal requires list-merge or append behavior for volumes/volumeMounts to preserve user input.\n- Exactly one backend struct must be specified per volume entry; specifying zero or multiple backend structs is a validation error.\n\n### Risks and Mitigations\n\n- Security risk: Docker hostPath mounts can expose host data. Mitigation: enforce allowlist prefixes, forbid path traversal, and use `readOnly: true` for read-only access when appropriate.\n- Portability risk: different backends behave differently. Mitigation: keep core API minimal and require explicit backend selection.\n- Operational risk: storage misconfiguration causes startup failures. Mitigation: validate mounts early and provide clear error responses.\n\n## Design Details\n\n### API schema changes\nAdd to `CreateSandboxRequest`:\n\n```yaml\nvolumes:\n  # Host path mount (read-write by default)\n  - name: workdir\n    host:\n      path: \"/data/opensandbox/user-a\"\n    mountPath: /mnt/work\n    subPath: \"task-001\"\n\n  # OSSFS mount\n  - name: data\n    ossfs:\n      bucket: \"my-bucket\"\n      endpoint: \"oss-cn-hangzhou.aliyuncs.com\"\n      path: \"/sandbox/user-a\"\n      accessKeyId: \"AKIDEXAMPLE\"\n      accessKeySecret: \"SECRETEXAMPLE\"\n      version: \"2.0\"\n    mountPath: /mnt/data\n\n  # PVC mount (platform-managed named volume, read-only)\n  # Kubernetes: maps to PersistentVolumeClaim\n  # Docker: maps to named volume\n  - name: models\n    pvc:\n      claimName: \"shared-models-pvc\"\n    mountPath: /mnt/models\n    readOnly: true\n\n  # NFS mount (future, read-only)\n  - name: shared\n    nfs:\n      server: \"nfs.example.com\"\n      path: \"/exports/sandbox\"\n      options: \"nfsvers=4.1,hard,timeo=600\"\n    mountPath: /mnt/shared\n    readOnly: true\n```\n\n### Core semantics\n- `volumes[]` declares storage mounts. Each volume entry contains:\n  - `name`: unique identifier for the volume within the sandbox.\n  - Exactly one backend struct (`host`, `ossfs`, `pvc`, `nfs`, etc.) with backend-specific typed fields.\n  - `mountPath`: absolute path inside the container where the volume is mounted.\n  - `readOnly` (optional): if true, the volume is mounted as read-only. Defaults to false (read-write).\n  - `subPath` (optional): subdirectory under the backend path to mount.\n\n### Backend struct definitions\nEach backend type is defined as a distinct struct with explicit typed fields:\n\n**`host`** - Host path bind mount:\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `path` | string | Yes | Absolute path on the host filesystem |\n\n**`ossfs`** - Alibaba Cloud OSS mount via ossfs:\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `bucket` | string | Yes | OSS bucket name |\n| `endpoint` | string | Yes | OSS endpoint URL (e.g., `oss-cn-hangzhou.aliyuncs.com`) |\n| `accessKeyId` | string | Yes | Access key ID for inline authentication |\n| `accessKeySecret` | string | Yes | Access key secret for inline authentication |\n| `version` | string | No | ossfs version: `1.0` or `2.0` (default: `2.0`) |\n| `options` | []string | No | Mount options list (e.g., `[\"allow_other\", \"umask=0022\"]`) |\n\n**`pvc`** - Platform-managed named volume:\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `claimName` | string | Yes | Name of the volume on the target platform (PVC name in Kubernetes, Docker volume name in Docker) |\n\nThe `pvc` backend is a runtime-neutral abstraction for referencing a pre-existing, platform-managed named volume. The semantics are identical across runtimes: claim an existing volume by name, mount it into the container, and leave volume lifecycle management to the user. In Kubernetes this maps to a PersistentVolumeClaim; in Docker this maps to a named volume (created via `docker volume create`).\n\n**`nfs`** - NFS mount (future):\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `server` | string | Yes | NFS server hostname or IP |\n| `path` | string | Yes | Absolute export path on the NFS server |\n| `options` | string | No | Comma-separated mount options (e.g., `nfsvers=4.1,hard,timeo=600`) |\n\nAdditional backends (e.g., `s3`) can be added by defining new structs following this pattern.\n\n### Backend constraints\nValidation rules for each backend struct to reduce runtime-only failures:\n\n- **`host`**: `path` must be an absolute path (e.g., `/data/opensandbox/user-a`). Reject relative paths and require normalization before validation.\n- **`ossfs`**: `bucket` must be a valid bucket name. `endpoint` must be a valid OSS endpoint. `accessKeyId` and `accessKeySecret` are required for current MVP. `version` must be `1.0` or `2.0`; if omitted, defaults to `2.0`. In OSSFS backend, `subPath` represents bucket prefix. The runtime performs the mount during sandbox creation.\n- **`pvc`**: `claimName` must be a valid resource name (DNS label: lowercase alphanumeric and hyphens, max 63 characters). The volume identified by `claimName` must already exist on the target platform; the runtime validates existence before container creation. In Kubernetes, the PVC must exist in the same namespace as the sandbox pod. In Docker, a named volume with the given name must exist (created via `docker volume create`); if the volume does not exist, the request fails validation rather than auto-creating it, to maintain explicit volume lifecycle management.\n- **`nfs`**: `server` must be a valid hostname or IP. `path` must be an absolute path (e.g., `/exports/sandbox`).\n\nThese constraints are enforced in request validation and surfaced as clear API errors; runtimes may apply stricter checks.\n\n### Permissions and ownership\nVolume permissions are a frequent source of runtime failures and must be explicit in the contract:\n- Default behavior: OpenSandbox does not automatically fix ownership or permissions on mounted storage. Users are responsible for ensuring the backend target is writable by the sandbox process UID/GID.\n- Docker `host`: host path permissions are enforced by the host filesystem. Even with `readOnly: false`, writes will fail if the host path is not writable by the container user.\n- Docker `pvc` (named volume): Docker named volumes created with the default `local` driver are owned by root. If the container runs as a non-root user, write access depends on the volume's filesystem permissions. Users should ensure correct ownership when creating the volume or use an init process to fix permissions.\n- Kubernetes: filesystem permissions vary by storage driver. Future enhancement: add optional `fsGroup` field to backend structs that support it for pod-level volume access control.\n\n### Concurrency and isolation\nSubPath provides path-level isolation, not concurrency control. If multiple sandboxes mount the same volume without distinct `subPath` values and use `readOnly: false`, they may overwrite each other. OpenSandbox does not provide file-locking or coordination; users are responsible for handling concurrent access safely.\n\n### Docker mapping\n- `host` backend maps to bind mounts. `host.path + subPath` resolves to a concrete host directory.\n- The host config uses `mounts`/`binds` with `ReadOnly` set from `readOnly` field.\n- If the resolved host path does not exist, the request fails validation (do not auto-create host directories in MVP to avoid permission and security pitfalls).\n- Allowed host paths are restricted by a server-side allowlist; users must specify a `host.path` under permitted prefixes. The allowlist is an operator-configured policy and should be documented for users of a given deployment.\n- `pvc` backend maps to Docker named volumes. `pvc.claimName` is used as the Docker volume name in the bind string (e.g., `my-volume:/mnt/data:rw`). Docker recognizes non-absolute-path sources as named volume references. The named volume must already exist (created via `docker volume create`); if it does not exist, the request fails validation. When `subPath` is specified, the runtime resolves the volume's host-side `Mountpoint` via `docker volume inspect` and appends the `subPath` to produce a standard bind mount (e.g., `/var/lib/docker/volumes/my-volume/_data/subdir:/mnt/data:rw`). This requires the volume to use the `local` driver; non-local drivers are rejected when `subPath` is present because their `Mountpoint` may not be a real filesystem path. The resolved path must exist on the host; if it does not, the request fails validation.\n- `ossfs` backend requires the runtime to mount OSS via ossfs during sandbox creation. Current MVP uses inline credentials (`accessKeyId`/`accessKeySecret`). In OSSFS backend, `subPath` is treated as bucket prefix and is resolved/validated on host before bind-mounting into the container. If the runtime does not support ossfs mounting, the request is rejected.\n\n### Kubernetes mapping\n- `pvc` backend maps to Kubernetes `persistentVolumeClaim` volume source: `pvc.claimName` → `volumes[].persistentVolumeClaim.claimName`.\n- `nfs` backend maps to Kubernetes `nfs` volume source: `nfs.server` → `volumes[].nfs.server`, `nfs.path` → `volumes[].nfs.path`.\n- `mountPath` maps to `volumeMounts.mountPath`.\n- `subPath` maps to `volumeMounts.subPath`.\n- `ossfs` backend maps to OSS CSI driver or equivalent runtime-specific mount configured with the struct fields.\n- `host` backend maps to `hostPath` volume source and is node-local. For persistence guarantees in multi-node clusters, users must pin scheduling (node affinity) or use LocalPersistentVolume; otherwise data can disappear if the pod is rescheduled.\n\n### Example: Host path mount\nCreate a sandbox that mounts a host directory:\n\n```yaml\nvolumes:\n  - name: workdir\n    host:\n      path: \"/data/opensandbox/user-a\"\n    mountPath: /mnt/work\n    subPath: \"task-001\"\n```\n\nPython SDK example (host):\n\n```python\nfrom opensandbox.api.lifecycle.client import AuthenticatedClient\nfrom opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\nfrom opensandbox.api.lifecycle.models.create_sandbox_request import CreateSandboxRequest\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec\nfrom opensandbox.api.lifecycle.models.resource_limits import ResourceLimits\nfrom opensandbox.api.lifecycle.models.volume import Volume\nfrom opensandbox.api.lifecycle.models.host import Host\n\nclient = AuthenticatedClient(base_url=\"https://api.opensandbox.io\", token=\"YOUR_API_KEY\")\n\nresource_limits = ResourceLimits.from_dict({\"cpu\": \"500m\", \"memory\": \"512Mi\"})\nrequest = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    timeout=3600,\n    resource_limits=resource_limits,\n    entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n    volumes=[\n        Volume(\n            name=\"workdir\",\n            host=Host(\n                path=\"/data/opensandbox/user-a\",\n            ),\n            mount_path=\"/mnt/work\",\n            sub_path=\"task-001\",\n        )\n    ],\n)\n\npost_sandboxes.sync(client=client, body=request)\n```\n\n### Example: OSSFS mount\nCreate a sandbox that mounts an OSS bucket via ossfs:\n\n```yaml\nvolumes:\n  - name: workdir\n    ossfs:\n      bucket: \"my-bucket\"\n      endpoint: \"oss-cn-hangzhou.aliyuncs.com\"\n      path: \"/sandbox/user-a\"\n      accessKeyId: \"AKIDEXAMPLE\"\n      accessKeySecret: \"SECRETEXAMPLE\"\n      version: \"2.0\"\n      options:\n        - \"allow_other\"\n        - \"umask=0022\"\n    mountPath: /mnt/work\n    subPath: \"task-001\"\n```\n\nRuntime mapping (Docker):\n- host path: runtime resolves target path under configured mount root (e.g., `/mnt/ossfs/<bucket>/<path>`), performs on-demand mount (or reuses existing mount), then bind-mounts into the container\n- container path: `/mnt/work`\n- readOnly: false (default, read-write)\n\n### Example: Python SDK (lifecycle client)\nUse the Python SDK lifecycle client to create a sandbox with an OSSFS volume mount (future typed model):\n\n```python\nfrom opensandbox.api.lifecycle.client import AuthenticatedClient\nfrom opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\nfrom opensandbox.api.lifecycle.models.create_sandbox_request import CreateSandboxRequest\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec\nfrom opensandbox.api.lifecycle.models.resource_limits import ResourceLimits\nfrom opensandbox.api.lifecycle.models.volume import Volume\nfrom opensandbox.api.lifecycle.models.ossfs import OSSFS\n\nclient = AuthenticatedClient(base_url=\"https://api.opensandbox.io\", token=\"YOUR_API_KEY\")\n\nresource_limits = ResourceLimits.from_dict({\"cpu\": \"500m\", \"memory\": \"512Mi\"})\nrequest = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    timeout=3600,\n    resource_limits=resource_limits,\n    entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n    volumes=[\n        Volume(\n            name=\"workdir\",\n            ossfs=OSSFS(\n                bucket=\"my-bucket\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                path=\"/sandbox/user-a\",\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n                version=\"2.0\",\n                options=[\"allow_other\", \"umask=0022\"],\n            ),\n            mount_path=\"/mnt/work\",\n            sub_path=\"task-001\",\n        )\n    ],\n)\n\npost_sandboxes.sync(client=client, body=request)\n```\n\n### Example: PVC mount (cross-runtime)\nThe `pvc` backend provides a portable way to reference platform-managed named volumes. The same API request works on both Docker and Kubernetes:\n\n```yaml\nvolumes:\n  - name: shared-data\n    pvc:\n      claimName: \"my-shared-volume\"\n    mountPath: /mnt/data\n    subPath: \"task-001\"\n```\n\nRuntime mapping (Docker):\nThe `claimName` is used as the Docker named volume name. The volume must already exist (created via `docker volume create my-shared-volume`). When `subPath` is specified, the runtime resolves the volume's host-side `Mountpoint` via `docker volume inspect` and appends the subPath to produce a standard bind mount:\n```text\n# Docker bind string generated by the runtime (with subPath):\n# Mountpoint = /var/lib/docker/volumes/my-shared-volume/_data\n/var/lib/docker/volumes/my-shared-volume/_data/task-001:/mnt/data:rw\n\n# Without subPath, the named volume is used directly:\n# my-shared-volume:/mnt/data:rw\n```\n\nRuntime mapping (Kubernetes):\nThe `claimName` maps to a PersistentVolumeClaim in the same namespace.\n```yaml\nvolumes:\n  - name: shared-data\n    persistentVolumeClaim:\n      claimName: my-shared-volume\ncontainers:\n  - name: sandbox\n    volumeMounts:\n      - name: shared-data\n        mountPath: /mnt/data\n        subPath: task-001\n```\n\nPython SDK example (PVC):\n\n```python\nfrom opensandbox.api.lifecycle.client import AuthenticatedClient\nfrom opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\nfrom opensandbox.api.lifecycle.models.create_sandbox_request import CreateSandboxRequest\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec\nfrom opensandbox.api.lifecycle.models.resource_limits import ResourceLimits\nfrom opensandbox.api.lifecycle.models.volume import Volume\nfrom opensandbox.api.lifecycle.models.pvc import PVC\n\nclient = AuthenticatedClient(base_url=\"https://api.opensandbox.io\", token=\"YOUR_API_KEY\")\n\nresource_limits = ResourceLimits.from_dict({\"cpu\": \"500m\", \"memory\": \"512Mi\"})\nrequest = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    timeout=3600,\n    resource_limits=resource_limits,\n    entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n    volumes=[\n        Volume(\n            name=\"shared-data\",\n            pvc=PVC(\n                claim_name=\"my-shared-volume\",\n            ),\n            mount_path=\"/mnt/data\",\n            sub_path=\"task-001\",\n        )\n    ],\n)\n\npost_sandboxes.sync(client=client, body=request)\n```\n\n#### Cross-container data sharing with PVC (Docker)\nMultiple sandboxes can share data through the same named volume. This is more convenient and secure than using host paths, as Docker manages the storage location and no host paths need to be exposed:\n\n```python\n# Sandbox A: writes data to the shared volume\nsandbox_a = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    entrypoint=[\"python\", \"-c\", \"open('/mnt/shared/result.txt','w').write('hello')\"],\n    volumes=[\n        Volume(name=\"shared\", pvc=PVC(claim_name=\"team-data\"), mount_path=\"/mnt/shared\")\n    ],\n)\n\n# Sandbox B: reads data from the same shared volume\nsandbox_b = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    entrypoint=[\"python\", \"-c\", \"print(open('/mnt/shared/result.txt').read())\"],\n    volumes=[\n        Volume(name=\"shared\", pvc=PVC(claim_name=\"team-data\"), mount_path=\"/mnt/shared\")\n    ],\n)\n```\n\n### Example: Kubernetes NFS (future)\nCreate a sandbox that mounts an NFS export with subPath isolation (non-MVP):\n\n```yaml\nvolumes:\n  - name: workdir\n    nfs:\n      server: \"nfs.example.com\"\n      path: \"/exports/sandbox\"\n      options: \"nfsvers=4.1,hard,timeo=600\"\n    mountPath: /mnt/work\n    subPath: \"task-001\"\n```\n\nRuntime mapping (Kubernetes):\n```yaml\nvolumes:\n  - name: workdir\n    nfs:\n      server: nfs.example.com\n      path: /exports/sandbox\ncontainers:\n  - name: sandbox\n    volumeMounts:\n      - name: workdir\n        mountPath: /mnt/work\n        readOnly: false\n        subPath: task-001\n```\n\nPython SDK example (NFS, future):\n\n```python\nfrom opensandbox.api.lifecycle.client import AuthenticatedClient\nfrom opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\nfrom opensandbox.api.lifecycle.models.create_sandbox_request import CreateSandboxRequest\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec\nfrom opensandbox.api.lifecycle.models.resource_limits import ResourceLimits\nfrom opensandbox.api.lifecycle.models.volume import Volume\nfrom opensandbox.api.lifecycle.models.nfs import NFS\n\nclient = AuthenticatedClient(base_url=\"https://api.opensandbox.io\", token=\"YOUR_API_KEY\")\n\nresource_limits = ResourceLimits.from_dict({\"cpu\": \"500m\", \"memory\": \"512Mi\"})\nrequest = CreateSandboxRequest(\n    image=ImageSpec(uri=\"python:3.11\"),\n    timeout=3600,\n    resource_limits=resource_limits,\n    entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n    volumes=[\n        Volume(\n            name=\"workdir\",\n            nfs=NFS(\n                server=\"nfs.example.com\",\n                path=\"/exports/sandbox\",\n                options=\"nfsvers=4.1,hard,timeo=600\",\n            ),\n            mount_path=\"/mnt/work\",\n            sub_path=\"task-001\",\n        )\n    ],\n)\n\npost_sandboxes.sync(client=client, body=request)\n```\n\n### Provider validation\n- Reject unsupported backend types per runtime (e.g., `nfs` is only valid in Kubernetes).\n- Validate that exactly one backend struct is specified per volume entry.\n- Normalize and validate `subPath` against traversal; reject `..` and absolute path inputs.\n- Enforce allowlist prefixes for `host.path` in Docker.\n- For `ossfs` backend, validate required fields (`bucket`, `endpoint`, `accessKeyId`, `accessKeySecret`).\n- For `pvc` backend, validate `claimName` is a valid DNS label (lowercase alphanumeric and hyphens, max 63 characters). In Kubernetes, validate the PVC exists in the same namespace. In Docker, validate the named volume exists via the Docker API (`docker volume inspect`).\n- For `nfs` backend, validate required fields (`server`, `path`).\n- `subPath` is created if missing under the resolved backend path; if creation fails due to permissions or policy, the request is rejected.\n\n### Configuration (example)\nHost path allowlists are configured by the control plane (server/execd) and enforced at validation time. Example `config.toml`:\n\n```toml\n[storage]\nallow_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\nossfs_mount_root = \"/mnt/ossfs\"\n```\n\n## Test Plan\n\n- Unit tests for schema validation and path normalization.\n- Unit tests for backend struct validation:\n  - Reject volume entries with zero or multiple backend structs.\n  - Validate required fields per backend type.\n- Provider unit tests:\n  - Docker `host`: bind mount generation, read-only enforcement, allowlist rejection.\n  - Docker `pvc`: named volume bind generation, volume existence validation, read-only enforcement, `claimName` format validation, rejection when volume does not exist, `subPath` resolution via `Mountpoint` for `local` driver, rejection of `subPath` for non-local drivers, rejection when resolved subPath does not exist.\n  - Docker `ossfs`: mount option validation, inline credential validation (`accessKeyId`/`accessKeySecret`), version validation (`1.0`/`2.0`), `subPath`-as-prefix resolution, mount failure handling.\n  - Kubernetes `pvc`: PVC reference validation, volume mount generation.\n- Integration tests:\n  - Docker: sandbox creation with `host` volume, sandbox creation with `pvc` (named volume), `pvc` with `subPath` mount, cross-container data sharing via named volume.\n  - Kubernetes: sandbox creation with `pvc`, sandbox creation with `host` volume.\n- Negative tests for unsupported backends and invalid paths.\n\n## Drawbacks\n\n- Adds API surface area and increases runtime provider complexity.\n- Docker bind mounts introduce security considerations and operational policy requirements.\n\n## Alternatives\n\n- Keep using file upload/download only: simpler but does not satisfy persistence requirements.\n- Use runtime-specific `extensions` only: faster to ship but fractures API consistency and increases client complexity.\n\n## Infrastructure Needed\n\nThe runtime must have the ability to perform filesystem mounts for the requested backend types. For `ossfs` backend, the runtime must have ossfs 1.0 or 2.0 installed; the MVP assumes the runtime can mount using the struct fields provided in the request.\n\n## Upgrade & Migration Strategy\n\nThis change is additive for volume support and supports OSSFS inline credentials (`accessKeyId`/`accessKeySecret`). If a client submits volume fields to a runtime that does not support them, the API will return a clear validation error.\n\n## Kubernetes Feasibility (Design Only)\n\nKubernetes runtime is not implemented in this phase, but API compatibility is preserved by design:\n\n- Keep request schema runtime-neutral: `volumes[].ossfs` has consistent shape across Docker and Kubernetes.\n- Introduce runtime adapters:\n  - Docker adapter performs host-side ossfs mount + bind using inline credentials.\n  - Kubernetes adapter can map OSSFS fields to native Secret/CSI references in a future phase.\n- Keep failure semantics aligned:\n  - Missing credential reference -> validation error with shared error code family.\n  - Runtime unsupported backend -> explicit `UNSUPPORTED_VOLUME_BACKEND`.\n- Keep `subPath` semantics aligned:\n  - API meaning remains \"`subPath` is mounted under backend path\".\n  - Docker resolves to host path (`subPath` as OSS prefix); Kubernetes maps to `volumeMounts.subPath`.\n"
  },
  {
    "path": "oseps/0004-secure-container-runtime.md",
    "content": "---\ntitle: Pluggable Secure Container Runtime Support\nauthors:\n  - \"@hittyt\"\ncreation-date: 2026-02-05\nlast-updated: 2026-02-09\nstatus: implementing\n---\n\n# OSEP-0004: Pluggable Secure Container Runtime Support\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [API and SDK Impact](#api-and-sdk-impact)\n  - [Server Configuration](#server-configuration)\n  - [Infrastructure Prerequisites](#infrastructure-prerequisites)\n  - [Runtime Resolver](#runtime-resolver)\n  - [Startup Validation](#startup-validation)\n  - [Docker Mode Implementation](#docker-mode-implementation)\n  - [Kubernetes Mode Implementation](#kubernetes-mode-implementation)\n    - [BatchSandboxProvider](#batchsandboxprovider)\n    - [AgentSandboxProvider](#agentsandboxprovider)\n    - [Pooled Sandbox Consistency](#pooled-sandbox-consistency)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nThis proposal introduces secure container runtime support for OpenSandbox, enabling sandboxes to run in secure container runtimes such as gVisor, Firecracker, and Kata Containers. This provides hardware-level isolation for executing untrusted AI-generated code, protecting the host system from potential malicious behavior.\n\nThe secure runtime is configured at the **server level**: administrators choose a single secure runtime in the server configuration, and all sandboxes on that server transparently use it. SDK users and API callers require no code changes — the isolation upgrade is entirely an infrastructure-level decision.\n\n## Motivation\n\nOpenSandbox is designed to execute untrusted code generated by AI models (such as Claude, GPT-4, Gemini). While standard container isolation (runc) provides process-level isolation, it may not be sufficient for scenarios where:\n\n1. **Untrusted Code Execution**: AI-generated code could potentially contain malicious behavior, including container escape attempts\n2. **Multi-tenant Environments**: Different users' sandboxes may require stronger isolation guarantees\n3. **Compliance Requirements**: Some industries require hardware-level virtualization for security compliance\n\nSecure container runtimes like gVisor, Firecracker, and Kata Containers provide additional isolation layers:\n\n| Runtime | Isolation Mechanism | Use Case |\n|---------|-------------------|----------|\n| gVisor | User-space kernel (syscall interception) | General workloads, low overhead |\n| Kata Containers (QEMU) | Full VM with QEMU hypervisor | Maximum isolation, compatibility |\n| Kata Containers (Firecracker) | MicroVM with Firecracker hypervisor | High density, minimal footprint |\n| Kata Containers (CLH) | Cloud Hypervisor | Balanced performance and isolation |\n\n### Goals\n\n1. **Server-Level Configuration**: Secure runtime is configured once at the server level; all sandboxes use the same runtime\n2. **Transparent to SDK Users**: No SDK or API changes required — upgrading isolation is purely an infrastructure decision\n3. **Dual-Mode Compatibility**: Work seamlessly in both Local Docker and Kubernetes deployment modes\n4. **Graceful Fallback**: Default to standard runc when no secure runtime is configured\n5. **Validation**: Verify runtime availability at server startup and before sandbox creation, with clear error messages\n\n### Non-Goals\n\n1. **Runtime Installation**: OpenSandbox will not install or configure secure container runtimes; this is the responsibility of infrastructure administrators\n2. **Per-Request Runtime Selection**: SDK users cannot choose or override the secure runtime on a per-sandbox basis; this is an infrastructure-level decision managed by administrators\n3. **Runtime-Specific Features**: Exposing all features of each secure runtime (e.g., gVisor platforms, Kata hypervisors) is out of scope for the initial implementation\n4. **Performance Optimization**: Tuning secure runtimes for optimal performance is left to operators\n5. **Multiple Runtimes on One Server**: A single server instance supports exactly one secure runtime; mixed runtimes require separate server deployments\n\n## Requirements\n\n| ID | Requirement | Priority |\n|----|-------------|----------|\n| R1 | Server configuration defines the secure runtime for all sandboxes | Must Have |\n| R2 | Support gVisor, Kata (including Firecracker backend) as runtime types | Must Have |\n| R3 | Validate runtime availability at server startup | Must Have |\n| R4 | Work in both Docker and Kubernetes modes | Must Have |\n| R5 | Default to runc when no secure runtime is configured | Must Have |\n| R6 | Clear error messages when configured runtime is unavailable | Should Have |\n| R7 | No SDK or API changes required for existing users | Should Have |\n\n## Proposal\n\nWe propose adding a `[secure_runtime]` section to the server configuration file (`~/.sandbox.toml`). When configured, **all sandboxes** on that server transparently run in the specified secure runtime. No changes to the Sandbox Lifecycle API or SDKs are required.\n\n```\nServer Config                              Backend\n┌──────────────────────┐                ┌─────────────────┐\n│ [secure_runtime]     │                │ Docker:         │\n│ type = \"gvisor\"      │     ┌────→     │   --runtime=    │\n│ docker_runtime       │     │          │     runsc       │\n│   = \"runsc\"          │─────┤          ├─────────────────┤\n│ k8s_runtime_class    │     │          │ Kubernetes:     │\n│   = \"gvisor\"         │     └────→     │   runtimeClass- │\n│                      │                │     Name: gvisor│\n└──────────────────────┘                └─────────────────┘\n         ▲\n         │ Infrastructure admin configures once\n         │ SDK users require NO code changes\n```\n\n### Notes/Constraints/Caveats\n\n1. **Infrastructure Dependency**: Secure runtimes must be pre-installed and configured on the host (Docker) or cluster (Kubernetes) before use\n\n2. **Performance Overhead**: Secure runtimes add latency and resource overhead compared to runc:\n\n     | Runtime | Isolation Mechanism | Startup Overhead | Memory Overhead | Best For |\n     |---------|---------------------|------------------|-----------------|----------|\n     | **runc** (default) | Process-level cgroups | ~0ms | Minimal | Trusted workloads, local development |\n     | **gVisor** | User-space kernel (syscall interception) | ~10-50ms | ~50MB | General workloads with low overhead |\n     | **Kata (QEMU)** | Full VM with QEMU hypervisor | ~500ms | ~20-50MB | Maximum compatibility and isolation |\n     | **Kata (Firecracker)** | MicroVM with Firecracker hypervisor | ~125ms | ~5MB | High density, minimal footprint |\n     | **Kata (CLH)** | Cloud Hypervisor | ~200ms | ~10-20MB | Balanced performance and isolation |\n\n     Warm start performance (from pre-warmed Pool):\n\n     | Runtime | Cold Start | Warm Start (from Pool) | Memory per Sandbox |\n     |---------|-----------|------------------------|-------------------|\n     | runc | ~500ms | ~50ms | ~5MB |\n     | gVisor | ~550ms | ~100ms | ~50MB |\n     | Kata (QEMU) | ~1000ms | ~200ms | ~20-50MB |\n     | Kata (Firecracker) | ~625ms | ~125ms | ~5MB |\n\n     The actual hypervisor is determined by the `RuntimeClass` handler configured by the SRE administrator (e.g., `kata-qemu`, `kata-clh`, `kata-fc`).\n\n     > **Note**: Firecracker is not a standalone OCI runtime. In this OSEP, `secure_runtime=\"firecracker\"` maps to Kata Containers with the Firecracker hypervisor backend (`kata-fc`). See [Server Configuration](#server-configuration) for details.\n\n3. **Compatibility**: Not all container images work with all secure runtimes:\n   - gVisor: Some syscalls may not be implemented; check [gVisor compatibility](https://gvisor.dev/docs/user_guide/compatibility/)\n   - Kata (QEMU/CLH): Generally most compatible but highest overhead\n   - Kata + Firecracker (`kata-fc`): Limited device support; some workloads requiring specific kernel features may not work\n\n4. **execd Injection**: The execd binary injection mechanism must work within secure runtime constraints\n\n5. **Pooled Sandbox Consistency (Kubernetes)**: In Kubernetes mode with resource pools (Pool CRD), the Pool's `runtimeClassName` must match the server's `[secure_runtime]` configuration. Since both are managed by the same SRE administrator, this is an operational requirement validated at server startup.\n\n### Risks and Mitigations\n\n| Risk | Impact | Mitigation |\n|------|--------|------------|\n| Runtime unavailable at creation time | Sandbox creation fails | Pre-validation with clear error messages |\n| Syscall compatibility issues | Application may not work | Document known limitations per runtime |\n| Performance degradation | Slower sandbox creation | Allow users to choose based on security/performance tradeoff |\n| Configuration complexity | Operational burden | Provide sensible defaults and clear documentation |\n\n## Design Details\n\n> **Note**: Code snippets in this section are illustrative and demonstrate the design intent. Actual implementation may differ in structure and details.\n\n### API and SDK Impact\n\n**No changes to the Sandbox Lifecycle API or SDKs are required.**\n\nThe `CreateSandboxRequest` schema remains unchanged. The secure runtime is applied transparently by the server based on its configuration. Existing SDK code works as-is:\n\n```python\n# This code works identically whether the server uses runc or gVisor.\n# The SDK user does not need to know or care about the secure runtime.\nsandbox = await Sandbox.create(\n    image=\"python:3.11\",\n    entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n)\n```\n\nThis is a key advantage of server-level configuration: upgrading from runc to gVisor is a pure infrastructure change that requires zero application code modifications.\n\n### Server Configuration\n\nExtension to `~/.sandbox.toml`. A single `[secure_runtime]` section configures the secure runtime for **all sandboxes** on this server:\n\n```toml\n[runtime]\ntype = \"docker\"  # or \"kubernetes\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n# Secure container runtime configuration.\n# When enabled, ALL sandboxes on this server use the specified runtime.\n# Comment out or leave type empty to use standard runc.\n[secure_runtime]\n# Runtime type identifier. Supported values:\n#   \"gvisor\"      - gVisor (runsc), user-space kernel isolation\n#   \"kata\"        - Kata Containers (QEMU backend), VM-level isolation\n#   \"firecracker\" - Kata Containers with Firecracker backend (K8s only)\n#   \"\"            - Standard runc (default, no secure runtime)\ntype = \"\"\n\n# Docker mode: --runtime parameter name\n# Ignored when runtime.type = \"kubernetes\"\ndocker_runtime = \"runsc\"\n\n# Kubernetes mode: pod.spec.runtimeClassName value\n# Ignored when runtime.type = \"docker\"\nk8s_runtime_class = \"gvisor\"\n```\n\n**Configuration examples** (pick ONE per server, these are separate config files):\n\nExample 1 — gVisor on Docker:\n\n```toml\n# ~/.sandbox.toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[secure_runtime]\ntype = \"gvisor\"\ndocker_runtime = \"runsc\"\nk8s_runtime_class = \"gvisor\"\n```\n\nExample 2 — Kata Containers (QEMU) on Kubernetes:\n\n```toml\n# ~/.sandbox.toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[secure_runtime]\ntype = \"kata\"\ndocker_runtime = \"kata-runtime\"\nk8s_runtime_class = \"kata-qemu\"\n```\n\nExample 3 — Kata + Firecracker on Kubernetes:\n\n> Firecracker is a VMM, not an OCI runtime. It cannot serve as a CRI implementation directly. This OSEP recommends using Firecracker via Kata Containers (`kata-fc` handler), which is the mature, production-ready approach. The alternative (`firecracker-containerd`) is less actively maintained and not recommended.\n\n```toml\n# ~/.sandbox.toml\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:latest\"\n\n[secure_runtime]\ntype = \"firecracker\"\ndocker_runtime = \"\"              # Not supported in Docker mode\nk8s_runtime_class = \"kata-fc\"\n```\n\n### Infrastructure Prerequisites\n\nOpenSandbox does not install secure runtimes. The following must be configured by infrastructure administrators.\n\n#### Docker Mode - gVisor Setup\n\n**Step 1: Install gVisor runsc**\n\nFor Docker mode, you only need to install the **runsc** OCI runtime:\n\n```bash\n# Ubuntu/Debian\ncurl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg\necho \"deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main\" | \\\n  sudo tee /etc/apt/sources.list.d/gvisor.list\nsudo apt-get update && sudo apt-get install -y runsc\n\n# Verify installation\nrunsc --version\n```\n\n> **Note**: For Docker mode, only `runsc` is required. The `containerd-shim-runsc-v1` is only needed for Kubernetes/containerd.\n\n**Step 2: Configure Docker daemon**\n\nUse the `runsc install` command to automatically configure Docker daemon:\n\n```bash\nsudo runsc install\n```\n\nOr manually edit `/etc/docker/daemon.json`:\n\n```json\n{\n  \"runtimes\": {\n    \"runsc\": {\n      \"path\": \"/usr/bin/runsc\",\n      \"runtimeArgs\": [\n        \"--platform=systrap\",\n        \"--network=host\"\n      ]\n    }\n  }\n}\n```\n\n```bash\nsudo systemctl restart docker\n```\n\n**Step 3: Verify installation**\n\n```bash\ndocker run --runtime=runsc hello-world\n```\n\n#### Docker Mode - Kata Containers Setup\n\n##### System Requirements\n\nKata Containers requires hardware virtualization support. Verify your system meets the following requirements:\n\n**Hardware Virtualization Support:**\n```bash\n# Check if CPU supports hardware virtualization (VT-x for Intel, AMD-V for AMD)\nlscpu | grep Virtualization\n# Expected output: Virtualization: VT-x (Intel) or AMD-V (AMD)\n\n# Alternatively on Intel\ngrep -E --color=auto 'vmx|svm' /proc/cpuinfo\n# Expected: vmx (Intel) or svm (AMD) flags present\n```\n\n**KVM Module:**\n```bash\n# Check if KVM module is loaded\nlsmod | grep kvm\n# Expected: kvm_intel (Intel) or kvm_amd (AMD)\n\n# If not loaded, load KVM module\nsudo modprobe kvm_intel  # For Intel\n# or\nsudo modprobe kvm_amd    # For AMD\n```\n\n**Kernel Requirements:**\n- Linux kernel 5.10 or later recommended\n- KVM enabled in kernel config\n\n**Docker Requirements:**\n- Docker 20.10 or later\n- `/etc/docker/daemon.json` configured for Kata runtime\n\n##### Installation\n\nDownload and install Kata Containers static binaries from GitHub releases:\n\n```bash\n# Find the latest release at https://github.com/kata-containers/kata-containers/releases\nKATA_VERSION=\"3.27.0\"\nwget https://github.com/kata-containers/kata-containers/releases/download/${KATA_VERSION}/kata-static-${KATA_VERSION}-amd64.tar.zst\n\n# Extract to root directory - Kata will be installed in /opt/kata\nzstd -d kata-static-${KATA_VERSION}-amd64.tar.zst\ntar -xvf kata-static-${KATA_VERSION}-amd64.tar -C /\n\n# Create symbolic links for PATH access\nsudo ln -sf /opt/kata/bin/kata-runtime /usr/local/bin/kata-runtime\nsudo ln -sf /opt/kata/bin/containerd-shim-kata-v2 /usr/local/bin/containerd-shim-kata-v2\n\n# Verify installation\nkata-runtime --version\n```\n\n##### Configure Docker Daemon\n\nEdit `/etc/docker/daemon.json` to register Kata as a runtime:\n\n```json\n{\n  \"default-runtime\": \"runc\",\n  \"runtimes\": {\n    \"kata\": {\n      \"runtimeType\": \"io.containerd.kata.v2\"\n    }\n  }\n}\n```\n\nRestart Docker to apply changes:\n\n```bash\nsudo systemctl restart docker\n\n# Verify Kata is available in Docker\ndocker info | grep -A5 Runtimes\n# Expected output should include \"io.containerd.runc.v2 kata\"\n```\n\n#### Kubernetes Mode - RuntimeClass Setup\n\nCluster administrators must create RuntimeClass resources:\n\n```yaml\n# gVisor RuntimeClass\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc  # Matches containerd handler name\nscheduling:\n  nodeSelector:\n    kubernetes.io/arch: amd64\n\n---\n# Kata Containers (QEMU backend) RuntimeClass\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: kata-qemu\nhandler: kata-qemu\n\n---\n# Kata Containers (Firecracker backend) RuntimeClass\n# This is what secure_runtime=\"firecracker\" maps to\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: kata-fc\nhandler: kata-fc\n```\n\ncontainerd configuration (`/etc/containerd/config.toml`):\n\n```toml\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc]\n          runtime_type = \"io.containerd.runsc.v1\"\n          [plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.runsc.options]\n            TypeUrl = \"io.containerd.runsc.v1.options\"\n            ConfigPath = \"/etc/containerd/runsc.toml\"\n\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.kata-qemu]\n  runtime_type = \"io.containerd.kata-qemu.v2\"\n\n[plugins.\"io.containerd.grpc.v1.cri\".containerd.runtimes.kata-fc]\n  runtime_type = \"io.containerd.kata-fc.v2\"\n```\n\nCreate the gVisor configuration file:\n\n```bash\nsudo tee /etc/containerd/runsc.toml > /dev/null <<'EOF'\n[runsc]\n  platform = \"ptrace\"\nEOF\n```\n\nRestart containerd:\n\n```bash\nsudo systemctl restart containerd\n```\n\n##### Kata Containers on Kubernetes\n\nFollow the [official Kata Containers installation guide](https://github.com/kata-containers/kata-containers/blob/main/tools/packaging/kata-deploy/helm-chart/README.md).\n\nQuick installation using Helm:\n\n```bash\n# Install kata-deploy which will set up Kata Containers via DaemonSet\nhelm install kata-deploy \"oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy\" --version \"3.27.0\" --namespace kube-system --create-namespace\n\n# Wait for kata-deploy pods to be ready\nkubectl wait --for=condition=ready pod -l name=kata-deploy -n kube-system --timeout=300s\n```\n\n> **Note**: The `kata-deploy` DaemonSet will automatically configure containerd on all nodes. Manual containerd configuration is not required when using kata-deploy.\n\nVerify installation:\n\n```bash\n# Check RuntimeClasses\nkubectl get runtimeclass\n\n# Expected output:\n# NAME         HANDLER     AGE\n# kata         kata-qemu   10m\n# kata-qemu    kata-qemu   10m\n# kata-clh     kata-clh    10m\n# kata-fc      kata-fc     10m\n\n# Test Kata with a simple pod\nkubectl run test-kata --restart=Never --image=hello-world --runtime-class=kata-qemu\nkubectl logs test-kata\nkubectl delete pod test-kata\n```\n\n### Runtime Resolver\n\nThe server reads `[secure_runtime]` at startup and resolves it to the backend-specific identifier based on the deployment mode:\n\n```python\nclass SecureRuntimeResolver:\n    \"\"\"Resolves secure runtime config to backend-specific parameters.\"\"\"\n    \n    def __init__(self, config: AppConfig):\n        self.secure_runtime = config.secure_runtime  # may be None\n        self.runtime_mode = config.runtime.type       # \"docker\" or \"kubernetes\"\n    \n    def get_docker_runtime(self) -> Optional[str]:\n        \"\"\"Return Docker --runtime value, or None for runc.\"\"\"\n        if not self.secure_runtime or not self.secure_runtime.type:\n            return None\n        if not self.secure_runtime.docker_runtime:\n            raise ConfigError(\n                f\"Secure runtime '{self.secure_runtime.type}' is not supported \"\n                f\"in Docker mode (docker_runtime is empty).\"\n            )\n        return self.secure_runtime.docker_runtime\n    \n    def get_k8s_runtime_class(self) -> Optional[str]:\n        \"\"\"Return K8s runtimeClassName, or None for cluster default.\"\"\"\n        if not self.secure_runtime or not self.secure_runtime.type:\n            return None\n        return self.secure_runtime.k8s_runtime_class\n```\n\n### Startup Validation\n\nThe server validates the configured secure runtime at startup, failing fast if the runtime is unavailable:\n\n```python\ndef validate_secure_runtime_on_startup(config: AppConfig, docker_client=None, k8s_client=None):\n    \"\"\"Validate secure runtime availability at server startup.\"\"\"\n    sr = config.secure_runtime\n    if not sr or not sr.type:\n        logger.info(\"No secure runtime configured; using standard runc.\")\n        return\n    \n    if config.runtime.type == \"docker\":\n        if not sr.docker_runtime:\n            raise ConfigError(\n                f\"secure_runtime.type='{sr.type}' but docker_runtime is empty. \"\n                f\"This runtime is not supported in Docker mode.\"\n            )\n        info = docker_client.info()\n        available = info.get(\"Runtimes\", {}).keys()\n        if sr.docker_runtime not in available:\n            raise ConfigError(\n                f\"Docker runtime '{sr.docker_runtime}' is not available. \"\n                f\"Available runtimes: {list(available)}. \"\n                f\"Please install and configure it in /etc/docker/daemon.json.\"\n            )\n    else:  # kubernetes\n        try:\n            k8s_client.read_runtime_class(sr.k8s_runtime_class)\n        except ApiException as e:\n            if e.status == 404:\n                raise ConfigError(\n                    f\"RuntimeClass '{sr.k8s_runtime_class}' does not exist. \"\n                    f\"Please create it in the cluster.\"\n                )\n            raise\n    \n    logger.info(f\"Secure runtime '{sr.type}' validated successfully.\")\n```\n\n### Docker Mode Implementation\n\nChanges to `server/src/services/docker.py`. The runtime is read from server config, not from the request:\n\n```python\nclass DockerSandboxService(SandboxService):\n    def __init__(self, config: Optional[AppConfig] = None):\n        # ... existing initialization ...\n        self.resolver = SecureRuntimeResolver(self.app_config)\n        # Runtime is resolved once at init; already validated at startup\n        self.docker_runtime = self.resolver.get_docker_runtime()\n    \n    async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n        # ... existing code ...\n        \n        container = self.docker_client.containers.run(\n            image=request.image.uri,\n            # ... other parameters ...\n            runtime=self.docker_runtime,  # \"runsc\", \"kata-runtime\", or None\n        )\n```\n\n### Kubernetes Mode Implementation\n\nBoth Kubernetes workload providers inject `runtimeClassName` from server config. The `runtimeClassName` is resolved once at service initialization (already validated at startup).\n\n#### BatchSandboxProvider\n\nChanges to `server/src/services/k8s/batchsandbox_provider.py`:\n\n- **CRD**: `sandbox.opensandbox.io/v1alpha1` BatchSandbox\n- **Pod spec path**: `spec.template.spec`\n\n```python\nclass BatchSandboxProvider:\n    def __init__(self, config: AppConfig, ...):\n        # ... existing initialization ...\n        self.resolver = SecureRuntimeResolver(config)\n        self.runtime_class = self.resolver.get_k8s_runtime_class()\n    \n    def create_workload(self, request: CreateSandboxRequest, ...):\n        # ... existing code ...\n\n        if self.runtime_class:\n            runtime_manifest[\"spec\"][\"template\"][\"spec\"][\"runtimeClassName\"] = self.runtime_class\n        \n        # ... template merge ...\n```\n\n#### AgentSandboxProvider\n\nChanges to `server/src/services/k8s/agent_sandbox_provider.py`:\n\n- **CRD**: `agents.x-k8s.io/v1alpha1` Sandbox\n- **Pod spec path**: `spec.podTemplate.spec`\n\n```python\nclass AgentSandboxProvider:\n    def __init__(self, config: AppConfig, ...):\n        # ... existing initialization ...\n        self.resolver = SecureRuntimeResolver(config)\n        self.runtime_class = self.resolver.get_k8s_runtime_class()\n    \n    def create_workload(self, request: CreateSandboxRequest, ...):\n        # ... existing code ...\n\n        pod_spec = self._build_pod_spec(request, ...)\n        if self.runtime_class:\n            pod_spec[\"runtimeClassName\"] = self.runtime_class\n\n        runtime_manifest[\"spec\"][\"podTemplate\"][\"spec\"] = pod_spec\n        # ... template merge ...\n```\n\n#### Provider Comparison\n\n| Aspect | BatchSandboxProvider | AgentSandboxProvider |\n|--------|---------------------|---------------------|\n| CRD Kind | `BatchSandbox` | `Sandbox` |\n| Pod Spec Path | `spec.template.spec` | `spec.podTemplate.spec` |\n| Pool Support | Yes (`poolRef`) | No |\n| Runtime Source | Server config | Server config |\n\n#### Pooled Sandbox Consistency\n\nIn Kubernetes mode with resource pools (Pool CRD), the Pool's `runtimeClassName` must match the server's `[secure_runtime]` configuration. Since both are managed by the same SRE administrator, this is an operational requirement.\n\n**Pool configuration by SRE administrator:**\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: Pool\nmetadata:\n  name: gvisor-pool\nspec:\n  template:\n    spec:\n      runtimeClassName: \"gvisor\"  # Must match server's secure_runtime.k8s_runtime_class\n      containers:\n      - name: sandbox-container\n        image: python:3.11\n  capacitySpec:\n    bufferMax: 10\n    bufferMin: 2\n    poolMax: 20\n    poolMin: 5\n```\n\nThe server validates this consistency at startup. If the Pool's `runtimeClassName` does not match the server config, the server logs a warning and refuses to use that pool.\n\n### Compatibility Matrix\n\n| Secure Runtime | Local Docker | Kubernetes | Notes |\n|---------------|--------------|------------|-------|\n| gVisor (runsc) | Full support | Full support | Docker `--runtime=runsc`; K8s via RuntimeClass |\n| Kata Containers | Full support | Full support | Docker `--runtime=kata-runtime`; K8s via RuntimeClass |\n| Firecracker | Not supported | Via Kata (`kata-fc`) | Not a Docker OCI runtime; use Kata with Firecracker hypervisor backend in K8s |\n| Custom runtimes | Via config | Via RuntimeClass | Requires pre-installation |\n\n## Test Plan\n\n### Unit Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| Config parsing | Verify `SecureRuntimeConfig` correctly parses TOML |\n| Resolver (Docker) | Verify `get_docker_runtime()` returns correct value or None |\n| Resolver (K8s) | Verify `get_k8s_runtime_class()` returns correct value or None |\n| Empty type handling | Verify fallback to runc when `type = \"\"` |\n| Firecracker in Docker | Verify error when `docker_runtime` is empty in Docker mode |\n\n### Integration Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| Startup validation (Docker) | Server fails to start when configured runtime not in Docker daemon |\n| Startup validation (K8s) | Server fails to start when RuntimeClass doesn't exist |\n| Docker + gVisor | Create sandbox on Docker host with `[secure_runtime] type = \"gvisor\"` |\n| Docker + Kata | Create sandbox on Docker host with `[secure_runtime] type = \"kata\"` |\n| K8s + gVisor | Create sandbox in cluster with gVisor RuntimeClass |\n| K8s + kata-fc | Create sandbox in cluster with kata-fc RuntimeClass |\n| Pool consistency | Server warns when Pool runtimeClassName doesn't match config |\n\n### E2E Tests\n\n| Test Case | Description |\n|-----------|-------------|\n| SDK unaware of runtime | SDK creates sandbox without any runtime parameter; runs in gVisor |\n| Runtime isolation verification | Verify syscall interception in gVisor sandbox |\n| Fallback behavior | Verify standard runc when `[secure_runtime]` not configured |\n| execd injection under gVisor | Verify execd binary injection works within gVisor runtime |\n\n## Drawbacks\n\n1. **Operational Complexity**: Administrators must install and configure secure runtimes\n2. **Performance Overhead**: Secure runtimes add startup latency and memory overhead\n3. **Compatibility Issues**: Some workloads may not work with certain runtimes\n4. **Documentation Burden**: Requires comprehensive setup guides for each runtime\n\n## Alternatives\n\n### Alternative 1: Per-Request Runtime Selection\n\n**Approach**: Add a `secureRuntime` field to `CreateSandboxRequest`, allowing SDK users to choose the runtime per sandbox (e.g., `secure_runtime=\"gvisor\"`).\n\n**Pros**:\n- Maximum flexibility for users\n- Different sandboxes can use different runtimes on the same server\n- Supports mixed security levels (trusted vs untrusted workloads)\n\n**Cons**:\n- Secure runtime is fundamentally an infrastructure decision, not a per-request decision\n- API callers could potentially downgrade security\n- Adds complexity to SDK and API surface\n- Most deployments only use one runtime; per-request selection is rarely needed\n\n**Decision**: Rejected. Secure runtime selection is an infrastructure-level concern that should be managed by administrators, consistent with how Docker (`daemon.json`) and Kubernetes (`RuntimeClass`) handle runtime configuration. Per-request selection may be revisited as a future enhancement if demand arises.\n\n### Alternative 2: Automatic Runtime Detection\n\n**Approach**: Automatically detect and use the most secure available runtime.\n\n**Pros**:\n- Zero configuration\n- Always uses best available isolation\n\n**Cons**:\n- Unpredictable behavior across environments\n- May break workloads with runtime incompatibilities\n- Performance impact without administrator consent\n\n**Decision**: Rejected. Explicit administrator choice is preferred for security/performance tradeoffs.\n\n## Infrastructure Needed\n\n- **Testing Environments**:\n  - Docker host with gVisor (runsc) configured\n  - Docker host with Kata Containers (kata-runtime) configured\n  - Kubernetes cluster with gVisor RuntimeClass (`runsc`)\n  - Kubernetes cluster with Kata QEMU RuntimeClass (`kata-qemu`)\n  - Kubernetes cluster with Kata + Firecracker RuntimeClass (`kata-fc`)\n\n- **CI/CD Updates**:\n  - Add integration tests for secure runtime validation\n  - Add E2E tests with gVisor-enabled environment\n\n- **Documentation**:\n  - User guide: How to use secure runtimes\n  - Admin guide: How to set up gVisor/Kata/Firecracker\n  - API reference updates\n\n## Upgrade & Migration Strategy\n\n### Backward Compatibility\n\n- **No API breaking changes**: `CreateSandboxRequest` schema is unchanged\n- **No SDK changes**: Existing SDK code works as-is\n- **Default behavior unchanged**: Without `[secure_runtime]` config, sandboxes use standard runc\n- **Existing configurations work**: The new `[secure_runtime]` section is optional\n\n### Migration Path\n\n1. **Phase 1**: Install and configure secure runtime on infrastructure (Docker daemon or K8s RuntimeClass)\n2. **Phase 2**: Add `[secure_runtime]` section to server configuration\n3. **Phase 3**: Restart server — all sandboxes now use the secure runtime\n4. No SDK or application code changes required at any phase\n\n### Documentation Updates\n\n- Add infrastructure setup guide for gVisor/Kata/Firecracker\n- Add server configuration reference for `[secure_runtime]`\n- Add troubleshooting guide for runtime compatibility issues\n"
  },
  {
    "path": "oseps/0005-client-side-sandbox-pool.md",
    "content": "---\ntitle: Client-Side Sandbox Pool\nauthors:\n  - \"@ninan\"\ncreation-date: 2026-03-02\nlast-updated: 2026-03-06\nstatus: implementing\n---\n\n# OSEP-0005: Client-Side Sandbox Pool\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Functional Boundaries](#functional-boundaries)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [Design Reading Guide](#design-reading-guide)\n  - [Terminology](#terminology)\n  - [Class Model](#class-model)\n  - [Public API](#public-api)\n  - [Core Model: Properties and Constraints](#core-model-properties-and-constraints)\n  - [Configuration](#configuration)\n  - [State Store Abstraction](#state-store-abstraction)\n  - [Pool and Sandbox Lifecycle](#pool-and-sandbox-lifecycle)\n    - [Lifecycle operation pseudocode](#lifecycle-operation-pseudocode)\n  - [Acquire Flow and Method Semantics](#acquire-flow-and-method-semantics)\n    - [Acquire pseudocode](#acquire-pseudocode)\n    - [Acquire sequence (simplified)](#acquire-sequence-simplified)\n  - [Reconcile Loop](#reconcile-loop)\n    - [Reconcile pseudocode](#reconcile-pseudocode)\n    - [Reconcile sequence (simplified)](#reconcile-sequence-simplified)\n  - [Failure Handling and Recovery](#failure-handling-and-recovery)\n    - [Failure and backoff pseudocode](#failure-and-backoff-pseudocode)\n  - [Observability](#observability)\n  - [Compatibility and Evolution](#compatibility-and-evolution)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nThis proposal introduces a client-side `SandboxPool` in the SDK for acquiring\nready sandboxes with predictable latency. The pool is an SDK-local component,\nstrictly decoupled from runtime-side pooling and infrastructure internals.\n\nPool-managed sandboxes are created through standard lifecycle create APIs.\nIdle records use a fixed key TTL of 24h in the state store and are naturally\nevicted on expiry. Callers can specify sandbox timeout duration at `acquire`\ntime.\n\nSandboxes are still treated as ephemeral and non-reusable. The pool only\nmaintains an idle buffer target; runtime remains the source of truth for hard\nresource limits.\n\n## Motivation\n\nPer-request sandbox creation introduces avoidable cold-start cost. A client-side\nreserve of clean, ready sandboxes improves first-byte latency while preserving a\nclear caller-owned capacity model.\n\n### Goals\n\n- Define a first-class SDK abstraction for idle-buffer sandbox pooling.\n- Provide clear and deterministic acquire behavior when idle is available or empty.\n- Unify single-node and distributed modes behind one storage interface.\n- Keep runtime coupling out of pool control logic.\n- Preserve compatibility with existing SDK usage.\n- Make caller responsibility explicit for cost and fallback strategy.\n\n### Non-Goals\n\n- Introducing or modifying runtime-side pool implementations.\n- Auto-discovering backend resource limits from runtime/infrastructure.\n- Guaranteeing zero cold starts under unlimited burst.\n- Coupling pool behavior to Kubernetes, Docker, or any specific backend.\n- Shipping a built-in opinionated distributed backend (e.g., Redis/etcd/SQL).\n- Building strict global capacity accounting in SDK.\n\n## Requirements\n\n- Must work using only existing lifecycle APIs.\n- Must not assume runtime-specific capabilities.\n- Must not require lifecycle OpenAPI schema changes.\n- Must expose deterministic behavior when idle buffer is empty.\n- Must keep config explicit and caller-controlled.\n- Must expose pool health, counters, and acquire latency metrics.\n\n## Proposal\n\nAdd SDK-level `SandboxPool` that pre-creates and manages a target idle buffer\nof clean, borrowable sandboxes.\n\nCallers:\n- `acquire` a sandbox,\n- optionally provide `sandboxTimeout` for the acquired sandbox,\n- use the sandbox,\n- terminate sandbox via existing `sandbox.kill()` when done.\n\nThe pool is treated as a purely client-layer construct:\n\n- No runtime coupling in control logic.\n- No runtime-specific optimization assumptions.\n- No hidden server-side autoscaling behavior.\n\nIdle buffering is caller-owned and best-effort:\n- `maxIdle` is a standby target/cap (not strict guarantee).\n- Runtime enforces hard resource/quota limits.\n\nCreate compatibility:\n- Pool create paths use existing lifecycle create APIs directly.\n- Pool does not require any special extension key/value convention.\n\n### Functional Boundaries\n\nThis OSEP explicitly defines the following boundaries:\n\n- **In scope**\n  - SDK-side model, APIs, and control loop.\n  - Deterministic pool behavior under normal and degraded conditions.\n  - Idle-buffer management for clean, ready sandboxes.\n  - A pluggable state-store interface used by both single-node and distributed modes.\n- **Out of scope**\n  - Runtime-side scheduler policy.\n  - Backend capacity introspection.\n  - Any specific distributed datastore implementation bundled by default.\n\n### Notes/Constraints/Caveats\n\n- Runtime-level pooling may coexist but is irrelevant to this SDK model.\n- Sandboxes are ephemeral and non-reusable after use.\n- Runtime is authoritative for capacity limits; SDK pool does not enforce global hard caps.\n\n### Risks and Mitigations\n\n- Risk: Frequent empty-idle events under burst traffic.\n  Mitigation: configurable empty behavior (`DIRECT_CREATE` or `FAIL_FAST`) and metrics.\n- Risk: Backend state/lifecycle changes break assumptions.\n  Mitigation: connect-on-acquire validation, stale-id cleanup, and adapter-based\n  state handling.\n- Risk: Multi-process replenish may issue duplicate create attempts.\n  Mitigation: distributed primary-lock ownership, idempotent store operations,\n  backoff, and runtime-side quota protection.\n\n## Design Details\n\n### Design Reading Guide\n\nRecommended reading order for implementation and review:\n\n1. **Class Model + Public API**: understand responsibilities and entrypoints.\n2. **State Store Abstraction**: lock down single-node/distributed correctness contracts.\n3. **Acquire Flow**: understand foreground request behavior and deterministic outcomes.\n4. **Reconcile Loop**: understand background convergence and recovery behavior.\n5. **Failure Handling**: verify retry/degrade/backoff behavior and caller actions.\n\n### Terminology\n\n- **Idle sandbox**: healthy sandbox ID currently available for borrow.\n- **Authoritative store**: the single source of truth for idle membership.\n- **Best-effort maxIdle**: convergence target, not a strict availability guarantee.\n- **Leader (Primary)**: current lock owner for one `poolName`; allowed to run\n  reconcile maintenance write paths.\n- **Follower (Non-Leader)**: node that does not currently hold leader lock.\n\n### Class Model\n\n```mermaid\nclassDiagram\n    class SandboxPool {\n      <<interface>>\n      +start()\n      +acquire(sandboxTimeout, policy) Sandbox\n      +resize(maxIdle)\n      +snapshot() PoolSnapshot\n      +shutdown(graceful)\n    }\n\n    class DefaultSandboxPool {\n      -config PoolConfig\n      -reconciler PoolReconciler\n      -stateStore PoolStateStore\n      +start()\n      +acquire(sandboxTimeout, policy) Sandbox\n      +resize(maxIdle)\n      +snapshot() PoolSnapshot\n      +shutdown(graceful)\n    }\n\n    class Sandbox {\n      +sandboxId String\n    }\n\n    class PoolConfig {\n      +poolName String\n      +ownerId String\n      +maxIdle Int\n      +warmupConcurrency Int\n      +primaryLockTtl Duration\n      +emptyBehavior EmptyBehavior\n      +stateStore PoolStateStore\n    }\n\n    class PoolStateStore {\n      <<interface>>\n      +tryTakeIdle(poolName) String?\n      +putIdle(poolName, sandboxId)\n      +removeIdle(poolName, sandboxId)\n      +tryAcquirePrimaryLock(poolName, ownerId, ttl) bool\n      +renewPrimaryLock(poolName, ownerId, ttl) bool\n      +releasePrimaryLock(poolName, ownerId)\n      +reapExpiredIdle(poolName, now)\n      +snapshotCounters(poolName) StoreCounters\n    }\n\n    class PoolSnapshot {\n      +state PoolState\n      +idleCount Int\n      +lastError String\n    }\n\n    class PoolReconciler {\n      +reconcileTick()\n      +runPrimaryReplenishOnce()\n      +retireExpiredIdle()\n      +applyBackoff()\n    }\n\n    class AcquirePolicy {\n      <<enumeration>>\n      FAIL_FAST\n      DIRECT_CREATE\n    }\n\n    class EmptyBehavior {\n      <<enumeration>>\n      FAIL_FAST\n      DIRECT_CREATE\n    }\n\n    class PoolState {\n      <<enumeration>>\n      HEALTHY\n      DEGRADED\n      DRAINING\n      STOPPED\n    }\n\n    SandboxPool <|.. DefaultSandboxPool\n    DefaultSandboxPool --> PoolConfig : uses\n    DefaultSandboxPool --> PoolReconciler : owns\n    DefaultSandboxPool --> PoolSnapshot : returns\n    DefaultSandboxPool --> Sandbox : returns\n    DefaultSandboxPool --> AcquirePolicy : parameter\n    DefaultSandboxPool --> PoolStateStore : persists state\n    PoolSnapshot --> PoolState : includes\n```\n\n### Public API\n\nLanguage-neutral contract (normative semantics, not tied to any SDK syntax):\n\n```text\nSandboxPool\n  - start()\n  - acquire(sandboxTimeout?, policy=DIRECT_CREATE) -> Sandbox\n  - resize(maxIdle)\n  - snapshot() -> PoolSnapshot\n  - shutdown(graceful=true)\n\nAcquirePolicy\n  - FAIL_FAST\n  - DIRECT_CREATE\n\nPoolStateStore\n  - tryTakeIdle(poolName) -> sandboxId?\n  - putIdle(poolName, sandboxId)\n  - removeIdle(poolName, sandboxId)\n  - tryAcquirePrimaryLock(poolName, ownerId, ttl) -> bool\n  - renewPrimaryLock(poolName, ownerId, ttl) -> bool\n  - releasePrimaryLock(poolName, ownerId)\n  - reapExpiredIdle(poolName, now)\n```\n\nMethod intent:\n- `acquire`: primary pool operation; it takes/creates a sandbox ID internally and\n  returns a connected sandbox instance (`Sandbox` in host SDK terms).\n- `PoolStateStore`: stores only IDs and pool coordination state; it must not store\n  language runtime sandbox objects.\n- `runPrimaryReplenishOnce` (internal): primary-only maintenance write path;\n  independent from caller-facing `acquire` flow.\n\n### Core Model: Properties and Constraints\n\nModel entities:\n- **Sandbox**: connected sandbox client object created from `sandboxId` on demand.\n- **Sandbox ID**: canonical identity managed by pool and store.\n- **Idle reserve**: clean and borrowable sandboxes only.\n\nConstraints:\n- Soft target: pool tries to keep `idle` near `maxIdle`\n- Idle eligibility is validated at `acquire` connection time; stale IDs are\n  removed and fallback to direct create is applied.\n- Runtime authority: hard capacity/quota is enforced by runtime, not by SDK pool.\n\nCounter transition rules:\n- `acquire` from idle: `idle - 1`\n- `replenish create success`: `idle + 1` (after persisted to `PoolStateStore`)\n- `idle retire`: `idle - 1`\n\n### Configuration\n\nConfiguration keys:\n- `poolName` (required): user-defined readable name and namespace key for this logical pool.\n- `ownerId` (required in distributed mode): unique process identity used for primary lock ownership.\n- `maxIdle` (required): standby idle target/cap.\n- `warmupConcurrency` (optional): max concurrent creation workers.\n- `primaryLockTtl` (optional): lock TTL for distributed primary ownership.\n- `emptyBehavior` (optional): behavior when idle buffer is empty (`DIRECT_CREATE` or `FAIL_FAST`).\n- `stateStore` (required): injected implementation of `PoolStateStore`.\n\nDefault derivation (when omitted):\n- `warmupConcurrency = max(1, ceil(maxIdle * 0.2))`\n- `primaryLockTtl` should be larger than one reconcile tick interval.\n- `idleTtl` is fixed at 24h (non-configurable in V1).\n- `emptyBehavior = DIRECT_CREATE` (default). Caller may explicitly set\n  `FAIL_FAST` for fail-fast semantics.\n- `putIdle` may use an implementation-defined safety margin and write\n  `effectiveIdleTtl = idleTtl - ttlSafetyMargin`; `effectiveIdleTtl` should stay\n  greater than one reconcile tick interval.\n- caller-provided numeric values override defaults for configurable keys.\n\n### State Store Abstraction\n\nThe SDK pool logic is implementation-invariant and always uses a `PoolStateStore`\ninterface. Deployment mode is decided by which implementation is injected:\n\n- `InMemoryPoolStateStore`: single-node/local mode.\n- User-provided remote datastore implementation: distributed mode.\n\nContract semantics (normative):\n- Pool scoping: all operations are namespaced by `poolName`; no cross-pool leakage.\n- Atomic take: one idle sandbox can only be taken by one acquire operation.\n- Idempotent put/remove operations for idle membership.\n- Ordering: `tryTakeIdle` should prefer FIFO (oldest idle first) as a\n  best-effort implementation goal. Strict FIFO is not required across all\n  backends.\n- Snapshot consistency at least eventually consistent for counters.\n\nLock semantics (normative):\n- Primary lock semantics for distributed safety:\n  - Only the current leader lock holder may execute **reconcile maintenance**\n    writes (`putIdle`, `reapExpiredIdle`).\n  - Foreground acquire-path write (`tryTakeIdle`) is allowed on **all** nodes,\n    including leader and followers.\n  - `removeIdle` on stale-id cleanup is an acquire-path cleanup write and is\n    allowed on all nodes.\n  - Lock ownership must be time-bounded (`ttl`) and renewable by owner only.\n  - `tryAcquirePrimaryLock` is best-effort mutually exclusive by `poolName`.\n  - Lock loss must cause immediate stop of replenish attempts on that node.\n\nIdle TTL semantics (normative):\n  - Idle entries are written with logical `idleTtl=24h`.\n  - Store may apply a small `ttlSafetyMargin` when writing keys, as long as\n    `effectiveIdleTtl > reconcileTickInterval`.\n  - Distributed stores should rely on backend TTL expiry.\n  - Single-node in-memory store must track `expiresAt` and evict expired entries\n    via lazy-on-acquire and periodic sweep.\n  - `reapExpiredIdle` is a unified store hook invoked by reconcile:\n    - In-memory store: performs active sweep.\n    - TTL-capable distributed store: may be no-op.\n- Store data model scope:\n  - Store persists only `sandboxId` and idle/lock coordination metadata.\n  - Store must not require serialization of SDK language objects.\n\nImplementation-owned settings:\n- Any optional coordination/locking policy for distributed replenish is managed\n  by each `PoolStateStore` implementation, not top-level `SandboxPool` config keys.\n\nThis keeps SDK behavior unified across modes while avoiding coupling to any\nspecific distributed system.\n\nDistributed role boundary (normative):\n\n| Responsibility area | Leader (lock owner) | Follower (non-leader) |\n|---|---|---|\n| Foreground `acquire` (`tryTakeIdle`) | Allowed | Allowed |\n| Foreground stale-id cleanup (`removeIdle`) | Allowed | Allowed |\n| Direct-create fallback in `acquire` | Allowed | Allowed |\n| Reconcile replenish (`createSandbox` + `putIdle`) | Allowed | Not allowed |\n| Reconcile TTL reap (`reapExpiredIdle`) | Allowed | Not allowed |\n| Lock renew/release for reconcile ownership | Allowed | Not allowed (must fail/reject) |\n\nRule of thumb:\n- Leader is a **background maintenance role**, not a request-routing role.\n- Leader must continue serving foreground acquires exactly like any other node.\n- Losing leader lock only stops reconcile maintenance on that node; it must not\n  stop foreground acquire handling.\n\nPool naming rules:\n- `poolName` is user-defined and human-readable.\n- `poolName` must be stable for one logical pool lifecycle.\n- Different business pools must use different `poolName` values.\n\n#### PoolStateStore compliance matrix (required)\n\nUser-provided distributed stores must pass the following contract checks before\nbeing considered production-ready:\n\n| Contract area | Scenario | Expected result |\n|---|---|---|\n| Atomic idle take | Two concurrent `tryTakeIdle` requests target one idle `sandboxId` | Exactly one caller succeeds; the other receives empty result |\n| Idempotent put | Duplicate `putIdle(poolName, sandboxId)` retries | Idle membership remains single-copy; counters do not overcount |\n| Idempotent remove | Duplicate `removeIdle(poolName, sandboxId)` retries | Operation remains successful/no-op on second attempt |\n| FIFO preference | Multiple idle entries with different insertion times | `tryTakeIdle` returns oldest-first as best effort (strict global FIFO not required) |\n| Primary lock acquire | Multiple nodes call `tryAcquirePrimaryLock` concurrently | At most one node becomes current primary for that `poolName` window |\n| Primary lock renew | Non-owner tries `renewPrimaryLock` | Renew is rejected; ownership is unchanged |\n| Primary lock failover | Current primary crashes and lock TTL expires | Another node can acquire lock and continue replenish |\n| Idle TTL expiry | Idle entry reaches 24h TTL | Entry is no longer borrowable and is removed/expired |\n| Reconcile write ownership | Non-leader tries `putIdle` from reconcile path | Write is rejected (must not be applied) |\n| Pool isolation | Same `sandboxId` key pattern used across different `poolName` values | No cross-pool take/remove visibility |\n| Eventual counters | Mixed put/take/create/fail under load | `snapshotCounters` converges to actual membership within implementation SLA |\n\nImplementation note:\n- The SDK should provide a reusable compliance test suite that runs the above\n  scenarios against any `PoolStateStore` implementation.\n\n### Pool and Sandbox Lifecycle\n\nPool lifecycle:\n\n```mermaid\nstateDiagram-v2\n    [*] --> Created\n    Created --> Starting: start()\n    Starting --> Running\n    Running --> Draining: shutdown(graceful=true)\n    Running --> Stopped: shutdown(graceful=false)\n    Draining --> Stopped\n    Stopped --> [*]\n```\n\n#### Lifecycle operation pseudocode\n\n```text\nfunction start(pool):\n  if pool.state in [RUNNING, STARTING]:\n    return\n  pool.state = STARTING\n  spawn reconcile worker (periodic tick)\n  if pool.config.maxIdle > 0:\n    trigger immediate reconcile tick for warmup\n  pool.state = RUNNING\n\nfunction resize(pool, newMaxIdle):\n  validate newMaxIdle >= 0\n  pool.config.maxIdle = newMaxIdle\n  trigger reconcile tick (do not block caller on convergence)\n\nfunction shutdown(pool, graceful=true):\n  stop accepting new acquire requests\n  if !graceful:\n    stop reconcile worker immediately\n    pool.state = STOPPED\n    return\n\n  pool.state = DRAINING\n  stop reconcile worker\n  // no force-return path: borrowed sandboxes remain caller-owned\n  wait until in-flight pool operations finish or drainTimeout reached\n  pool.state = STOPPED\n```\n\nSandbox state model:\n\nThis is a runtime-facing reference model used by pool logic. It is descriptive,\nnot a strict SDK-owned lifecycle contract.\n\n```mermaid\nstateDiagram-v2\n    [*] --> Creating\n    Creating --> Ready: health check pass\n    Creating --> Terminated: create/check failed\n    Ready --> InUse: acquire\n    InUse --> Terminated: sandbox.kill() or timeout\n    InUse --> Terminated: unrecoverable runtime failure\n    Ready --> Retiring: idle ttl exceeded\n    Retiring --> Terminated\n    Terminated --> [*]\n```\n\n### Acquire Flow and Method Semantics\n\n`acquire` flow:\n\nDiagram note: this flowchart is an overview. Normative behavior is defined by\nthe pseudocode and method semantics below.\n\n```mermaid\nflowchart TD\n    A[Acquire request] --> B{Idle sandboxId available?}\n    B -- yes --> C[Atomically take idle sandboxId]\n    C --> C1{Connect succeeds?}\n    C1 -- yes --> C2[Return connected sandbox instance]\n    C1 -- no --> C3[Remove stale idle id and try direct create]\n    B -- no --> D{Acquire policy}\n\n    D -- FAIL_FAST --> E[\"Return SandboxException(code=POOL_EMPTY)\"]\n    D -- DIRECT_CREATE --> I[Attempt direct create -> connect -> optional renew]\n    C3 --> I\n    I --> J{Success?}\n    J -- yes --> K[Return connected sandbox instance]\n    J -- no --> H[\"Return original create/connect error\"]\n```\n\nMethod semantics:\n- `acquire`: returns a connected sandbox instance. Internally it first tries atomic idle-take\n  by `sandboxId`, validates by connect, cleans stale IDs on connect failure, then\n  applies empty behavior (`DIRECT_CREATE` default, or `FAIL_FAST` if configured).\n  It may apply `sandboxTimeout` by calling lifecycle `renew`.\n\n#### Acquire pseudocode (normative)\n\n```text\nfunction acquire(pool, sandboxTimeout, policy):\n  sandboxId = stateStore.tryTakeIdle(pool.config.poolName) // atomic\n  if sandboxId != null:\n    try:\n      handle = lifecycle.connectById(sandboxId) // host SDK's connect equivalent\n      if sandboxTimeout != null:\n        lifecycle.renew(sandboxId, sandboxTimeout) // throw original timeout/renew error on failure\n      return handle\n    catch e:\n      // small-probability stale idle (killed externally/runtime reclaimed)\n      // best-effort cleanup then fallback cold start\n      stateStore.removeIdle(pool.config.poolName, sandboxId)\n      lifecycle.tryKill(sandboxId)\n\n  if policy == FAIL_FAST:\n    throw SandboxException(code=POOL_EMPTY)\n\n  // direct create uses standard create with 24h idle-style timeout.\n  // create/connect failure handling and cleanup reuse existing lifecycle logic.\n  createdId = lifecycle.createSandbox(timeout=24h)\n  createdHandle = lifecycle.connectById(createdId)\n  if sandboxTimeout != null:\n    lifecycle.renew(createdId, sandboxTimeout)\n  return createdHandle\n```\n\n#### Acquire sequence (simplified, informative)\n\n```mermaid\nsequenceDiagram\n    participant Caller\n    participant Pool as SandboxPool\n    participant Store as PoolStateStore\n    participant API as Lifecycle API\n\n    Caller->>Pool: acquire(timeout, policy)\n    Pool->>Store: tryTakeIdle(poolName)\n    alt idle hit\n        Store-->>Pool: sandboxId\n        Pool->>API: connect(sandboxId)\n        alt connect ok\n            API-->>Pool: connected\n            Pool-->>Caller: Sandbox\n        else connect failed\n            API-->>Pool: failed\n            Pool->>Store: removeIdle(poolName, sandboxId)\n            Pool->>API: create sandbox(timeout=24h)\n            API-->>Pool: sandboxId / failure\n            Pool->>API: connect(createdId)\n            Pool->>API: renew(createdId, sandboxTimeout?) \n            API-->>Pool: connected / failed\n            Pool-->>Caller: Sandbox or original create/connect error\n        end\n    else idle miss + FAIL_FAST\n        Store-->>Pool: null\n        Pool-->>Caller: POOL_EMPTY\n    else idle miss + DIRECT_CREATE\n        Store-->>Pool: null\n        Pool->>API: create sandbox(timeout=24h)\n        API-->>Pool: sandboxId / failure\n        Pool->>API: connect(createdId)\n        Pool->>API: renew(createdId, sandboxTimeout?)\n        API-->>Pool: connected / failed\n        Pool-->>Caller: Sandbox or original create/connect error\n    end\n```\n\nKill-only model:\n- Pool does not expose return/finalize APIs.\n- Caller ends sandbox lifecycle via existing `sandbox.kill()` (or runtime timeout).\n- Pool does not track borrowed sandbox terminal state as a hard capacity source of truth.\n\nImportant behavior:\n- Borrowing from idle at `idle == maxIdle` is expected and correct.\n- Runtime capacity/quota remains authoritative under burst.\n- 24h idle-key TTL reduces stale-id probability but does not guarantee runtime\n  state is still `Running`; acquire handles this small-probability case by\n  cleaning stale id and degrading to direct create.\n\n### Reconcile Loop\n\nThe pool runs a background reconcile loop that fires on a periodic tick. Each\ntick drives through four ordered phases:\n\nDiagram note: this flowchart is an overview. Normative behavior is defined by\nthe pseudocode below.\n\n```mermaid\nflowchart TD\n    A[Reconcile tick] --> B[\"Snapshot counters: idle\"]\n    B --> C[\"Rely on key TTL expiry (fixed 24h); optional local sweep in in-memory store\"]\n    C --> E[\"Compute deficit:\n        target = maxIdle\n        deficit = target − idle\"]\n\n    E --> F{deficit > 0?}\n    F -- no --> J[Assess health]\n    F -- yes --> G{In backoff?}\n    G -- yes --> J\n    G -- no --> H[\"Create min(deficit, warmupConcurrency) sandboxes\"]\n    H --> I{Create outcome}\n    I -- all OK --> I1[\"New sandboxes → Ready → idle reserve\n        idle ▲ — clear failure counter\"]\n    I -- partial / fail --> I2[\"Failed creates recorded\n        increment failure counter\"]\n    I1 --> J\n    I2 --> J\n\n    J --> K{Consecutive failures > threshold?}\n    K -- yes --> M[\"Pool state → DEGRADED\n        Apply exponential backoff\"]\n    K -- \"no — was DEGRADED\" --> N[\"Pool state → HEALTHY\n        Clear backoff\"]\n    K -- \"no — already HEALTHY\" --> O[No state change]\n    M --> P[Schedule next tick]\n    N --> P\n    O --> P\n```\n\n#### Reconcile pseudocode (normative)\n\n```text\nfunction reconcileTick(poolName, cfg, now):\n  // leader-gated scheduler: only current leader may run reconcile maintenance writes\n  if !stateStore.tryAcquirePrimaryLock(poolName, cfg.ownerId, ttl=cfg.primaryLockTtl):\n    return\n\n  try:\n    runPrimaryReplenishOnce(poolName, cfg, now)\n  finally:\n    stateStore.releasePrimaryLock(poolName, cfg.ownerId)\n\nfunction runPrimaryReplenishOnce(poolName, cfg, now):\n  // 1) idle keys use fixed 24h TTL and expire naturally in TTL-capable stores\n  //    in-memory store may run local sweep/lazy eviction\n  stateStore.reapExpiredIdle(poolName, now) // no-op allowed for TTL-capable backends\n  counters = stateStore.snapshotCounters(poolName) // idle...\n\n  // 2) replenish toward maxIdle, bounded by warmupConcurrency\n  deficit = max(0, cfg.maxIdle - counters.idle)\n  toCreate = min(deficit, cfg.warmupConcurrency)\n  if toCreate == 0 or backoff.active():\n    stateStore.renewPrimaryLock(poolName, cfg.ownerId, ttl=cfg.primaryLockTtl)\n    return\n\n  repeat toCreate times:\n    if !stateStore.renewPrimaryLock(poolName, cfg.ownerId, ttl=cfg.primaryLockTtl):\n      break // lock lost; stop creating immediately\n    try:\n      newId = lifecycle.createSandbox(timeout=24h)\n      stateStore.putIdle(poolName, newId)\n    catch e:\n      recordFailureAndMaybeBackoff(e)\n```\n\n#### Reconcile sequence (simplified, informative)\n\n```mermaid\nsequenceDiagram\n    participant Reconciler as PoolReconciler\n    participant Store as PoolStateStore\n    participant API as Lifecycle API\n\n    Reconciler->>Store: try acquire leader lock\n    alt lock not acquired\n        Store-->>Reconciler: false\n        Reconciler-->>Reconciler: skip this tick\n    else lock acquired\n        Store-->>Reconciler: true\n    end\n    Reconciler-->>Reconciler: run replenish once\n    Reconciler->>Store: reap expired idle\n    Reconciler->>Store: snapshot counters\n\n    loop create up to min(deficit, warmupConcurrency)\n        Reconciler->>Store: renew leader lock\n        Reconciler->>API: create sandbox with 24h timeout\n        API-->>Reconciler: sandboxId / failure\n        Reconciler->>Store: put idle on success\n    end\n    Reconciler->>Store: release leader lock\n```\n\nReconcile policy notes:\n- Replenishment is background work to restore standby reserve.\n- Under high foreground demand or runtime quota pressure, idle may drain below\n  `maxIdle`; this is expected.\n- In distributed mode, replenish is leader-gated: only the current leader lock\n  holder performs reconcile maintenance create paths for a given `poolName`.\n- Nodes that fail to acquire/renew primary lock skip replenish on that tick and\n  retry lock acquisition on subsequent reconcile ticks.\n- Caller-facing `acquire` path remains independent and is served by all nodes\n  (leader included); it does not require leader ownership.\n- Source of truth:\n  - Single-node mode: in-memory state store is authoritative.\n  - Distributed mode: centralized state store is authoritative.\n- `PoolReconciler` never mutates state directly; all state changes go through\n  `PoolStateStore`.\n\n**Pool health state transitions:**\n\n| From | To | Trigger |\n|------|----|---------|\n| `HEALTHY` | `DEGRADED` | Consecutive create failures exceed threshold |\n| `DEGRADED` | `HEALTHY` | Probe or create succeeds, failure counter resets |\n| `HEALTHY` / `DEGRADED` | `DRAINING` | `shutdown(graceful=true)` called |\n| any | `STOPPED` | `shutdown(graceful=false)` or drain completes |\n\nWhen `DEGRADED`, the reconciler applies exponential backoff to create attempts,\npreventing cascading pressure on a failing backend while continuing to serve\nfrom existing idle sandboxes (validated by connect-on-acquire).\n\n### Failure Handling and Recovery\n\nExpected deterministic outcomes:\n\n- `FAIL_FAST`: no idle sandbox available -> `SandboxException(code=POOL_EMPTY)`.\n- `DIRECT_CREATE`: no idle -> attempt direct create; create/connect failure ->\n  propagate original lifecycle error code.\n- `sandboxTimeout` application fails ->\n  propagate original lifecycle timeout/apply error code.\n- Backend quota/capacity errors -> typed create failures, no silent fallback.\n- Empty idle + repeated replenish failure -> degraded pool with user-configured\n  fallback (`DIRECT_CREATE` or `FAIL_FAST`).\n- Idle connect failure on acquire -> remove stale idle ID and fallback to direct create.\n- State-store contention on idle-take/put -> retry with bounded backoff.\n- State-store unavailability -> degrade to policy-defined empty behavior.\n\nError-model alignment:\n- SDK should surface pool failures through existing `SandboxException` hierarchy.\n- Pool-specific error codes should be minimal and used only for pool-owned\n  deterministic states (for example `POOL_EMPTY` under `FAIL_FAST`).\n- Lifecycle create/connect/timeout failures should propagate original SDK/server\n  error codes rather than being remapped into pool-only codes.\n\nMinimal error-code contract (normative):\n\n1. Pool may emit pool-specific codes only for pool-owned deterministic outcomes\n   that lifecycle APIs cannot represent (for example `POOL_EMPTY`).\n2. Pool must not wrap or remap lifecycle create errors.\n3. Pool must not wrap or remap lifecycle connect errors.\n4. Pool must not wrap or remap lifecycle timeout-apply/renew errors.\n5. If pool performs best-effort cleanup (`removeIdle`, `tryKill`) after failure,\n   cleanup errors must not replace the original lifecycle error returned to caller.\n6. Store-layer failures may use pool/store-specific codes when no existing\n   lifecycle error is applicable.\n\nError code action matrix:\n\n| `error.code` | Typical trigger | Retryable | Caller action |\n|---|---|---|---|\n| `POOL_EMPTY` | `acquire` with `FAIL_FAST` and no idle sandbox available | No (for same call) | Fail request fast or retry later according to business SLA |\n| `<existing lifecycle error codes>` | Direct create/connect/timeout apply path fails | Depends on specific error | Reuse existing caller retry/degrade policy for lifecycle errors |\n| `POOL_STATE_STORE_UNAVAILABLE` | Store unavailable during idle take/put/lock operations | Yes | Apply bounded retry; if exhausted, follow `emptyBehavior` fallback |\n| `POOL_STATE_STORE_CONTENTION` | Atomic take or lock-update conflicts | Yes | Retry with bounded backoff and jitter |\n\n#### Failure and backoff pseudocode\n\n```text\nfunction handleCreateFailure(pool, err):\n  pool.failureCount += 1\n  emitCounter(\"create_failure_total\", tags={code: classify(err)})\n  if pool.failureCount > pool.config.degradedThreshold:\n    pool.state = DEGRADED\n    backoff.bump() // exponential: min(maxBackoff, base * 2^n)\n\nfunction handleCreateSuccess(pool):\n  pool.failureCount = 0\n  if pool.state == DEGRADED:\n    pool.state = HEALTHY\n  backoff.reset()\n\nfunction withStateStoreRetry(op):\n  for attempt in 1..maxStoreRetries:\n    try:\n      return op()\n    catch e if isContention(e) or isTransientStoreError(e):\n      sleep(jitteredBackoff(attempt))\n  throw SandboxException(code=POOL_STATE_STORE_UNAVAILABLE)\n```\n\nRecovery model:\n- On repeated create failures: move to `DEGRADED`.\n- Use exponential backoff for create/replenish attempts.\n- Keep serving from existing idle when possible (validated by connect-on-acquire).\n- Return to `HEALTHY` after successful probes/creates.\n\n```mermaid\nsequenceDiagram\n    participant Pool\n    participant API as Lifecycle API\n    Pool->>API: create sandbox\n    API-->>Pool: 5xx / quota error\n    Pool->>Pool: mark DEGRADED + backoff\n    loop retry with backoff\n        Pool->>API: health check / create probe\n        API-->>Pool: still failing\n    end\n    Pool->>API: probe\n    API-->>Pool: success\n    Pool->>Pool: clear DEGRADED, resume replenish\n```\n\n### Observability\n\nMetrics and logs are emitted at SDK layer:\n\n- Gauges: `pool_idle`.\n- Timers: `acquire_latency_ms`, `create_latency_ms`.\n- Counters: `pool_exhausted_total`, `create_failure_total`, `direct_create_total`, `direct_create_failure_total`.\n- Structured logs include `pool_name`, `sandbox_id`, acquire policy, and state transitions.\n\n### Compatibility and Evolution\n\n- Existing `Sandbox.builder()` and `SandboxManager` flows remain unchanged.\n- Pool feature is opt-in and additive.\n- Single-node and distributed modes share the same SDK pool control logic and API.\n- Mode selection is implementation-driven via `PoolStateStore` injection.\n- SDK does not prescribe or bundle a specific distributed datastore backend.\n- All store records and coordination are isolated by `poolName`.\n- Runtime remains authoritative for hard capacity and quota limits.\n- State handling is forward-compatible: unknown backend lifecycle states are treated\n  conservatively (fallback to direct create on connect failure).\n- Pool adapts through lifecycle adapters rather than runtime-specific paths.\n\n## Test Plan\n\nTest plan includes:\n\n- Unit tests for state transitions and idle-buffer semantics.\n- Concurrency tests for `acquire` and replenish races under empty-idle conditions.\n- State-store contract tests (atomic idle-take, idempotent put/remove, pool scoping).\n- Reference in-memory store tests and user-store compliance test suite.\n- Idle TTL tests: fixed 24h expiry behavior for distributed TTL-backed stores and\n  in-memory `expiresAt` sweep/lazy eviction.\n- Acquire fallback tests: idle connect failure triggers stale-id cleanup and\n  direct-create fallback path.\n- Replenish boundedness tests: leader-only create path respects `warmupConcurrency`\n  and allows small best-effort overshoot under concurrent acquire/reconcile races.\n- Fault-injection tests for backend creation failures and timeouts.\n- Integration tests in local and remote environments.\n- Compatibility tests for non-pool SDK usage.\n- Soak tests for leak/retire correctness.\n\n## Drawbacks\n\n- Additional SDK complexity and maintenance overhead.\n- More caller-facing tuning knobs that can be misconfigured.\n- No implicit protection from backend quota misalignment.\n\n## Alternatives\n\n- Keep per-request sandbox creation only.\n- Build runtime-side pool controls into server APIs.\n- Provide best-effort caching without explicit acquire policies.\n\n## Infrastructure Needed\n\nNo new mandatory infrastructure is required. Optional benchmark and soak-test\nenvironments are recommended for tuning default pool parameters.\n\n## Upgrade & Migration Strategy\n\n- Backward compatible: existing SDK usage remains unchanged.\n- Pooling introduced as opt-in API.\n- Start with conservative defaults and iterative tuning guidance.\n"
  },
  {
    "path": "oseps/0006-developer-console.md",
    "content": "---\r\ntitle: Developer Console for Sandbox Operations\r\nauthors:\r\n  - \"@divyamagrawal06\"\r\ncreation-date: 2026-03-05\r\nlast-updated: 2026-03-06\r\nstatus: implementable\r\n---\r\n\r\n# OSEP-0006: Developer Console for Sandbox Operations with Phased Auth Model\r\n\r\n<!-- toc -->\r\n\r\n- [Summary](#summary)\r\n- [Motivation](#motivation)\r\n  - [Goals](#goals)\r\n  - [Non-Goals](#non-goals)\r\n- [Requirements](#requirements)\r\n- [Proposal](#proposal)\r\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\r\n  - [Risks and Mitigations](#risks-and-mitigations)\r\n- [Design Details](#design-details)\r\n  - [Current State](#current-state)\r\n  - [Phase 1 (MVP): Console + Server-Side RBAC Without DB](#phase-1-mvp-console--server-side-rbac-without-db)\r\n  - [Phase 2: OIDC/JWT + PostgreSQL RBAC and Audit](#phase-2-oidcjwt--postgresql-rbac-and-audit)\r\n  - [Role and Permission Model](#role-and-permission-model)\r\n  - [Ownership and Team Scoping Without Database](#ownership-and-team-scoping-without-database)\r\n  - [Server Changes](#server-changes)\r\n  - [Console Application Design](#console-application-design)\r\n  - [API and Spec Changes](#api-and-spec-changes)\r\n  - [Operational Rollout](#operational-rollout)\r\n- [Test Plan](#test-plan)\r\n- [Drawbacks](#drawbacks)\r\n- [Alternatives](#alternatives)\r\n- [Infrastructure Needed](#infrastructure-needed)\r\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\r\n<!-- /toc -->\r\n\r\n## Summary\r\n\r\nThis proposal is based on [#348](https://github.com/alibaba/OpenSandbox/issues/348), which outlines the need for a Developer Console for sandbox lifecycle operations with a phased auth model.\r\n\r\nThe idea is to add a `console/` web app for day-to-day sandbox management (list, create, renew, delete, get endpoint, filtering) and a server-side auth/authz layer that does not break existing API key automation.\r\n\r\nPhase 2:\r\nOIDC JWT validation, PostgreSQL RBAC bindings, and durable audit logs.\r\n\r\n## Motivation\r\n\r\nToday OpenSandbox exposes lifecycle APIs and Swagger docs, but developers/operators still need to manage sandbox resources via APIs. This creates friction for common workflows (search/create/renew/delete), weakens governance in multi-user environments, and raises onboarding cost for teams that are not API-first.\r\n\r\n- Server auth today is global API key only (`server/src/middleware/auth.py` with `OPEN-SANDBOX-API-KEY`).\r\n- Lifecycle operations already exist and are stable (`server/src/api/lifecycle.py`, `specs/sandbox-lifecycle.yml`).\r\n- Filtering by state/metadata already exists (`GET /sandboxes` and `matches_filter`).\r\n- Sandbox metadata already maps to labels in Docker/Kubernetes services and is returned in list/get responses.\r\n\r\nThis means a console can be built on top of what exists without touching core runtime behavior.\r\n\r\n### Goals\r\n\r\n1. Add a standalone React app under `console/` for sandbox lifecycle operations.\r\n2. Cover the MVP flows called out in [#348](https://github.com/alibaba/OpenSandbox/issues/348):\r\n   - list and detail views\r\n   - create sandbox from image + basic runtime options\r\n   - renew expiration, delete sandbox, get endpoint\r\n   - filtering by state and metadata\r\n3. Enforce role boundaries server-side (not only hidden in UI):\r\n   - `read_only` role for read operations\r\n   - `operator` role for mutating operations\r\n4. Keep existing API key automation and SDK behavior backward compatible.\r\n5. Ensure browser clients never receive server API keys.\r\n6. Ensure that it is feasible and easy to scale to Phase 2:\r\n   - OIDC login and JWT validation in server\r\n   - PostgreSQL-backed RBAC and durable audit events\r\n\r\n### Non-Goals\r\n\r\nPer the [issue discussion](https://github.com/alibaba/OpenSandbox/issues/348), the following are out of scope:\r\n\r\n1. Billing or chargeback portal.\r\n2. Approval workflows for every operation.\r\n3. Replacing existing SDK/CLI/API workflows.\r\n4. Changing Docker/Kubernetes runtime internals.\r\n5. Full enterprise IAM policy language in the MVP.\r\n\r\n## Requirements\r\n\r\n| ID  | Requirement                                                  | Priority    |\r\n| --- | ------------------------------------------------------------ | ----------- |\r\n| R1  | Console users use core lifecycle operations from UI          | Must Have   |\r\n| R2  | Role-based authorization on server for each lifecycle action | Must Have   |\r\n| R3  | Existing `OPEN-SANDBOX-API-KEY` flow continues unchanged     | Must Have   |\r\n| R4  | No server API key is exposed to browser code                 | Must Have   |\r\n| R5  | Phase 1 works without introducing a database                 | Must Have   |\r\n| R6  | Ownership/team scoping via existing metadata/labels          | Should Have |\r\n| R7  | OIDC JWT validation and PostgreSQL RBAC/audit in Phase 2     | Should Have |\r\n\r\n## Proposal\r\n\r\nFollowing the phased strategy suggested in [#348](https://github.com/alibaba/OpenSandbox/issues/348) (\"Ship MVP fast, no DB, validate usage and workflows\"):\r\n\r\n1. **Phase 1 (MVP)**:\r\n   - Add a `console/` React app.\r\n   - Add a user-auth path in server (config-gated) suitable for console access without API keys in browser.\r\n   - Add authorization checks on lifecycle operations.\r\n   - Add metadata-based scoping using reserved metadata keys for owner/team.\r\n   - Emit audit logs for mutating operations.\r\n   - No new database dependency.\r\n\r\n2. **Phase 2 (Hardening)**:\r\n   - Add OIDC JWT validation in server.\r\n   - Add PostgreSQL tables for RBAC bindings and audit events.\r\n   - Add richer operational UX (bulk safeguards, failure insights).\r\n\r\n```mermaid\r\nflowchart LR\r\n    A[Developer Browser] --> B[console React App]\r\n    B --> C[OpenSandbox Server]\r\n    C --> D[Lifecycle Service Layer]\r\n    D --> E[Docker/Kubernetes Runtime]\r\n```\r\n\r\n```mermaid\r\nflowchart LR\r\n    A[SDK/Automation] -->|OPEN-SANDBOX-API-KEY| C[OpenSandbox Server]\r\n    B[Console User] -->|User Auth Path| C\r\n    C --> D[AuthN + AuthZ Enforcement]\r\n    D --> E[Lifecycle Operations]\r\n```\r\n\r\n### Notes/Constraints/Caveats\r\n\r\n1. Metadata values must satisfy label constraints already enforced in `ensure_metadata_labels`; owner/team values require canonicalization.\r\n2. Kubernetes runtime currently does not support pause/resume (`501`); console must reflect runtime capability.\r\n3. API key requests remain privileged for backward compatibility.\r\n4. Phase 1 audit is log-based (non-durable). Durable queryable audit is planned for Phase 2.\r\n\r\n### Risks and Mitigations\r\n\r\n| Risk                                                    | Impact                      | Mitigation                                                                       |\r\n| ------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------- |\r\n| Scope creep from simple console into full control plane | Delivery delay              | Strict phase gates; MVP only core operations                                     |\r\n| Header-spoofing if pre-auth mode is misconfigured       | Security                    | Config-gated user auth mode, trusted deployment guidance, Phase 2 JWT validation |\r\n| Metadata-based scoping collisions                       | Authorization bugs          | Reserve keys for access control and enforce server-side overwrite rules          |\r\n| Claim values incompatible with label format             | Provisioning/authz mismatch | Canonicalization to label-safe owner/team tokens                                 |\r\n| Breaking API automation                                 | Adoption risk               | Keep API key path as-is; add compatibility tests                                 |\r\n| Lack of durable audit in MVP                            | Governance gap              | Structured mutation logs in Phase 1 + Phase 2 audit table plan                   |\r\n\r\n## Design Details\r\n\r\n### Current State\r\n\r\nQuick summary of the relevant server code as it stands today:\r\n\r\n- Auth middleware: API key only (`server/src/middleware/auth.py`, header `OPEN-SANDBOX-API-KEY`).\r\n- Lifecycle routes: `server/src/api/lifecycle.py`.\r\n- Service implementations: `server/src/services/docker.py` (Docker), `server/src/services/k8s/kubernetes_service.py` (Kubernetes).\r\n- Filtering: `state` and `metadata` filters in route parsing, `matches_filter` helper.\r\n- Metadata: already stored as Docker/Kubernetes labels.\r\n\r\nThere is no database for RBAC or audit today.\r\n\r\n### Phase 1 (MVP): Console + Server-Side RBAC Without DB\r\n\r\n1. Standalone React + TypeScript app under `console/`.\r\n2. Config-gated user-auth mode on the server (no API key in the browser).\r\n3. Authorization checks in the lifecycle API path.\r\n4. Reuse metadata labels for owner/team scoping.\r\n5. Structured audit logs for mutations (create, delete, renew, etc.).\r\n\r\n### Phase 2 (Hardening): OIDC/JWT + PostgreSQL RBAC and Audit\r\n\r\n1. Validate OIDC-issued JWT in server (issuer, audience, signature/JWKS, exp/nbf).\r\n2. Replace static role mapping with PostgreSQL RBAC bindings.\r\n3. Persist mutation audit events in PostgreSQL.\r\n4. Add query APIs for audit and governance.\r\n\r\n### Role and Permission Model\r\n\r\nThree roles, matching the separation called for in [#348](https://github.com/alibaba/OpenSandbox/issues/348):\r\n\r\n- `read_only`: list/get/get endpoint.\r\n- `operator`: read_only + create/renew/delete (+ pause/resume where runtime supports).\r\n- `service_admin`: API key automation role with full access (compatibility role).\r\n\r\nHere's a table for ref:\r\n\r\n| Endpoint                                | read_only | operator | service_admin |\r\n| --------------------------------------- | --------- | -------- | ------------- |\r\n| `GET /sandboxes`                        | yes       | yes      | yes           |\r\n| `GET /sandboxes/{id}`                   | yes       | yes      | yes           |\r\n| `GET /sandboxes/{id}/endpoints/{port}`  | yes       | yes      | yes           |\r\n| `POST /sandboxes`                       | no        | yes      | yes           |\r\n| `POST /sandboxes/{id}/renew-expiration` | no        | yes      | yes           |\r\n| `DELETE /sandboxes/{id}`                | no        | yes      | yes           |\r\n| `POST /sandboxes/{id}/pause`            | no        | yes      | yes           |\r\n| `POST /sandboxes/{id}/resume`           | no        | yes      | yes           |\r\n\r\n### Ownership and Team Scoping Without Database\r\n\r\nPhase 1 scope source:\r\n\r\n- `metadata[\"access.owner\"]`\r\n- `metadata[\"access.team\"]`\r\n\r\nHow it works:\r\n\r\n1. On create, server injects/overwrites reserved scope metadata from authenticated principal.\r\n2. Non-admin users can only act on resources within their owner/team scope.\r\n3. `service_admin` bypasses scope checks.\r\n4. Existing user-provided metadata remains supported, but reserved keys are server-controlled.\r\n\r\nCanonicalization:\r\n\r\n- Principal identifiers from user auth claims/headers are transformed into label-safe tokens (length and charset compatible with existing metadata-label validators).\r\n- Canonicalization must be deterministic to keep scope matching stable across requests.\r\n\r\n### Server Changes\r\n\r\n#### 1. Configuration\r\n\r\nExtend `server/src/config.py` with auth/authz sections.\r\n\r\n`auth.mode` controls high-level authentication behavior:\r\n\r\n- `\"api_key_only\"`: current behavior; only `OPEN-SANDBOX-API-KEY` auth is accepted.\r\n- `\"api_key_and_user\"`: dual path; API key auth remains for SDK/automation, and user-authenticated requests are also accepted for Console access.\r\n\r\n`user_mode` controls how user identity is extracted when `auth.mode = \"api_key_and_user\"`:\r\n\r\n- Phase 1 supports `\"trusted_header\"` only.\r\n- When `auth.mode = \"api_key_only\"`, `user_mode` is ignored.\r\n\r\n```toml\r\n[auth]\r\n# Allowed values:\r\n# - \"api_key_only\"\r\n# - \"api_key_and_user\"\r\nmode = \"api_key_only\"\r\n\r\n# Used only when auth.mode = \"api_key_and_user\".\r\n# Phase 1 supports \"trusted_header\".\r\nuser_mode = \"trusted_header\"\r\n\r\n[auth.trusted_header]\r\n# Used when user_mode = \"trusted_header\".\r\nuser_header = \"X-OpenSandbox-User\"\r\nteam_header = \"X-OpenSandbox-Team\"\r\nroles_header = \"X-OpenSandbox-Roles\"\r\n\r\n[authz]\r\ndefault_role = \"read_only\"\r\nowner_metadata_key = \"access.owner\"\r\nteam_metadata_key = \"access.team\"\r\noperator_subjects = []\r\nread_only_subjects = []\r\n```\r\n\r\nTrusted-header failure behavior (Phase 1):\r\n\r\n1. Applies when `auth.mode = \"api_key_and_user\"` and `user_mode = \"trusted_header\"`.\r\n2. Requests on the user-auth path that are missing required trusted identity headers are treated as unauthenticated and rejected with `401 Unauthorized`.\r\n3. The server must NOT fall back to anonymous/default user access when trusted headers are missing.\r\n4. The server must NOT silently switch to another auth path UNLESS that credential is explicitly provided (for example, `OPEN-SANDBOX-API-KEY` for API key auth).\r\n\r\nPhase 2 adds:\r\n\r\n```toml\r\n[auth.oidc]\r\nissuer = \"https://accounts.google.com\"           # or any OIDC provider\r\naudience = \"opensandbox-console\"\r\njwks_url = \"https://www.googleapis.com/oauth2/v3/certs\"\r\n```\r\n\r\n#### 2. Authentication Middleware\r\n\r\nChanges to `server/src/middleware/auth.py`:\r\n\r\n1. Preserve current API key path exactly.\r\n2. Add user principal extraction path (phase-gated by config).\r\n3. Attach normalized principal to `request.state.principal`.\r\n4. If trusted-header mode is active and required headers are missing, return `401 Unauthorized` (unauthenticated), not `403` (authenticated but forbidden).\r\n5. Keep proxy path exemptions behavior unchanged for sandbox proxy route.\r\n\r\n#### 3. Authorization Enforcement\r\n\r\nNew module `server/src/middleware/authorization.py` with a single entry point:\r\n\r\n- `authorize_action(principal, action, sandbox=None)`.\r\n- Scope checks for owner/team.\r\n\r\nIntegrate into `server/src/api/lifecycle.py` per route before invoking mutating service operations.\r\n\r\nFor list operations:\r\nApply server-side scope filter in addition to client-provided filters.\r\n\r\nFor get/delete/renew/endpoint:\r\nResolve sandbox resource and evaluate scope before action.\r\n\r\n#### 4. Mutation Audit Logging (Phase 1)\r\n\r\nFor mutating actions, log:\r\n\r\n- request_id\r\n- principal subject/team/role\r\n- action\r\n- sandbox_id\r\n- outcome (success/error code)\r\n- timestamp\r\n\r\nThis extends existing request-id logging without DB dependency.\r\n\r\n### Console Application Design\r\n\r\nStandalone React app living under `console/`. Pages map directly to the MVP scope from [#348](https://github.com/alibaba/OpenSandbox/issues/348):\r\n\r\n1. **Sandbox List:** state + metadata filters, pagination.\r\n2. **Sandbox Detail:** status, metadata, image, entrypoint, expiration.\r\n3. **Create Sandbox:** image, entrypoint, timeout, resource limits, env vars, metadata.\r\n4. **Operations:** renew expiration, delete, endpoint retrieval.\r\n\r\nThe UI should disable buttons the user's role cannot use (e.g., hide \"Create\" for `read_only`), but the server is always the final authority. The browser only uses the user-auth path; the API key is never shipped in frontend code.\r\n\r\nIf Console requests are rejected with `401` because trusted headers are missing, the Console should render an explicit \"authentication required / auth proxy misconfiguration\" state instead of retrying with anonymous assumptions.\r\n\r\n### API and Spec Changes\r\n\r\nPrimary lifecycle endpoints MUST remain unchanged.\r\n\r\nUpdates to `specs/sandbox-lifecycle.yml`:\r\n\r\n1. Document dual auth path (API key + user auth mode).\r\n2. Add `401` responses for unauthenticated user-auth requests (including trusted-header mode with missing required headers).\r\n3. Add `403` responses where role restrictions apply (e.g., create/renew/delete).\r\n4. Clarify reserved metadata keys used for ownership/team scoping.\r\n5. Add error codes for authentication and authorization failures.\r\n\r\n### Operational Rollout\r\n\r\n1. Phase 1 stays behind a config flag (`auth.mode = \"api_key_and_user\"`).\r\n2. Deploy console + updated server in a non-prod environment first.\r\n3. Validate role boundaries and scope filtering.\r\n4. Phase 2 switches to OIDC JWT mode and runs PostgreSQL migrations.\r\n\r\n## Test Plan\r\n\r\n### Unit Tests\r\n\r\n1. Auth middleware:\r\n   - API key success/failure unchanged.\r\n   - user principal extraction in enabled mode.\r\n   - dual-mode conflict behavior.\r\n   - trusted-header mode rejects missing required headers with `401`.\r\n2. Authorization logic:\r\n   - Each action against the role permission table.\r\n   - Owner/team scope checks (allow and deny cases).\r\n   - Reserved metadata injection on create.\r\n3. Canonicalization:\r\n   - deterministic label-safe owner/team tokens.\r\n\r\n### Integration Tests (Server)\r\n\r\n1. Route-level authz:\r\n   - `read_only` can list/get/endpoint; gets 403 on create/renew/delete.\r\n   - `operator` can do all MVP operations.\r\n2. Backward compat:\r\n   - Existing API key clients work exactly as before.\r\n3. Scope filtering:\r\n   - Users only see sandboxes matching their owner/team.\r\n4. Runtime parity:\r\n   - Scoped list/get/delete/renew behaves the same on Docker and Kubernetes.\r\n5. Trusted-header deployment behavior:\r\n   - direct Console-to-server request without proxy-injected headers returns `401`.\r\n   - proxy misconfiguration (one or more missing identity headers) returns `401`.\r\n\r\n### Console Tests\r\n\r\n1. Page-level API integration tests for list/detail/create/renew/delete/endpoint flows.\r\n2. Role-based UX tests (buttons disabled/hidden for read_only).\r\n3. E2E smoke path from login context to sandbox operations.\r\n\r\n### Phase 2 Tests\r\n\r\n1. JWT signature and claim validation tests.\r\n2. PostgreSQL RBAC lookup tests.\r\n3. Durable audit write/read tests.\r\n\r\n## Drawbacks\r\n\r\n1. A second auth path in the server means more code to maintain and more surface to test.\r\n2. Metadata-based scoping (Phase 1) is less flexible than a proper DB-backed policy.\r\n3. Adding a React app introduces a frontend build/release cycle into the repo.\r\n4. Durable audit and richer RBAC are punted to Phase 2.\r\n\r\n## Alternatives\r\n\r\n### Alternative 1: Keep API-only (no console)\r\n\r\nPros:\r\n\r\n- Zero frontend maintenance.\r\n- No auth model changes.\r\n\r\nCons:\r\n\r\n- Does not address operator efficiency and onboarding needs.\r\n\r\nDecision: Rejected as it does not solve the efficiency and onboarding problems raised in [#348](https://github.com/alibaba/OpenSandbox/issues/348)\r\n\r\n### Alternative 2: Implement Full OIDC + DB in One Phase\r\n\r\nPros:\r\n\r\n- Strongest model from day one.\r\n\r\nCons:\r\n\r\n- Larger scope, slower delivery, higher integration risk.\r\n\r\nDecision: Rejected in favor of phased delivery, as mentioned in the issue.\r\n\r\n### Alternative 3: Expose the API key to the browser\r\n\r\nWould need almost no server changes, but leaks the global API key to every console user and gives up per-user governance entirely. Rejected.\r\n\r\n## Infrastructure Needed\r\n\r\nPhase 1:\r\n\r\n- Node.js (for building/testing the `console/` app).\r\n- Existing OpenSandbox server runtime (Docker or Kubernetes).\r\n- If using trusted-header mode: a reverse proxy (e.g., Nginx, Envoy) that sets the identity headers after authenticating the user.\r\n\r\nPhase 2:\r\n\r\n- An OIDC provider (e.g., Google, Keycloak, Auth0).\r\n- PostgreSQL instance for RBAC bindings and audit events.\r\n- A schema migration tool (e.g., Alembic).\r\n\r\n## Upgrade & Migration Strategy\r\n\r\n1. Backward compatibility is preserved by default:\r\n   - `auth.mode = \"api_key_only\"` keeps existing behavior.\r\n2. User auth path is opt-in through configuration.\r\n3. Existing SDK/automation clients continue using `OPEN-SANDBOX-API-KEY`.\r\n4. Enabling console/user auth does not require lifecycle API contract breaks.\r\n5. Phase 2 DB migrations are additive:\r\n   - static config role mapping can remain as fallback during cutover.\r\n"
  },
  {
    "path": "oseps/0007-fast-sandbox-runtime-support.md",
    "content": "---\ntitle: Fast Sandbox Runtime Support\nauthors:\n  - \"@fengcone\"\ncreation-date: 2026-02-08\nlast-updated: 2026-02-08\nstatus: provisional\n---\n# OSEP-0007: Fast Sandbox Runtime Support\n\n<!-- toc -->\n\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Why Fast-Sandbox is Fast](#why-fast-sandbox-is-fast)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [How Fast-Sandbox Achieves Millisecond-Scale Latency](#how-fast-sandbox-achieves-millisecond-scale-latency)\n  - [Kubernetes Ecosystem Integration](#kubernetes-ecosystem-integration)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n\n<!-- /toc -->\n\n## Summary\n\nAdd first-class support for [fast-sandbox](https://github.com/fengcone/fast-sandbox) as a high-performance runtime backend for OpenSandbox. By leveraging fast-sandbox's gRPC Fast-Path API and pre-warmed Agent pools, OpenSandbox can achieve **millisecond-scale cold start latency** (compared to ~1 second with OpenSandbox's BatchSandbox pool, or 2-5 seconds with standard K8s runtime) for AI Agents, Serverless functions, and other latency-sensitive workloads while maintaining the existing SDK and API contract.\n\n**Performance Characteristics** (with cached images on Agent nodes):\n\n- **Fast Mode**: <50ms (container-first, async CRD)\n- **Strong Mode**: ~50-100ms base + K8s API write latency (typically 20-50ms via etcd)\n\n> **Note**: The millisecond-scale latency assumes the container image is already cached on the Agent's host node. Cold starts with uncached images incur additional image pull time.\n\n## Motivation\n\nOpenSandbox currently supports Docker and Kubernetes runtimes. While the Kubernetes runtime provides scalability, sandbox creation typically takes 2-5 seconds due to:\n\n- K8s scheduler latency (~100-500ms)\n- etcd write and watch propagation (~50-200ms)\n- Kubelet pod creation and container runtime startup (~1-3s)\n- Image pull when cache miss occurs (~1-10s)\n\n### OpenSandbox's Existing Pool Optimization\n\nOpenSandbox's Kubernetes runtime already supports a **pool-based optimization** via the `poolRef` field in BatchSandbox CRD. When `poolRef` is specified:\n\n```yaml\napiVersion: sandbox.opensandbox.io/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: my-sandbox\nspec:\n  poolRef: my-pool              # Reference to pre-warmed pool\n  taskTemplate:\n    spec:\n      process:\n        command: [\"python\", \"app.py\"]\n```\n\n**How it works**:\n\n- Users create a pool of pre-provisioned pods (managed by BatchSandbox controller)\n- When creating a sandbox, OpenSandbox assigns a task from the pool\n- Only `entrypoint` and `env` are customizable; image and resources are pre-defined\n- Controller and OpenSandbox Server watch K8s API for state changes\n\n**Performance with pool** (measured):\n\n- Approximately **1 second** latency for pool-based allocation\n- Eliminates scheduler wait and pod startup time\n- Still requires K8s API write + watch propagation overhead\n- Image must be pre-pulled in pool pods\n\nThis is an effective optimization for many use cases. However, fast-sandbox aims to push latency even lower through additional innovations described below.\n\nFor AI Agent and Serverless scenarios that require rapid sandbox provisioning, reducing even the K8s API overhead is valuable.\n\n### Why Fast-Sandbox is Fast\n\nfast-sandbox achieves millisecond-scale cold start through three key design innovations:\n\n**Comparison: OpenSandbox Pool vs fast-sandbox**\n\n\n| Aspect                          | OpenSandbox BatchSandbox Pool                      | fast-sandbox                                |\n| ------------------------------- |----------------------------------------------------|---------------------------------------------|\n| **Allocation mechanism**        | K8s API write → Controller watch → Task assignment | gRPC → in-memory Registry → Agent HTTP      |\n| **Latency (with cached image)** | ~1 second (measured)                               | <50ms Fast, ~50 + API write (Strong)        |\n| **Scheduling**                  | K8s Scheduler places pool pods (one-time)          | In-memory Registry with image affinity      |\n| **Image awareness**             | Pool pods have fixed image                         | Registry scores by image cache availability |\n| **Customization**               | entrypoint, env only                               | entrypoint, env, image, ports per request   |\n| **Container creation**          | pre-warmed                                         | Direct containerd socket                    |\n| **Consistency**                 | Strong (K8s etcd)                                  | Fast (eventual) or Strong (K8s etcd)        |\n| **Failure recovery**            | K8s Controller reconciliation                      | Node Janitor + AutoRecreate policy          |\n\nBoth approaches use pre-provisioned resource pools to eliminate cold start overhead. fast-sandbox's key advantage is bypassing the K8s API path for container creation while maintaining visibility through async CRD writes.\n\n#### 1. Direct API Allocation, Bypassing K8s Control Plane\n\nTraditional K8s sandbox creation follows the slow path:\n\n```\nClient → K8s API Server → etcd → Scheduler → etcd → Kubelet → Container Runtime\n (~2-5 seconds total)\n```\n\nfast-sandbox uses a gRPC Fast-Path API that bypasses the K8s control plane:\n\n**Fast Mode** (image cached on Agent node):\n\n```\nClient → gRPC Fast-Path → Registry (in-memory) → Agent HTTP → Containerd (<50ms)\n```\n\n**Strong Mode** (image cached on Agent node):\n\n```\nClient → gRPC Fast-Path → K8s API → etcd → Registry (in-memory) → Agent HTTP → Containerd\n       ( <50ms base + 20-50ms API write)\n```\n\n**With uncached image** (both modes): Additional image pull time applies.\n\nThe Controller maintains an **in-memory Registry** for scheduling, eliminating:\n\n- etcd write/read latency\n- scheduler queue wait time\n- watch propagation delays\n\nThis is similar to how \"burst\" instances work in cloud providers - resources are pre-provisioned and allocation happens at memory speed.\n\n#### 2. In-Memory Scheduling with Image Affinity\n\nfast-sandbox's Registry implements a smart scheduling algorithm:\n\n```\nscore = allocated_count + (image_not_cached ? 1000 : 0)\n```\n\nKey characteristics:\n\n- **In-memory allocation**: No disk I/O, no database queries (~1ms for 100 agents)\n- **Image affinity scoring**: Prioritizes agents with cached images\n- **Atomic slot management**: Avoids port conflicts through pre-reserved slots\n- **Zero image pull latency**: When image is cached (common case), container starts immediately\n\nThis is fundamentally different from K8s scheduler which:\n\n- Runs as a separate process with IPC overhead\n- Doesn't track image cache state\n- Schedules pods without considering image availability\n\n#### 3. Kubernetes Ecosystem Reuse with Direct Containerd Access\n\nfast-sandbox achieves speed while maintaining K8s compatibility:\n\n\n| Aspect                     | fast-sandbox Approach                                          | K8s Benefit                               |\n| -------------------------- | -------------------------------------------------------------- | ----------------------------------------- |\n| **Resource Accounting**    | Agent Pods tracked in K8s                                      | Resource visibility via`kubectl get pods` |\n| **Scheduling Constraints** | Node selectors, taints, tolerations via K8s                    | K8s scheduler places Agent Pods optimally |\n| **Container Creation**     | Direct containerd socket access (bypasses kubelet)             | <10ms container creation vs ~500ms        |\n| **Security Containers**    | Supports gVisor/Kata Containers via containerd runtime handler | Same workflow, different runtime class    |\n| **Network Namespace**      | Reuses Agent Pod's network namespace                           | K8s CNI plugins work transparently        |\n\nThe key insight: **use K8s for what it's good at** (resource accounting, cluster management, scheduling constraints), but **bypass K8s for the hot path** (container creation).\n\n### Goals\n\n- Support creating, querying, and terminating sandboxes backed by fast-sandbox via the OpenSandbox server API\n- Preserve existing OpenSandbox SDK and API behavior - no breaking changes\n- Enable sub-100ms sandbox creation latency (strong consistency mode, with cached image) or sub-50ms (fast mode, with cached image)\n- Support both Fast (ultra-low latency, eventual consistency) and Strong (guaranteed consistency) modes\n- Provide flexible deployment: users can bring their own fast-sandbox or use OpenSandbox-provided charts\n\n### Non-Goals\n\n- Replacing or removing existing Docker or Kubernetes runtimes\n- Implementing a full Kubernetes operator for fast-sandbox (it has its own controller)\n- Changing the OpenSandbox sandbox lifecycle API or SDKs in a breaking way\n- Direct management of fast-sandbox Agent Pods (handled by fast-sandbox controller)\n\n## Requirements\n\n- Must use the existing OpenSandbox lifecycle API and SDKs without breaking changes\n- Must support OpenSandbox's execd-based command execution and file operations\n- Must integrate with OpenSandbox's ingress component for routing\n- Must support the standard OpenSandbox configuration model\n- Must handle status mapping between fast-sandbox and OpenSandbox states\n\n## Proposal\n\nIntroduce a `fast-sandbox` workload provider implementation that communicates directly with the fast-sandbox Controller via gRPC Fast-Path API. The provider is exposed as a new option under the Kubernetes runtime (`kubernetes.workload_provider = \"fast-sandbox\"`).\n\n**Architecture Overview**:\n\n```\n+-------------------------------------------------------------------------+\n|                        OpenSandbox Control Plane                        |\n+-------------------------------------------------------------------------+\n|                                                                         |\n|   +--------------+    gRPC Fast-Path (9090)    +---------------------+  |\n|   | OpenSandbox  | ------------------------>   |  fast-sandbox       |  |\n|   |   Server     | <-------------------------  |  Controller         |  |\n|   |              |    endpoints (IP:Port)      |                     |  |\n|   +------+-------+                             +-------+-------------+  |\n|          |                                             |                |\n|          | SDK                                         | Registry       |\n|          |                                             | (in-memory)    |\n|          v                                             v                |\n|   +--------------+    HTTP (5758)             +----------------------+  |\n|   | OpenSandbox  | ---------------------->    |  Agent Pods          |  |\n|   |  SDK         |    execd (44772)           |  (K8s Managed)       |  |\n|   +--------------+                            +----------+-----------+  |\n|                                                        |                |\n|                                                        | containerd     |\n|                                                        v                |\n|                                                 +----------------+      |\n|                                                 | User Container |      |\n|                                                 | with execd     |      |\n|                                                 +----------------+      |\n|                                                                         |\n+-------------------------------------------------------------------------+\n                                ^\n                                | K8s API Server (for Agent Pod mgmt only)\n                                |\n+-------------------------------------------------------------------------+\n|                    Kubernetes Control Plane (async path)                |\n|  - Agent Pod lifecycle (create/monitor/delete)                          |\n|  - Resource accounting (CPU/memory requests visible in kubectl)         |\n|  - Scheduling constraints (node selectors, taints, tolerations)         |\n+-------------------------------------------------------------------------+\n```\n\n**Data Flow Comparison** (assuming cached image):\n\n```\nStandard K8s Runtime:\nOpenSandbox Server → K8s API → etcd → Scheduler → etcd → Kubelet → containerd\n      (2-5 seconds)\n\nFast-Sandbox Runtime (Fast Mode):\nOpenSandbox Server → gRPC Fast-Path → Registry → Agent HTTP → containerd\n      (<50ms, async CRD)\n\nFast-Sandbox Runtime (Strong Mode):\nOpenSandbox Server → gRPC Fast-Path → K8s API → etcd → Watch → Agent → containerd\n      (~50-100ms base + 20-50ms API write)\n```\n\n### Notes/Constraints/Caveats\n\n- The fast-sandbox Controller and Agent Pods must be deployed separately (either by the user or via OpenSandbox-provided Helm charts)\n- fast-sandbox uses its own CRD types (`Sandbox`, `SandboxPool`) for resource pool management - OpenSandbox does not manipulate these directly\n- gRPC communication requires network reachability from OpenSandbox Server to fast-sandbox Controller\n- The execd binary must be present in sandbox containers (typically via image or init container)\n\n### Risks and Mitigations\n\n\n| Risk                                                                     | Mitigation                                                                                                                         |\n| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |\n| fast-sandbox Controller becomes a single point of failure                | fast-sandbox Controller is designed for high availability; OpenSandbox can implement retries with fallback to standard K8s runtime |\n| gRPC API changes in fast-sandbox could break integration                 | Version pinning in deployment; compatibility matrix documentation                                                                  |\n| Network partition between OpenSandbox Server and fast-sandbox Controller | Configurable timeouts; health check endpoint integration                                                                           |\n| State drift if sandboxes are managed outside OpenSandbox                 | OpenSandbox tracks sandbox IDs; periodic state reconciliation via gRPC GetSandbox                                                  |\n| Fast mode orphaned containers                                            | fast-sandbox's Node Janitor DaemonSet cleans up orphaned resources                                                                 |\n\n## Design Details\n\n### How Fast-Sandbox Achieves Millisecond-Scale Latency\n\nThe fast-sandbox architecture is built around three performance-critical design choices:\n\n#### 1. Bypassing K8s Control Plane for Hot Path\n\n```\n┌──────────────────────────────────────────────────────────────────────────┐\n│                    Fast Mode Creation Flow (image cached)                │\n├──────────────────────────────────────────────────────────────────────────┤\n│                                                                          |\n│  Prerequisite: Image is cached on Agent's host node (via containerd)     │\n│                                                                          |\n│  1. OpenSandbox Server → gRPC CreateSandbox request                      │\n│     └─────────────────────────────────────────────────> ~1ms             │\n│                                                                          |\n│  2. Registry.Allocate() - in-memory scheduling                           │\n│     └─────────────────────────────────────────────────> ~1ms             │\n│     • Filter by pool, namespace, capacity, port conflicts                │\n│     • Score by: allocated + (no_image_cache ? 1000 : 0)                  │\n│     • Atomic mutex-based allocation                                      │\n│                                                                          |\n│  3. Controller → Agent HTTP POST /api/v1/agent/create                    │\n│     └─────────────────────────────────────────────────> ~10-30ms         │\n│                                                                          |\n│  4. Agent → containerd.Create() with cached image                        │\n│     └─────────────────────────────────────────────────> ~5-10ms          │\n│     • Direct socket access to host containerd                            │\n│     • No image pull (cached)                                             │\n│     • Reuse Agent Pod's network namespace                                │\n│                                                                          |\n│  5. Controller returns response with endpoints                           │\n│     <───────────────────────────────────────────────── ~1ms              │\n│                                                                          |\n│  Total: <50ms (end-to-end, with cached image)                            │\n│                                                                          |\n│  (Async: Controller creates K8s CRD for reconciliation/audit trail)      │\n│                                                                          |\n│  If image is NOT cached: Image pull time is added to step 4              │\n└──────────────────────────────────────────────────────────────────────────┘\n```\n\nCompare to standard K8s:\n\n```\n1. API Server write to etcd              ~20ms\n2. Scheduler watch and decision          ~100-500ms\n3. Scheduler write to etcd               ~20ms\n4. Kubelet watch and pod creation        ~50-200ms\n5. Container runtime start               ~500ms-3s\n6. Image pull (if cache miss)            ~1-10s\nTotal: 2-5s (best case, cache hit)\n```\n\n#### 2. Registry Scheduling Algorithm\n\n```go\n// From fast-sandbox internal/controller/agentpool/registry.go (simplified)\n\nfunc Allocate(sandbox *Sandbox) (*AgentInfo, error) {\n    bestSlot := nil\n    minScore := 1000000\n\n    for _, agent := range registry.agents {\n        // Skip if pool/namespace mismatch or at capacity\n        if agent.PoolName != sandbox.PoolRef ||\n           agent.Namespace != sandbox.Namespace ||\n           agent.Allocated >= agent.Capacity {\n            continue\n        }\n        // Check port conflicts \n\t\tif contains(agent.UsedPorts, sandbox.ExposedPorts) {\n\t\t\tcontinue\n        }\n        // Score: prefer lower allocation + cached image\n        score := agent.Allocated\n        if !contains(agent.Images, sandbox.Image) {\n            score += 1000  // Heavy penalty for uncached image\n        }\n\n        if score < minScore {\n            minScore = score\n            bestSlot = agent\n        }\n    }\n\n    return bestSlot, nil\n}\n```\n\n**Performance characteristics** (from fast-sandbox benchmarks):\n\n- 100 Agents: ~1.3ms allocation time\n- 1000 Agents: ~14ms allocation time\n\n#### 3. Direct Containerd Integration\n\nAgent Pods run with privileged access to host containerd socket:\n\n```go\n// From fast-sandbox internal/agent/runtime/containerd_runtime.go\n\nclient, _ := containerd.New(\"/run/containerd/containerd.sock\",\n    containerd.WithDefaultNamespace(\"k8s.io\"))\n\n// Direct container creation - bypasses kubelet entirely\ncontainer, _ := client.NewContainer(\n    ctx,\n    sandboxID,\n    containerd.WithImage(image),           // Already cached\n    containerd.WithNewSnapshot(...),       // Instant with cache\n    containerd.WithRuntime(\"runc\", nil),   // Or \"io.containerd.runsc.v2\" for gVisor\n)\n\ntask, _ := container.NewTask(ctx, cio.NewCreator(...))\ntask.Start(ctx)\n```\n\nThis approach:\n\n- Eliminates kubelet API overhead (~50-200ms)\n- Enables image cache reuse (Agent Pod shares node's containerd image store)\n- Supports alternative runtimes (gVisor, Kata Containers) via runtime handler\n\n### Kubernetes Ecosystem Integration\n\nDespite bypassing the K8s control plane for the hot path, fast-sandbox maintains full compatibility:\n\n#### Resource Accounting via K8s Pods\n\nAgent Pods are normal K8s Pods:\n\n```yaml\napiVersion: v1\nkind: Pod\nmetadata:\n  name: fast-sandbox-agent-node-1\n  labels:\n    app: fast-sandbox-agent\n    pool-ref: default-pool\nspec:\n  containers:\n  - name: agent\n    image: fast-sandbox/agent:latest\n    resources:\n      requests:\n        cpu: \"2000m\"\n        memory: \"4Gi\"\n      limits:\n        cpu: \"4000m\"\n        memory: \"8Gi\"\n    volumeMounts:\n    - name: containerd-socket\n      mountPath: /run/containerd/containerd.sock\n  volumes:\n  - name: containerd-socket\n    hostPath:\n      path: /run/containerd/containerd.sock\n```\n\nThese Pods are visible in `kubectl get pods` and count against:\n\n- Node resource allocation (visible to cluster autoscaler)\n- Resource quotas (namespace limits enforced)\n- Scheduler decisions (node affinity, taints, tolerations)\n\n#### CRD for Reconciliation and Auditing\n\nfast-sandbox defines two CRDs:\n\n```yaml\n# SandboxPool - manages Agent Pod lifecycle\napiVersion: sandbox.fast.io/v1alpha1\nkind: SandboxPool\nmetadata:\n  name: default-pool\n  namespace: default\nspec:\n  capacity:\n    poolMin: 2\n    poolMax: 10\n    bufferMin: 1\n    bufferMax: 3\n  maxSandboxesPerPod: 5\n  runtimeType: container           # or \"gvisor\" for secure containers\n  agentTemplate:\n    spec:\n      containers:\n      - name: agent\n        image: fast-sandbox/agent:latest\n        imagePullPolicy: IfNotPresent\n        env:\n        - name: AGENT_CAPACITY\n          value: \"5\"\n        volumeMounts:\n        - name: containerd-socket\n          mountPath: /run/containerd/containerd.sock\n      volumes:\n      - name: containerd-socket\n        hostPath:\n          path: /run/containerd/containerd.sock\n\n---\n# Sandbox - audit trail for sandbox creation\napiVersion: sandbox.fast.io/v1alpha1\nkind: Sandbox\nmetadata:\n  name: my-sandbox\n  namespace: default\n  labels:\n    sandbox.fast.io/created-by: fastpath-fast  # or fastpath-strong\nspec:\n  image: python:3.11\n  poolRef: default-pool\n  command: [\"python\", \"-m\", \"http.server\", \"8000\"]\n  exposedPorts: [8000]\n  failurePolicy: AutoRecreate         # or \"Manual\"\n  recoveryTimeoutSeconds: 60\nstatus:\n  phase: Running\n  sandboxID: abc123...               # Actual container ID\n  assignedPod: fast-sandbox-agent-node-1\n  nodeName: node-1\n  endpoints:\n  - \"10.244.1.5:8000\"\n```\n\nThese CRDs serve as:\n\n- **Audit trail**: Reconciliation between gRPC state and K8s\n- **Self-healing**: Controller can detect and clean up orphaned sandboxes\n- **Observability**: Standard K8s tools (kubectl, metrics-server) work\n\n#### Security Container Support\n\nfast-sandbox supports gVisor/Kata Containers via containerd runtime handlers:\n\n```go\n// Fast mode: runc (default)\ncontainerd.WithRuntime(\"runc\", nil)\n\n// Secure mode: gVisor\ncontainerd.WithRuntime(\"io.containerd.runsc.v2\", nil)\n\n// VM mode: Kata Containers\ncontainerd.WithRuntime(\"io.containerd.kata.v2\", nil)\n```\n\nThis allows OpenSandbox to offer different security isolation levels without changing the integration layer.\n\n#### Node Janitor: Orphan Container Cleanup\n\nFast mode creates containers before writing CRD, which can result in orphaned containers if:\n- Agent Pod is unexpectedly deleted (crash, node drain, eviction)\n- CRD write fails after container creation\n- Network partition prevents CRD reconciliation\n\nTo handle these cases, fast-sandbox provides a **Node Janitor DaemonSet** that runs on each node\n\n**How Janitor detects orphans:**\n\n| Orphan Type | Detection Method | Cleanup Trigger |\n|-------------|-------------------|-----------------|\n| Agent Pod disappeared | Pod UID not found in K8s API | Immediate (after timeout) |\n| Sandbox CRD deleted | CRD not found by sandbox name | Immediate (after timeout) |\n| UID mismatch (recreated CRD) | Container label ≠ CRD UID | Immediate (after timeout) |\n| Fast mode timeout | Container created > 10s ago without CRD | After orphan timeout |\n\n**Janitor scan process:**\n\n1. List all containers with label `fast-sandbox.io/managed=true` via containerd\n2. For each container, check:\n   - Agent Pod exists (via K8s API)\n   - Sandbox CRD exists with matching UID\n   - Container age > orphan timeout (default 10s for Fast mode)\n3. If orphan detected: enqueue cleanup task\n4. Cleanup process:\n   - SIGKILL the task\n   - Delete task from containerd\n   - Delete container with snapshot cleanup\n   - Remove FIFO files from `/run/containerd/fifo/`\n\n**Configuration parameters:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `--scan-interval` | 2m | Full container scan interval |\n| `--orphan-timeout` | 10s | Wait before treating Fast-mode container as orphan |\n| `NODE_NAME` | (required) | Node this janitor pod runs on |\n\n**Why the timeout?** Fast mode creates containers before CRD writes. The 10-second (configurable) timeout allows time for the async CRD write to complete, preventing false positives during normal operation.\n\n### Configuration Extension\n\nAdd `FastSandboxRuntimeConfig` to `server/src/config.py`:\n\n```python\nclass FastSandboxRuntimeConfig(BaseModel):\n    \"\"\"fast-sandbox runtime configuration.\"\"\"\n\n    controller_endpoint: str = Field(\n        default=\"localhost:9090\",\n        description=\"fast-sandbox Controller gRPC endpoint.\",\n    )\n    default_pool_ref: str = Field(\n        default=\"default-pool\",\n        description=\"Default SandboxPool name for sandbox allocation.\",\n    )\n    default_consistency_mode: Literal[\"fast\", \"strong\"] = Field(\n        default=\"strong\",\n        description=(\n            \"Default consistency mode. 'fast' = sub-50ms with cached image, eventual consistency. \"\n            \"'strong' = ~50-100ms base + K8s API write latency (typically 20-50ms), guaranteed consistency.\"\n        ),\n    )\n    execd_port: int = Field(\n        default=44772,\n        description=\"execd port in sandbox containers.\",\n    )\n```\n\nUpdate `AppConfig` to include the new config block and validation logic.\n\n### TOML Configuration Example\n\n```toml\n[server]\nhost = \"0.0.0.0\"\nport = 8080\napi_key = \"your-secret-key\"\n\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[kubernetes]\nnamespace = \"default\"\nworkload_provider = \"fast-sandbox\"\n\n[fast_sandbox]\ncontroller_endpoint = \"fast-sandbox-controller.opensandbox.svc:9090\"\ndefault_pool_ref = \"default-pool\"\ndefault_consistency_mode = \"strong\"  # \"fast\" = sub-50ms (cached), \"strong\" = ~50-100ms + API write\nexecd_port = 44772\n```\n\n### New Code Structure\n\n```\nserver/src/services/k8s/\n├── fastsandbox_provider.py      # New: FastSandboxProvider WorkloadProvider implementation\n├── fastsandbox_client.py        # New: gRPC client wrapper for fast-sandbox Controller\n├── provider_factory.py          # Modified: Register \"fast-sandbox\" provider\n└── ...\n```\n\n### API Mapping\n\n\n| OpenSandbox API                         | fast-sandbox gRPC  | Description                       |\n| --------------------------------------- | ------------------ | --------------------------------- |\n| `POST /sandboxes`                       | `CreateSandbox`    | Create sandbox, returns endpoints |\n| `GET /sandboxes/{id}`                   | `GetSandbox`       | Query sandbox status              |\n| `DELETE /sandboxes/{id}`                | `DeleteSandbox`    | Delete sandbox                    |\n| `POST /sandboxes/{id}/renew-expiration` | `UpdateSandbox`    | Update expiration time            |\n| `GET /sandboxes/{id}/endpoints/{port}`  | (local resolution) | Resolve from CreateResponse       |\n\n### Request Parameter Mapping\n\n```python\n# OpenSandbox CreateSandboxRequest → fast-sandbox CreateRequest\n{\n    \"image\": {\"uri\": \"python:3.11\"},              # → image\n    \"entrypoint\": [\"python\", \"-m\", \"http.server\"], # → command\n    \"env\": {\"PYTHONUNBUFFERED\": \"1\"},             # → envs\n    \"resourceLimits\": {\"cpu\": \"500m\"},            # → (Agent pool capacity)\n    \"timeout\": 3600,                             # → expireTimeSeconds\n    \"extensions\": {\n        \"pool_ref\": \"default-pool\",              # → poolRef\n        \"consistency_mode\": \"strong\",            # → consistencyMode (override)\n        \"failure_policy\": \"auto_recreate\"        # → failurePolicy\n    }\n}\n```\n\n### Status Mapping\n\n\n| fast-sandbox Phase | OpenSandbox State |\n| ------------------ | ----------------- |\n| Running            | Running           |\n| Pending / Creating | Pending           |\n| Failed / Lost      | Failed            |\n| (deleted)          | Terminated        |\n\n### Extensions Field Support\n\nThe `extensions` field in `CreateSandboxRequest` supports fast-sandbox specific options:\n\n\n| Extension Key      | Type                       | Description                                 |\n| ------------------ | -------------------------- | ------------------------------------------- |\n| `pool_ref`         | string                     | Target SandboxPool name (overrides default) |\n| `consistency_mode` | \"fast\"\\| \"strong\"          | Consistency mode (overrides default)        |\n| `failure_policy`   | \"manual\"\\| \"auto_recreate\" | Failure recovery policy                     |\n\n## Test Plan\n\n- **Unit Tests**: FastSandboxClient gRPC wrapper, request/response mapping, status translation\n- **Integration Tests**: Deploy fast-sandbox in Kind cluster, test create/get/delete/renew flows\n- **E2E Tests**: Full OpenSandbox SDK flow using fast-sandbox runtime\n- **Performance Tests**: Measure sandbox creation latency vs standard K8s runtime\n\n### Test Scenarios\n\n1. Basic lifecycle: create → status query → delete\n2. Expiration renewal\n3. Fast vs Strong consistency modes\n4. Pool selection via extensions\n5. Image affinity: second sandbox on same node (should be faster)\n6. Failure: controller unavailable, invalid pool ref\n7. execd connectivity after sandbox creation\n8. Concurrent sandbox creation (stress test)\n\n### Performance Benchmarks\n\nTarget metrics (to be verified in tests):\n\n\n| Scenario                              | Target Latency         | Notes                                           |\n| ------------------------------------- | ---------------------- | ----------------------------------------------- |\n| OpenSandbox BatchSandbox Pool         | ~1 second              | Measured with K8s API + watch overhead          |\n| Cold start, image cached, Fast mode   | <50ms                  | Container-first, async CRD                      |\n| Cold start, image cached, Strong mode | ~50-100ms + API write  | CRD-first, ~20-50ms additional for K8s API/etcd |\n| Cold start, image NOT cached          | Base + image pull time | Image pull depends on size and network          |\n| Warm start (reuse same Agent)         | <30ms                  | Agent already allocated                         |\n| Registry allocation (100 Agents)      | ~1.3ms                 | In-memory scheduling                            |\n| Registry allocation (1000 Agents)     | ~14ms                  | In-memory scheduling                            |\n\n> **Important**: The millisecond-scale latencies above assume the container image is already cached on the Agent's host node. In production, pre-pulling images or using a common set of base images is recommended for consistent performance.\n\n## Drawbacks\n\n- **Added Dependency**: Requires deploying and managing fast-sandbox Controller and Agent Pods and Janitor DaemonSet\n- **Operational Complexity**: Teams need to understand both OpenSandbox and fast-sandbox concepts\n- **gRPC Protocol**: Introduces gRPC dependency (vs pure HTTP/REST for K8s API)\n- **Limited Ecosystem**: fast-sandbox is a newer project with smaller community than vanilla K8s\n- **Fast Mode Orphans**: Fast consistency mode can create orphaned containers if CRD write fails (mitigated by Node Janitor)\n\n## Alternatives\n\n1. **Continue with standard K8s runtime only**: Rejected due to 2-5s cold start latency\n2. **Use only fast-sandbox CRD path (via K8s API)**: Rejected because it loses the Fast-Path gRPC performance benefit\n3. **Build OpenSandbox-native fast-path**: Rejected due to reinventing complex scheduling and container management logic\n4. **External adapter service**: Rejected due to additional operational components\n\n## Infrastructure Needed\n\n- **CI/CD**: Kind cluster with fast-sandbox installed for integration tests\n- **Documentation**: Deployment guide for fast-sandbox + OpenSandbox integration\n- **Helm Charts** (optional): Unified charts deploying OpenSandbox Server + fast-sandbox components\n\n## Upgrade & Migration Strategy\n\n- **Backwards Compatible**: Default runtime unchanged; opt-in via configuration\n- **No Migration**: Existing Docker/K8s runtime users unaffected\n- **Enable by Config**: Simply set `kubernetes.workload_provider = \"fast-sandbox\"` and add `[fast_sandbox]` block\n- **Rollback**: Switch back to `kubernetes` or `docker` runtime type with no data loss\n"
  },
  {
    "path": "oseps/0008-pause-resume-rootfs-snapshot.md",
    "content": "---\ntitle: Pause and Resume via Rootfs Snapshot\nauthors:\n  - \"@fengcone\"\ncreation-date: 2026-03-11\nlast-updated: 2026-03-13\nstatus: implementing\n---\n\n# OSEP-0008: Pause and Resume via Rootfs Snapshot\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [API Overview](#api-overview)\n  - [Kubernetes Resource Overview](#kubernetes-resource-overview)\n  - [Component Interaction Overview](#component-interaction-overview)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nThis proposal introduces pause and resume semantics for Kubernetes-backed\nsandboxes by persisting the sandbox root filesystem as an OCI image. On pause,\nthe server creates a `SandboxSnapshot` CR for the running sandbox Pod, a\ndedicated controller creates a commit Job on the same node, and the rootfs is\ncommitted and pushed to a registry. After the snapshot becomes ready, the\noriginal `BatchSandbox` is removed so compute resources are released.\n\nResume is intentionally simpler. The server resolves the single retained\nsnapshot for the stable `sandboxId`, then creates a new `BatchSandbox` with\n`replicas = 1` from the snapshot image. The public `sandboxId` remains stable\nacross pause and resume.\n\n```text\nTime ------------------------------------------------------------------------>\n\nSandbox lifecycle:   [Running]--[Pausing]--[Paused]--[Resuming]--[Running]\n                         |                     |\n                  commit + push         create new BatchSandbox\n                  delete old BatchSandbox from snapshot image\n```\n\n## Motivation\n\nOpenSandbox users often need to temporarily stop a sandbox without losing the\nfilesystem state that has accumulated during a long-running task. Typical cases\ninclude releasing cluster resources overnight, pausing an agent before a risky\nstep, or resuming a workspace later from the same working directory.\n\nToday, Kubernetes runtime returns `HTTP 501 Not Implemented` for both `pause`\nand `resume`. Docker supports cgroup freeze, but that does not survive restart\nor migration. Rootfs snapshot is the practical middle ground in the persistence\nroadmap:\n\n- Phase 1: persistent volumes preserve explicit mounts.\n- Phase 2: rootfs snapshot preserves the container filesystem.\n- Phase 3: VM or process checkpoint preserves memory and execution state.\n\nThis OSEP deliberately chooses a simple architecture:\n\n- keep `BatchSandbox` as the runtime workload resource used by the server today\n- add a single `SandboxSnapshot` CR per `sandboxId`\n- do not introduce a new per-instance lifecycle CR\n- do not support multiple retained snapshots in v1\n\n### Goals\n\n- Implement `pause` for Kubernetes sandboxes by committing a running sandbox Pod\n  rootfs into an OCI image and pushing it to a configurable registry.\n- Keep the public `sandboxId` stable across pause and resume.\n- Release compute resources after pause by deleting the original\n  `BatchSandbox`.\n- Implement `resume` by creating a new `BatchSandbox` with `replicas = 1` from\n  the retained snapshot image.\n- Expose `Pausing`, `Paused`, and `Resuming` through the existing Lifecycle API.\n- Keep the design minimal by retaining only one snapshot per sandbox.\n\n### Non-Goals\n\n- Preserving in-memory process state, open sockets, or CPU registers.\n- Supporting multiple historical snapshots per sandbox.\n- Adding `GET /sandboxes/{sandboxId}/snapshots` in v1.\n- Designing a general multi-instance pause model for `BatchSandbox` with\n  `replicas > 1`.\n- Extending Docker runtime to rootfs snapshot.\n- Implementing automatic scheduled snapshots.\n\n## Requirements\n\n- Public `sandboxId` must remain unchanged after pause and resume.\n- A sandbox has at most one retained snapshot in v1.\n- Pause must work from the currently running sandbox Pod and record the concrete\n  `podName`, `containerName`, and `nodeName` that are being snapshotted.\n- The commit Job must run on the same node as the source Pod.\n- Pause must complete `commit -> push` before the original `BatchSandbox` is\n  deleted.\n- At most one pause operation may be in progress for a given `sandboxId`.\n- Resume must work when the original `BatchSandbox` no longer exists.\n- `GET /sandboxes/{sandboxId}` must still return `200` and state `Paused` while\n  the sandbox is represented only by a `SandboxSnapshot`.\n- `DELETE /sandboxes/{sandboxId}` must delete both the live workload and any\n  retained `SandboxSnapshot` for that sandbox.\n- Registry credentials must be referenced via Kubernetes Secret, not inline API\n  credentials.\n- `SandboxSnapshot` must carry enough policy and workload reconstruction data to\n  resume even after the original `BatchSandbox` has been deleted.\n- The API shape must leave room for future snapshot backends, especially VM\n  snapshot, even though this revision only implements rootfs snapshot.\n- The design must remain compatible with the current server behavior where\n  Kubernetes sandboxes are created as `BatchSandbox` with `replicas = 1`.\n\n## Proposal\n\nPause and resume are modeled around two resources:\n\n- `BatchSandbox`: runtime workload resource used for the live sandbox\n- `SandboxSnapshot`: persisted snapshot state for one stable `sandboxId`\n\nThe public API stays sandbox-oriented, and the server remains the orchestrator.\nThe snapshot controller only handles snapshot execution.\n\n### API Overview\n\n```text\nPOST /sandboxes/{sandboxId}/pause   -> create or update SandboxSnapshot, return 202\nPOST /sandboxes/{sandboxId}/resume  -> create new BatchSandbox from snapshot, return 202\nGET  /sandboxes/{sandboxId}         -> returns Running / Pausing / Paused / Resuming\n```\n\nThere is no `GET /sandboxes/{sandboxId}/snapshots` endpoint in this version\nbecause each sandbox retains only one snapshot.\n\n### Kubernetes Resource Overview\n\n```text\nBatchSandbox (existing)\n  |- used by Server as the live workload resource\n  |- created with replicas = 1 for public sandbox lifecycle API\n  `- deleted after pause succeeds\n\nSandboxSnapshot (new, one per sandboxId)\n  |- metadata.name = <sandboxId>\n  |- spec.sandboxId\n  |- spec.policy.type              # Rootfs today, reserved for VMSnapshot later\n  |- spec.sourceBatchSandboxName\n  |- spec.sourcePodName\n  |- spec.sourceContainerName\n  |- spec.sourceNodeName\n  |- spec.imageUri\n  |- spec.snapshotPushSecretName\n  |- spec.resumeImagePullSecretName\n  |- spec.resumeTemplate\n  |- status.phase                 # Pending | Committing | Pushing | Ready | Failed\n  |- status.readyAt\n  `- status.message\n```\n\nThe `SandboxSnapshot` name is deterministic and equal to `sandboxId`, which\nenforces the “one sandbox, one snapshot” rule.\n\n### Component Interaction Overview\n\nPause flow:\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant Server\n    participant Batch as BatchSandbox\n    participant Snapshot as SandboxSnapshot\n    participant Ctrl as SandboxSnapshotController\n    participant Job as Commit Job Pod\n    participant Registry\n\n    Client->>Server: POST /sandboxes/{id}/pause\n    Server->>Batch: Read live BatchSandbox and Pod info\n    Server->>Snapshot: Create/Update SandboxSnapshot\\n(sandboxId, podName, containerName, nodeName, imageUri, pushSecretRef, resumePullSecretRef)\n    Server-->>Client: 202 Accepted\n    Ctrl->>Job: Create same-node commit Job Pod\n    Job->>Registry: Push snapshot image\n    Job-->>Ctrl: Commit/push succeeded\n    Ctrl->>Snapshot: status.phase = Ready\n    Server->>Batch: Delete original BatchSandbox after snapshot Ready\n```\n\nResume flow:\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant Server\n    participant Snapshot as SandboxSnapshot\n    participant Batch as New BatchSandbox\n    participant Ctrl as BatchSandboxController\n    participant Pod as Sandbox Pod\n\n    Client->>Server: POST /sandboxes/{id}/resume\n    Server->>Snapshot: Lookup SandboxSnapshot by sandboxId\n    Server->>Snapshot: Validate snapshot status.phase == Ready\n    Server->>Batch: Create new BatchSandbox\\n(replicas=1, image=snapshot.imageUri, sandboxId unchanged)\n    Server-->>Client: 202 Accepted\n    Ctrl->>Pod: Create sandbox Pod\n    Pod-->>Ctrl: Pod becomes Running and Ready\n    Server->>Server: Aggregate state as Resuming -> Running\n```\n\n### Notes/Constraints/Caveats\n\n- `BatchSandbox` still supports broader semantics in the platform, but this\n  proposal only targets the current public server path where a sandbox maps to a\n  `BatchSandbox` with `replicas = 1`.\n- The old `BatchSandbox` is deleted after a successful pause, so the paused\n  state exists only in `SandboxSnapshot`.\n- The server remains the orchestration owner for pause and resume. The\n  snapshot controller is not responsible for creating or deleting\n  `BatchSandbox`.\n- `SandboxSnapshot.spec.policy.type` is reserved for future snapshot backends.\n  This revision only supports `Rootfs`.\n- Snapshot image URI should be stable for the single retained snapshot, for\n  example `<snapshotRegistry>/<sandboxId>:snapshot`. This v1 design therefore\n  assumes a registry/tag policy that allows replacing the retained snapshot\n  image for a sandbox.\n- Snapshot push authentication and resume-time image pull authentication are\n  modeled separately. They may reference the same Kubernetes Secret in some\n  deployments, but the design must not assume they are identical.\n- Because the original `BatchSandbox` is deleted, resume cannot rely on\n  `imageUri` alone. `SandboxSnapshot` must retain enough `resumeTemplate`\n  information for the server to reconstruct a new `BatchSandbox`.\n- Registries with immutable tags are not compatible with this simplified\n  single-snapshot design unless the implementation changes the tag strategy in a\n  future revision.\n- Resume creates a new `BatchSandbox`; it does not resurrect the previous one.\n\n### Risks and Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| Pause succeeds in commit but old workload is deleted too early | Delete the original `BatchSandbox` only after `SandboxSnapshot.status.phase == Ready`. |\n| Commit job lands on the wrong node | Store `sourceNodeName` in `SandboxSnapshot.spec` and pin the commit Job Pod to that node. |\n| Server cannot represent a paused sandbox once `BatchSandbox` is gone | Use `SandboxSnapshot` as the source of truth for paused state in `GET /sandboxes/{sandboxId}`. |\n| Repeated pause requests cause inconsistent state | Allow only one in-flight pause per `sandboxId`; return `409` if snapshot phase is already `Pending`, `Committing`, or `Pushing`. |\n| Snapshot image is unavailable on resume | Require `status.phase == Ready` before resume and surface image-pull failures through normal sandbox startup state. |\n| Single-snapshot design loses rollback ability | Accept as an intentional simplification for v1; multi-snapshot support is a future extension. |\n\n## Design Details\n\n### 1. Public Lifecycle API changes\n\nThis OSEP keeps the public API minimal:\n\n- `CreateSandboxRequest.pausePolicy` is added as an optional field.\n- `POST /sandboxes/{sandboxId}/pause`\n- `POST /sandboxes/{sandboxId}/resume`\n- `GET /sandboxes/{sandboxId}`\n\nThere is no snapshots listing API in this version.\n\nSuggested request shape:\n\n```yaml\npausePolicy:\n  snapshotType: Rootfs\n  snapshotRegistry: registry.example.com/sandbox-snapshots\n  snapshotPushSecretName: snapshot-registry-push-secret\n  resumeImagePullSecretName: snapshot-registry-pull-secret\n```\n\n`pausePolicy.snapshotType` is reserved for future expansion and currently only\nsupports `Rootfs`. A later revision can add `VMSnapshot` without breaking the\nAPI shape.\n\n### 2. PausePolicy on BatchSandbox\n\nPause policy remains part of the live sandbox workload definition:\n\n```go\ntype PausePolicy struct {\n    SnapshotType              string `json:\"snapshotType,omitempty\"` // Rootfs today, VMSnapshot reserved\n    SnapshotRegistry          string `json:\"snapshotRegistry\"`\n    SnapshotPushSecretName    string `json:\"snapshotPushSecretName,omitempty\"`\n    ResumeImagePullSecretName string `json:\"resumeImagePullSecretName,omitempty\"`\n}\n\ntype BatchSandboxSpec struct {\n    // existing fields...\n    PausePolicy *PausePolicy `json:\"pausePolicy,omitempty\"`\n}\n```\n\nThis policy is used by the server when constructing `SandboxSnapshot`.\n\n### 3. SandboxSnapshot CRD\n\nIntroduce `SandboxSnapshot` under `sandbox.opensandbox.io/v1alpha1`.\n\n```go\ntype SandboxSnapshotPhase string\n\nconst (\n    SandboxSnapshotPhasePending    SandboxSnapshotPhase = \"Pending\"\n    SandboxSnapshotPhaseCommitting SandboxSnapshotPhase = \"Committing\"\n    SandboxSnapshotPhasePushing    SandboxSnapshotPhase = \"Pushing\"\n    SandboxSnapshotPhaseReady      SandboxSnapshotPhase = \"Ready\"\n    SandboxSnapshotPhaseFailed     SandboxSnapshotPhase = \"Failed\"\n)\n\ntype SandboxSnapshotSpec struct {\n    SandboxID                 string                `json:\"sandboxId\"`\n    Policy                    SnapshotPolicy        `json:\"policy\"`\n    SourceBatchSandboxName    string                `json:\"sourceBatchSandboxName\"`\n    SourcePodName             string                `json:\"sourcePodName\"`\n    SourceContainerName       string                `json:\"sourceContainerName\"`\n    SourceNodeName            string                `json:\"sourceNodeName\"`\n    ImageURI                  string                `json:\"imageUri\"`\n    SnapshotPushSecretName    string                `json:\"snapshotPushSecretName,omitempty\"`\n    ResumeImagePullSecretName string                `json:\"resumeImagePullSecretName,omitempty\"`\n    ResumeTemplate            *runtime.RawExtension `json:\"resumeTemplate,omitempty\"`\n    PausedAt                  metav1.Time           `json:\"pausedAt\"`\n}\n\ntype SandboxSnapshotStatus struct {\n    Phase     SandboxSnapshotPhase `json:\"phase,omitempty\"`\n    Message   string               `json:\"message,omitempty\"`\n    ReadyAt   *metav1.Time         `json:\"readyAt,omitempty\"`\n    ImageDigest string             `json:\"imageDigest,omitempty\"`\n}\n\ntype SnapshotPolicy struct {\n    Type string `json:\"type\"` // Rootfs today, VMSnapshot reserved\n}\n```\n\nKey rules:\n\n- `metadata.name = sandboxId`\n- one namespace contains at most one `SandboxSnapshot` for a given `sandboxId`\n- creating a new pause request overwrites the retained snapshot\n- `policy.type` must be set to `Rootfs` in this revision\n- `SourcePodName`, `SourceContainerName`, and `SourceNodeName` are mandatory\n  because the commit workflow is bound to a concrete live container\n- `SourceContainerName` identifies the main sandbox workload container whose\n  rootfs is being snapshotted; init containers and sidecars are not committed\n- `SnapshotPushSecretName` is used only for the in-container registry push\n  performed by the commit Job\n- `ResumeImagePullSecretName` is used only when reconstructing the resumed\n  workload so kubelet can pull the retained snapshot image\n- `ResumeTemplate` must preserve enough information to reconstruct a new\n  `BatchSandbox` after the original workload has been deleted\n\n### 4. Pause state model\n\nState is derived from resource presence:\n\n- `BatchSandbox` exists and is ready, and no matching pause cleanup is pending\n  -> `Running`\n- `BatchSandbox` exists and snapshot phase is\n  `Pending|Committing|Pushing|Ready`, and the live workload still matches\n  `snapshot.spec.sourceBatchSandboxName` -> `Pausing`\n- `BatchSandbox` is absent and snapshot phase is `Ready` -> `Paused`\n- `BatchSandbox` exists and was created from snapshot but is not ready yet ->\n  `Resuming`\n- `SandboxSnapshot.status.phase == Failed` and no live replacement workload ->\n  `Failed`\n\nThis means `GET /sandboxes/{sandboxId}` must consult both `BatchSandbox` and\n`SandboxSnapshot`.\n\n### 5. Pause flow\n\nThe pause flow is:\n\n```text\n1. Client  POST /sandboxes/{sandboxId}/pause\n2. Server  Resolve current BatchSandbox and running Pod for sandboxId\n3. Server  Validate:\n           - workload exists\n           - replicas == 1 for this server path\n           - pausePolicy is configured\n           - no existing snapshot for sandboxId is already in phase\n             Pending|Committing|Pushing\n4. Server  Create or replace SandboxSnapshot(name=sandboxId) with:\n           - policy.type = Rootfs\n           - sourceBatchSandboxName\n           - sourcePodName\n           - sourceContainerName\n           - sourceNodeName\n           - target imageUri\n           - snapshotPushSecretName\n           - resumeImagePullSecretName\n           - resumeTemplate\n           - pausedAt\n           - status.phase = Pending\n5. Snapshot controller creates a same-node commit Job Pod\n6. Job Pod commits container rootfs and pushes image\n7. Snapshot controller updates phase:\n           Pending -> Committing -> Pushing -> Ready\n8. Server-side pause orchestration deletes the original BatchSandbox\n9. GET /sandboxes/{sandboxId} now returns Paused from SandboxSnapshot\n```\n\nFailure behavior:\n\n- If commit or push fails, `SandboxSnapshot.status.phase = Failed`\n- The original `BatchSandbox` is not deleted\n- The sandbox remains `Running` or transitions to `Failed` based on the final\n  server policy; this OSEP recommends keeping the workload running and exposing\n  the snapshot failure in the message\n- If another pause is already in progress for the same `sandboxId`, the server\n  returns `409 Conflict`\n\n### 6. Commit Job Pod\n\nThe snapshot controller creates one short-lived Job Pod:\n\n```yaml\napiVersion: batch/v1\nkind: Job\nmetadata:\n  name: sbxsnap-commit-<sandboxId>\nspec:\n  ttlSecondsAfterFinished: 300\n  template:\n    spec:\n      restartPolicy: Never\n      nodeName: <sourceNodeName>\n      containers:\n        - name: committer\n          image: <committerImage>\n          command: [\"/bin/sh\", \"-c\"]\n          args:\n            - |\n              snapshot-committer \\\n                --containerd-namespace k8s.io \\\n                --container-id <containerID> \\\n                --target-image <imageUri> \\\n                --registry-auth-file /var/run/opensandbox/registry/.dockerconfigjson\n          volumeMounts:\n            - name: containerd-sock\n              mountPath: /run/containerd/containerd.sock\n            - name: snapshot-push-auth\n              mountPath: /var/run/opensandbox/registry\n              readOnly: true\n      volumes:\n        - name: containerd-sock\n          hostPath:\n            path: /run/containerd/containerd.sock\n            type: Socket\n        - name: snapshot-push-auth\n          secret:\n            secretName: <snapshotPushSecretName>\n```\n\nThe controller resolves the source container ID from `SourcePodName` and\n`SourceContainerName`.\n\n`snapshot-committer` in this example is a logical role, not a required product\nname. The implementation may be a small in-house binary, a thin wrapper around\nexisting container tooling, or another committer client, as long as it\nperforms the following responsibilities explicitly:\n\n- commit the source container rootfs into a snapshot image\n- read the mounted registry auth config from the Secret volume\n- push the snapshot image to `spec.imageUri`\n- return a clear success/failure signal so the controller can update\n  `SandboxSnapshot.status.phase`\n\nImportant auth semantics:\n\n- `imagePullSecrets` on the Job Pod, if needed for the `committerImage`, only\n  affects kubelet pulling the Job image. It does not authenticate registry\n  operations performed by the process inside the container.\n- `snapshotPushSecretName` is mounted into the committer container and must be\n  consumed explicitly by the snapshot push client as registry auth config.\n- `resumeImagePullSecretName` is not used by the commit Job. It is propagated\n  to the resumed workload template so kubelet can pull `snapshot.spec.imageUri`\n  during resume.\n\n### 7. Resume flow\n\nThe resume flow is:\n\n```text\n1. Client  POST /sandboxes/{sandboxId}/resume\n2. Server  Resolve SandboxSnapshot(name=sandboxId)\n3. Server  Validate:\n           - snapshot exists\n           - snapshot status.phase == Ready\n4. Server  Create a new BatchSandbox:\n           - metadata.name reuses the same public sandbox identity mapping\n           - replicas = 1\n           - template reconstructed from snapshot.spec.resumeTemplate\n           - template image = snapshot.spec.imageUri\n           - template imagePullSecrets = snapshot.spec.resumeImagePullSecretName\n           - labels preserve sandboxId\n5. Server  Aggregate sandbox state as Resuming while the new BatchSandbox is\n           starting\n6. BatchSandbox controller creates the new Pod\n7. Once the new Pod is running and ready, GET /sandboxes/{sandboxId} returns Running\n```\n\nThe snapshot is retained after resume so the sandbox can be paused and resumed\nagain later, but only the latest snapshot is kept.\n\n### 8. Stable sandbox ID\n\nThe public `sandboxId` is stable across three states:\n\n- live workload exists: identify by `BatchSandbox` label `opensandbox.io/id`\n- paused workload: identify by `SandboxSnapshot.metadata.name == sandboxId`\n- resumed workload: identify by the new `BatchSandbox` label\n\nThe workload object identity may change, but the public sandbox identity does\nnot.\n\n### 9. List and get semantics\n\n`GET /sandboxes/{sandboxId}` must:\n\n- first resolve the live `BatchSandbox`\n- then resolve `SandboxSnapshot`\n- merge both views into one lifecycle status\n\n`GET /sandboxes` should include:\n\n- running sandboxes from live `BatchSandbox` objects\n- paused sandboxes from `SandboxSnapshot` objects that have no live\n  `BatchSandbox`\n\nThis keeps paused sandboxes visible even though their workloads have been\ndeleted.\n\n### 10. Delete semantics\n\n`DELETE /sandboxes/{sandboxId}` must remove all Kubernetes state associated with\nthe public sandbox identity:\n\n- delete the live `BatchSandbox` if it exists\n- delete `SandboxSnapshot(name=sandboxId)` if it exists\n\nRegistry cleanup is best-effort in this revision:\n\n- if the implementation can safely delete the retained snapshot image from the\n  registry, it may do so\n- registry image deletion failure must not block sandbox deletion success\n- operators may rely on registry retention or garbage collection if image\n  deletion is unavailable or undesirable\n\n### 11. Configuration\n\nAdd a new server config section:\n\n```toml\n[pause]\ndefault_snapshot_registry = \"\"\ncommitter_image = \"containerd/containerd:1.7\"\n```\n\nSemantics:\n\n- `default_snapshot_registry` is used when `pausePolicy.snapshotRegistry` is not\n  explicitly set.\n- `committer_image` is the image used by the commit Job Pod.\n\n### 12. Security considerations\n\nThe commit Job mounts the node's container runtime socket to resolve the source\ncontainer and commit its root filesystem. This is a privileged operation with\nnode-level runtime access.\n\nOperational constraints for this design:\n\n- `committer_image` is selected by server or operator configuration, not by the\n  public sandbox API\n- the commit Job spec is not user-extensible in this revision\n- operators should treat the snapshot controller and committer image as trusted\n  infrastructure components, with tighter RBAC and supply-chain controls than\n  ordinary sandbox workloads\n\n## Test Plan\n\n### Unit tests\n\n- Pause request creates or replaces `SandboxSnapshot(name=sandboxId)`.\n- `SandboxSnapshot` contains `sourcePodName`, `sourceContainerName`, and\n  `sourceNodeName` from the live workload.\n- Snapshot controller creates a Job pinned to the correct node.\n- Server returns `Paused` when `BatchSandbox` is absent and snapshot is `Ready`.\n- Server returns `Pausing` when snapshot is `Ready` but the source\n  `BatchSandbox` still exists.\n- Server returns `Resuming` after new `BatchSandbox` is created from snapshot but\n  before readiness.\n- Pause returns `409` when another pause is already in progress for the same\n  `sandboxId`.\n- Resume fails with `409` when snapshot is absent or not `Ready`.\n- Delete removes `SandboxSnapshot` for paused sandboxes.\n\n### Integration tests\n\n- End-to-end pause:\n  - running `BatchSandbox`\n  - snapshot becomes `Ready`\n  - original `BatchSandbox` is deleted\n  - `GET /sandboxes/{id}` returns `Paused`\n- End-to-end resume:\n  - server finds snapshot by `sandboxId`\n  - creates new `BatchSandbox`\n  - new Pod comes up from snapshot image\n  - `GET /sandboxes/{id}` returns `Running`\n- Repeat pause after resume:\n  - the same `SandboxSnapshot` resource is reused or replaced\n  - only one snapshot remains\n- Delete after pause:\n  - paused sandbox is removed even when no live `BatchSandbox` exists\n  - retained `SandboxSnapshot` is removed\n\n### Manual and operator validation\n\n- Confirm the committed image is present in the registry after pause.\n- Confirm working directory contents survive pause and resume.\n- Confirm CPU and memory are released after the old `BatchSandbox` is deleted.\n- Confirm the commit Job Pod actually runs on the source node.\n- Confirm the committed rootfs comes from the intended sandbox container rather\n  than a sidecar.\n\n## Drawbacks\n\n- Only one snapshot is retained, so rollback to older states is impossible.\n- The design assumes the server-side Kubernetes path uses `replicas = 1`.\n- The paused state is split from the live workload and must be reconstructed by\n  the server from multiple resources.\n- Registries that enforce immutable tags are a poor fit for the simplified\n  single-snapshot design.\n- Commit still requires node-local runtime access.\n\n## Alternatives\n\n### Introduce a dedicated SandboxInstance CR\n\nA more general design is possible, but rejected here because the user goal is a\nsimpler architecture aligned with the current server path. For v1, the single\nsnapshot CR plus existing `BatchSandbox` is sufficient.\n\n### Store pause state directly on BatchSandbox\n\nRejected because the paused state must survive after the workload is deleted.\nOnce pause succeeds, the original `BatchSandbox` no longer exists.\n\n### Support multiple snapshots and `/snapshots` API in v1\n\nRejected to keep the architecture minimal. Multi-snapshot history can be added\nlater by changing `SandboxSnapshot` naming and list semantics.\n\n### Restore the old BatchSandbox instead of creating a new one\n\nRejected because pause deletes the original workload to release resources. Resume\nis cleaner if it always creates a fresh `BatchSandbox` from the retained image.\n\n## Infrastructure Needed\n\n- An OCI registry reachable from cluster nodes.\n- A registry credential Secret of type `kubernetes.io/dockerconfigjson`.\n- A committer image that can access `containerd.sock` on the source node.\n- RBAC for `SandboxSnapshot`, Jobs, and reads on Pods and `BatchSandbox`.\n\n## Upgrade & Migration Strategy\n\nThis change is additive for the public API and simple for operators.\n\n- Existing clients keep using the same sandbox lifecycle endpoints.\n- Existing Kubernetes deployments without the new `SandboxSnapshot` CRD continue\n  to return `501` for pause and resume.\n- Rollout sequence:\n  - install the `SandboxSnapshot` CRD\n  - deploy the `SandboxSnapshotController`\n  - deploy the updated server with pause/resume orchestration\n- Existing running sandboxes do not require migration. Only new pause/resume\n  operations use the new flow.\n"
  },
  {
    "path": "oseps/0009-auto-renew-sandbox-on-ingress-access.md",
    "content": "---\ntitle: Auto-Renew Sandbox on Ingress Access\nauthors:\n  - \"@Pangjiping\"\ncreation-date: 2026-03-15\nlast-updated: 2026-03-19\nstatus: implementing\n---\n\n# OSEP-0009: Auto-Renew Sandbox on Ingress Access\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [Scope: Supported Reverse Proxy Paths](#scope-supported-reverse-proxy-paths)\n  - [Activation Model and Extensions Contract](#activation-model-and-extensions-contract)\n  - [Control Strategy to Prevent Renewal Storms](#control-strategy-to-prevent-renewal-storms)\n  - [Mode A: Server Proxy Path](#mode-a-server-proxy-path)\n  - [Mode B: Ingress Gateway Path (Redis Queue)](#mode-b-ingress-gateway-path-redis-queue)\n  - [Why Redis Between Ingress and Server](#why-redis-between-ingress-and-server)\n  - [Redis Data Model](#redis-data-model)\n  - [Configuration](#configuration)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nIntroduce an access-driven sandbox auto-renew mechanism for ingress traffic. When users access sandbox services through reverse proxy paths, OpenSandbox can automatically extend sandbox expiration for sandboxes that explicitly opt in to this capability.\n\nThis proposal only supports two proxy paths that can observe access traffic: server proxy and ingress gateway. Docker direct access is explicitly out of scope because no reverse proxy request can be reliably captured there.\n\n## Motivation\n\nToday users must renew expiration explicitly through `POST /sandboxes/{id}/renew-expiration`. For interactive workloads (IDE, notebook, web app), request traffic already implies sandbox activity, but expiration still depends on explicit lifecycle API calls from clients.\n\nThis creates two practical issues:\n\n- User sessions can be interrupted even while ingress traffic is still active.\n- Naively triggering renewal on every ingress request would create renewal storms under high QPS.\n\nAn access-driven renewal mechanism is needed, but it must be strongly rate-controlled and deduplicated.\n\n### Goals\n\n- Automatically renew sandbox expiration on observed ingress access for explicitly opted-in sandboxes.\n- Support exactly two existing reverse proxy implementations:\n  - server proxy path\n  - ingress gateway path\n- Use direct self-call renewal in server proxy mode.\n- Use Redis-backed queue forwarding in ingress gateway mode.\n- Require explicit capability enablement at three levels: server, ingress, and sandbox creation request.\n- Strictly control actual renewal API calls to avoid excessive renew traffic.\n- Preserve existing lifecycle API semantics and backward compatibility.\n\n### Non-Goals\n\n- Supporting Docker direct exposure mode for auto-renew triggers.\n- Replacing manual renewal API (`renew-expiration`) behavior.\n- Introducing per-request guaranteed renewal (best-effort under policy control is sufficient).\n- Building a generic event bus for all lifecycle actions.\n\n## Requirements\n\n- The implementation must work with existing lifecycle API and runtime providers.\n- Reverse proxy traffic must be the only trigger source for this proposal.\n- Auto-renew must be disabled unless all three conditions are met:\n  - server supports and enables auto-renew-on-access,\n  - ingress supports and enables renew-intent signaling (for ingress mode),\n  - sandbox creation request explicitly opts in via `extensions`.\n- Renewal requests must be bounded by deduplication and throttling controls.\n- Ingress gateway mode must use Redis as the forwarding queue.\n- Renewal must be idempotent from the caller perspective (repeated access events do not imply repeated renew calls).\n- The design must remain safe under burst traffic and multi-replica deployments.\n\n## Proposal\n\nAdd an \"access renew controller\" that converts proxy access signals into controlled renewal attempts.\n\n- In server proxy mode, the server path handling proxied traffic submits local renew intents and performs internal renewal calls.\n- In ingress gateway mode, ingress publishes renew intents into Redis; OpenSandbox server consumes and executes controlled renewals.\n- Both modes share the same renewal gate logic: opt-in check, eligibility window, cooldown, and per-sandbox in-flight deduplication.\n\nAt a high level, access traffic indicates activity, but only eligible events produce actual `renew-expiration` operations.\n\n### Notes/Constraints/Caveats\n\n- This OSEP applies to reverse proxy captured traffic only.\n- If a deployment bypasses proxy (direct pod/container access), no automatic renewal signal is available.\n- Ingress-mode auto-renew is best-effort and depends on Redis availability.\n- Renewal policy is intentionally conservative to prioritize control-plane stability.\n\n### Risks and Mitigations\n\n| Risk | Mitigation |\n| --- | --- |\n| Renewal storms under high ingress QPS | Multi-stage gating: renew-window check + cooldown + in-flight dedupe |\n| Duplicate renewals across server replicas | Redis lock keys for distributed dedupe in ingress mode; local dedupe in server proxy path |\n| Redis backlog growth in traffic spikes | Queue TTL, bounded consumer concurrency, and drop-on-overload policy |\n| False negatives (active sandbox not renewed) | Configurable renew window and cooldown; metrics/alerts for missed renew opportunities |\n| Added operational complexity | Feature flag rollout, default-off mode, and explicit docs/runbooks |\n\n## Design Details\n\n### Scope: Supported Reverse Proxy Paths\n\nOnly these two paths are supported:\n\n1. **Server proxy path**\n   - Access route: `/sandboxes/{sandbox_id}/proxy/{port}/...`\n   - Traffic is observed inside OpenSandbox server directly.\n2. **Ingress gateway path**\n   - Access is observed by ingress/gateway implementation (wildcard/header/uri routing modes).\n   - Signals are forwarded through Redis queue to server workers.\n\nExplicitly unsupported:\n\n- **Docker direct mode** (client accesses container endpoint directly):\n  - No mandatory reverse proxy hop exists.\n  - OpenSandbox cannot reliably observe all access requests.\n\n### Activation Model and Extensions Contract\n\nThis feature uses explicit \"three-party handshake\" activation.\n\n1. **Server-side capability switch**\n   - `server.auto_renew_on_access.enabled = true` must be set (stored under `ServerConfig`).\n2. **Ingress-side capability switch** (ingress mode only)\n   - ingress must be configured to publish renew-intents (`server.auto_renew_on_access.redis.enabled = true` and ingress integration enabled).\n3. **Sandbox-level opt-in and duration**\n   - sandbox must declare in `CreateSandboxRequest.extensions` how long each automatic renewal extends expiration (see below). Presence of a valid value opts the sandbox in.\n\nIf any condition is missing, access events are ignored for renewal.\n\nGiven current API schema (`extensions: Dict[str, str]`), this OSEP proposes:\n\n- `extensions[\"access.renew.extend.seconds\"]` = positive integer string (e.g. `\"1800\"`)\n\n**Meaning:** When auto-renew on access is triggered for this sandbox, each renewal extends expiration by this many seconds. The key thus both opts the sandbox in and defines the per-renewal extension duration.\n\n**Behavior rules:**\n\n- Missing key or invalid value (non-positive integer string) means no auto-renew on access for that sandbox.\n- Valid value (e.g. `\"1800\"`) enables auto-renew subject to policy gating; each successful renewal uses `new_expires_at = now + (value of access.renew.extend.seconds)`.\n- Invalid values are rejected at sandbox creation time with 4xx validation error.\n\n### Control Strategy to Prevent Renewal Storms\n\nBoth modes share the same strict control policy. An access event triggers renewal only when all checks pass:\n\n1. **Opt-in check**: sandbox has a valid positive `access.renew.extend.seconds` in extensions.\n2. **Sandbox state check**: sandbox must be `Running`.\n3. **Renew window check**: remaining TTL must be below `before_expiration_seconds`.\n4. **Cooldown check**: no successful renewal for this sandbox within `min_interval_seconds`.\n5. **In-flight dedupe**: at most one renewal task per sandbox at a time.\n\nIf any check fails, the event is acknowledged and dropped without a renewal call.\n\nRenew target time:\n\n- `new_expires_at = now + (value of extensions[\"access.renew.extend.seconds\"])`; server may enforce a cap or default.\n- must also satisfy `new_expires_at > current_expires_at` before calling renew API\n\nThis guarantees bounded renewal frequency even for very hot sandboxes.\n\n### Mode A: Server Proxy Path\n\nFor requests handled by server proxy:\n\n```\nClient --> OpenSandbox Server Proxy --> Sandbox Service\n              |\n              +--> AccessRenewController (local signal)\n                        |\n                        +--> eligibility + cooldown + in-flight checks\n                                |\n                                +--> internal renew call (server -> own renew handler)\n```\n\nImplementation notes:\n\n- Trigger point: after sandbox resolution and before/after proxy forward (implementation-defined), with non-blocking behavior.\n- Renewal execution must not increase proxy path latency materially; use async/background task dispatch.\n- Internal renewal uses existing service-level renewal logic to avoid API divergence.\n\n### Mode B: Ingress Gateway Path (Redis Queue)\n\nFor requests first seen by ingress:\n\n```\nClient --> Ingress/Gateway\n             |\n             +--> publish renew-intent to Redis (sandbox_id, ts, route info)\n                           |\n                           v\n                  OpenSandbox Renew Worker\n                           |\n                           +--> eligibility + cooldown + distributed dedupe\n                                   |\n                                   +--> renew call\n```\n\nRedis usage:\n\n- Queue: **Redis List only** (required). Ingress pushes with LPUSH; server workers pop with BRPOP. No ack—best-effort delivery. Keeps the model simple and avoids Stream/consumer-group complexity.\n- Intent payload (one JSON string per list element):\n  - `sandbox_id` (string, required)\n  - `observed_at` (string, required; RFC3339 or RFC3339Nano)\n  - `port` (int, optional) — sandbox port accessed\n  - `request_uri` (string, optional) — path forwarded to the sandbox\n- Ingress may apply a **client-side throttle** (e.g. min interval per sandbox) so not every request produces an intent; queue key and optional list cap are configurable.\n- Distributed dedupe lock key (server side):\n  - `opensandbox:renew:lock:{sandbox_id}` with short TTL\n\nWorker behavior:\n\n- One or more workers block on BRPOP; on pop, parse payload, drop if stale, then run gate checks and maybe renew (with lock). No requeue on failure—best-effort.\n- On publish/consume failures, log and drop.\n\n### Why Redis Between Ingress and Server\n\nRedis is selected for ingress -> server renew-intent delivery to decouple data-plane bursts from control-plane renew execution.\n\nCompared with ingress directly calling server renew APIs:\n\n- **Backpressure isolation**: ingress can LPUSH quickly; server workers process at their own pace.\n- **Latency protection**: ingress request path does not wait on renew execution.\n- **Multi-replica friendliness**: multiple server instances can BRPOP from the same list (competing consumers); each message is taken by one worker.\n- **Failure containment**: when server is transiently unhealthy, intents can sit in the list briefly instead of ingress retrying synchronously.\n\nCompared with other MQs (Kafka/NATS/Pulsar):\n\n- **Scope fit**: best-effort, short-lived; Redis List is the minimal option and avoids Stream/consumer-group complexity.\n- **Operational cost**: Redis is commonly available; List is the simplest structure.\n- **Implementation speed**: LPUSH + BRPOP + lock is enough; no XREADGROUP/XACK or group management.\n\n### Redis Data Model\n\nThis OSEP uses a Redis List for renew-intent events plus a lock key for per-sandbox dedupe (server side).\n\n**Keys:**\n\n- **Intent list key**: configurable, default `opensandbox:renew:intent` (Redis List)\n- **Per-sandbox lock key**: `opensandbox:renew:lock:{sandbox_id}` (server consumer only)\n\n**Intent payload** (single JSON string per list element):\n\n| Field          | Type   | Required | Description                          |\n|----------------|--------|----------|--------------------------------------|\n| `sandbox_id`   | string | yes      | Sandbox identifier                   |\n| `observed_at`  | string | yes      | Time of access (RFC3339 or RFC3339Nano) |\n| `port`         | int    | no       | Sandbox port accessed                |\n| `request_uri`  | string | no       | Path forwarded to the sandbox        |\n\nProducer (ingress):\n\n- Push with `LPUSH <queue_key> <serialized-json>`.\n- Optional: cap list length (`LTRIM <queue_key> 0 max_len-1` after LPUSH); overflow is best-effort drop.\n- Ingress may throttle: e.g. at most one intent per sandbox per N seconds (client-side) to limit queue growth.\n\nConsumer (server):\n\n- One or more workers block with `BRPOP opensandbox:renew:intent <timeout>`.\n- On pop: parse payload; if `now - observed_at > event_ttl_seconds`, drop and continue.\n- Acquire lock: `SET opensandbox:renew:lock:{sandbox_id} <value> NX EX lock_ttl_seconds`.\n- If lock acquired: run gate checks (opt-in, state, window, cooldown) and maybe renew; then lock expires by TTL.\n- If lock not acquired: treat as in-flight dedupe, drop.\n- No ack or requeue: if the worker crashes after pop, that intent is lost (best-effort).\n\nNotes:\n\n- Lock TTL must be short and greater than the renew critical section.\n- Implementations must use Redis List; this LPUSH/BRPOP + lock flow is the only specified processing model.\n\n### Configuration\n\nUse `server` configuration namespace; no independent top-level config block is required:\n\n```toml\n[server]\nauto_renew_on_access.enabled = false\nauto_renew_on_access.before_expiration_seconds = 300\nauto_renew_on_access.extension_seconds = 1800\nauto_renew_on_access.min_interval_seconds = 60\n\n# auto-detected by request path:\n# - server-proxy path uses local trigger\n# - ingress path uses redis trigger\n\nauto_renew_on_access.redis.enabled = false\nauto_renew_on_access.redis.url = \"redis://127.0.0.1:6379/0\"\nauto_renew_on_access.redis.queue_key = \"opensandbox:renew:intent\"\nauto_renew_on_access.redis.lock_ttl_seconds = 10\nauto_renew_on_access.redis.event_ttl_seconds = 30\nauto_renew_on_access.redis.consumer_concurrency = 8\n```\n\nConfiguration rules:\n\n- `server.auto_renew_on_access.enabled=false` means feature fully disabled.\n- Ingress path renewal requires Redis block enabled and reachable on the server; the **ingress component** uses its own config (e.g. CLI flags: `--renew-intent-enabled`, `--renew-intent-redis-dsn`, `--renew-intent-queue-key`, `--renew-intent-queue-max-len`, `--renew-intent-min-interval`) to connect to Redis and publish intents. Queue key and default list name should match what the server consumer expects (e.g. `opensandbox:renew:intent`).\n- Server proxy path can run without Redis.\n- Feature is applied per sandbox only when `extensions[\"access.renew.extend.seconds\"]` is present and a valid positive integer string.\n- Docker runtime direct mode remains unsupported regardless of this config.\n\nCreate request example:\n\n```json\n{\n  \"image\": { \"uri\": \"python:3.11-slim\" },\n  \"entrypoint\": [\"python\", \"-m\", \"http.server\", \"8000\"],\n  \"timeout\": 3600,\n  \"extensions\": {\n    \"access.renew.extend.seconds\": \"1800\"\n  }\n}\n```\n\n## Test Plan\n\n- **Unit Tests**\n  - Extension validation for auto-renew opt-in keys and values\n  - Renew eligibility function (window/cooldown/state checks)\n  - In-flight dedupe behavior under concurrent signals\n  - Renew target time calculation and monotonicity checks\n- **Integration Tests (Server Proxy)**\n  - Non-opt-in sandbox never triggers renew under access traffic\n  - Opt-in sandbox triggers bounded renew calls under same traffic\n  - High-frequency proxy requests only trigger bounded renew calls\n  - Proxy request path remains successful when renew path fails transiently\n- **Integration Tests (Ingress + Redis)**\n  - Non-opt-in sandbox intents are ignored at consumer side\n  - Ingress event publish -> worker consume -> renew success\n  - Duplicate events for same sandbox are coalesced\n  - Redis unavailable path follows best-effort drop semantics\n- **Stress Tests**\n  - N sandboxes x high QPS access confirms renew call count stays within policy bound\n\nSuccess criteria:\n\n- Renewal request rate remains proportional to policy limits, not ingress QPS.\n- Active sandboxes in supported proxy paths are renewed before expiration under normal operating conditions.\n\n## Drawbacks\n\n- Adds background components and policy tuning complexity.\n- Ingress mode introduces hard dependency on Redis availability.\n- Conservative gating may skip some renew opportunities under extreme failure conditions.\n\n## Infrastructure Needed\n\n- Redis service for ingress gateway mode.\n- Ingress (or gateway) that publishes renew intents (e.g. OpenSandbox Ingress with `--renew-intent-enabled`, Redis DSN, optional queue key / list cap / client-side per-sandbox min-interval throttle).\n\n## Upgrade & Migration Strategy\n\n- Backward compatible and disabled by default.\n- Rollout order:\n  1. Deploy server with feature flag off.\n  2. Enable in server proxy path for canary validation.\n  3. Enable ingress + Redis path progressively.\n- Rollback:\n  - Disable `server.auto_renew_on_access.enabled` (and `server.auto_renew_on_access.redis.enabled` for ingress mode).\n  - Existing manual renewal flow remains unchanged.\n"
  },
  {
    "path": "oseps/0010-opentelemetry-instrumentation.md",
    "content": "---\ntitle: OpenTelemetry Instrumentation (execd, egress, and ingress)\nauthors:\n  - \"@Pangjiping\"\ncreation-date: 2026-03-18\nlast-updated: 2026-03-18\nstatus: draft\n---\n\n# OSEP-0010: OpenTelemetry Instrumentation (execd, egress, and ingress)\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n  - [1. Metrics](#1-metrics)\n    - [1.1 execd metrics](#11-execd-metrics)\n    - [1.2 egress metrics](#12-egress-metrics)\n    - [1.3 ingress metrics](#13-ingress-metrics)\n  - [2. Logging](#2-logging)\n  - [3. Tracing](#3-tracing)\n  - [4. Initialization and configuration](#4-initialization-and-configuration)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\nThis proposal introduces unified **OpenTelemetry** instrumentation for OpenSandbox’s three Go components—**execd**, **egress**, and **ingress**—covering **Metrics**, **Logs**, and **Distributed Traces**. With OTLP export, configurable sampling, and environment-based configuration, operators and developers can observe request flows, resource usage, policy enforcement, and ingress proxy traffic in production and integrate with existing observability stacks (e.g., Jaeger, Prometheus, Grafana Loki).\n\n## Motivation\n\nToday execd, egress, and ingress have partial observability (e.g., execd’s HTTP API and `GetMetrics`/`WatchMetrics`, zap/loggers in egress and ingress) but lack:\n\n- **Standardized metrics**: No Prometheus/OpenTelemetry-style HTTP QPS, latency, status codes; no unified metrics for execd code execution and Jupyter sessions, egress DNS/policy, or ingress proxy requests and routing.\n- **Distributed tracing**: No way to correlate requests, code execution, DNS lookups, policy evaluation, and ingress proxy forwarding in a single trace.\n- **Log–trace correlation**: Logs do not include `trace_id`/`span_id`, making it hard to jump from logs to traces.\n- **Unified export**: No OTLP endpoint or sampling configuration, so integration with a central observability platform is difficult.\n\nAdopting OpenTelemetry allows the three components to gain consistent metrics, logs, and tracing without changing core logic, with the ability to disable or tune sampling via environment variables for production.\n\n### Goals\n\n- Integrate the OpenTelemetry SDK (Go) into execd, egress, and ingress to emit **Metrics**, **Logs**, and **Traces**.\n- **Metrics**: Cover HTTP, code execution, Jupyter, filesystem operations, and system resources (execd); DNS, policy, nftables, and system resources (egress); HTTP/WebSocket proxy requests, routing resolution, status codes, and system resources (ingress).\n- **Logging**: Extend the existing zap logger to automatically add `trace_id` and `span_id`, with context-aware logging.\n- **Tracing**: Instrument key paths (HTTP requests, code execution, DNS lookups, policy evaluation, ingress proxy and routing) with spans.\n- **Configuration**: Provide full initialization and support for OTLP exporters, sampling, and environment variables; default to no export or low sampling so deployments without observability backends are unaffected.\n\n### Non-Goals\n\n- Do not replace existing execd HTTP metric endpoints such as `GetMetrics`/`WatchMetrics`; they can coexist with OpenTelemetry metrics.\n- Do not implement OpenTelemetry on the server (Python) in this proposal; scope is limited to the three Go components (execd, egress, ingress).\n- Do not commit to vendor-specific backends (e.g., Datadog, New Relic); export is via the standard OTLP protocol only.\n- Do not require a Collector; both direct OTLP and via-Collector export are supported.\n\n## Requirements\n\n| ID | Requirement | Priority |\n|----|-------------|----------|\n| R1 | execd/egress/ingress support exporting Metrics, Logs, and Traces via OTLP | Must Have |\n| R2 | Metrics cover all execd, egress, and ingress metric items listed in this proposal | Must Have |\n| R3 | Logs automatically include trace_id and span_id, with values taken from context | Must Have |\n| R4 | Key paths (HTTP, code execution, DNS, policy, ingress proxy) have trace spans | Must Have |\n| R5 | Configuration via environment variables (endpoint, sampling, toggles) without code changes | Must Have |\n| R6 | Default or unset config results in no export or low sampling to avoid impacting deployments without observability | Should Have |\n| R7 | Compatible with existing zap Logger interface; no breaking changes to Logger abstraction | Should Have |\n\n## Proposal\n\nIntroduce an **OpenTelemetry initialization module** in the main startup of execd, egress, and ingress that:\n\n1. Creates and registers a **MeterProvider** and **MetricReader** (e.g., OTLP exporter).\n2. Creates a **TracerProvider** with a sampler such as **TraceIDRatioBased** and registers an OTLP trace exporter.\n3. Optionally sets up a **LoggerProvider** or **zap enhancement** so that log fields include trace/span information.\n4. Reads OTLP endpoint, sampling rate, service name, etc., from environment variables (or config files).\n\nApplication code records metrics and spans on critical paths and, when logging, extracts the current span’s trace_id/span_id from `context.Context` into zap fields. Metrics, logs, and traces then align semantically and can be exported to the same observability platform via OTLP. Egress and ingress both use the standard library `net/http` (egress for the policy API ServeMux, ingress for the proxy Handler); wrap the `Handler` or use middleware such as otelhttp to create a span and context per request. Execd uses Gin and can use the otelgin middleware.\n\n### Notes/Constraints/Caveats\n\n- OpenTelemetry Go SDK version and stability must match the project’s Go version; prefer the stable API (e.g., `go.opentelemetry.io/otel` v1).\n- Metric and span names should follow OpenTelemetry semantic conventions (e.g., HTTP attributes, metric units) for compatibility with generic dashboards.\n- egress may run as a sidecar in the same Pod as the workload; keep sampling and export batching configurable to limit sidecar CPU/memory.\n- Log enhancements apply only to code paths using the shared Logger; code that uses the standard `log` package is out of scope for this proposal but can be migrated later.\n\n### Risks and Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| OTLP export failures or unreachable endpoint cause blocking or retry storms | Use async export, configurable timeouts and queue limits; on failure only log locally and do not affect the main flow |\n| High sampling rate produces too much trace data | Default to low or no sampling; configure via environment; recommend ≤ 0.1 in production |\n| High metric cardinality (e.g., per sandbox_id or raw URL path) | Avoid high-cardinality dimensions: only use aggregated dimensions such as status_code, operation; **HTTP metrics must use the route template `http.route`** (e.g. `/code/contexts/:contextId`), not the raw request path, or execd routes with path parameters will produce high-cardinality series that are hard to operate |\n| Divergence from existing metrics APIs | Leave existing HTTP metric endpoints unchanged; OpenTelemetry metrics are additive |\n\n## Design Details\n\n### 1. Metrics\n\n#### 1.1 execd metrics\n\n| Category | Metric name (suggested) | Type | Description |\n|----------|-------------------------|------|-------------|\n| **HTTP** | `execd.http.request.count` | Counter | Request count by method, **http.route (route template)**, status_code (QPS derivable) |\n| | `execd.http.request.duration` | Histogram | Request latency (s or ms) by method, **http.route (route template)** |\n| **Code execution** | `execd.execution.count` | Counter | Execution count by result (success/failure) |\n| | `execd.execution.duration` | Histogram | Duration per execution |\n| | `execd.execution.memory_bytes` | Histogram / Gauge | Memory usage during execution (if available) |\n| **Jupyter sessions** | `execd.jupyter.sessions.active` | UpDownCounter / Gauge | Current active sessions |\n| | `execd.jupyter.sessions.created_total` | Counter | Sessions created |\n| | `execd.jupyter.sessions.deleted_total` | Counter | Sessions deleted |\n| **Filesystem** | `execd.filesystem.operations.count` | Counter | Operation count by type (upload/download/list/delete, etc.) |\n| | `execd.filesystem.operations.duration` | Histogram | Operation duration |\n| **System** | `execd.system.cpu.usage` | Gauge | Process or host CPU usage (optional) |\n| | `execd.system.memory.usage_bytes` | Gauge | Memory usage |\n| | `execd.system.process.count` | Gauge | Current number of processes in the system |\n\nAll metrics are created via the OpenTelemetry Meter; units and attributes follow [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/).\n\n**Execd HTTP dimensions:** Several execd routes embed identifiers in the URL (e.g. `/code/contexts/:contextId`, `/session/:sessionId/run`, `/command/status/:id` in `components/execd/pkg/web/router.go`). Using the raw request path as a metric dimension would create high-cardinality time series and make OTLP/Prometheus metrics hard to operate. Therefore **the route template must be used as the dimension**: `http.route` (e.g. `/code/contexts/:contextId`), not the actual request path (e.g. `/code/contexts/abc-123`). Gin and middleware such as otelgin should be configured to record the matched route pattern as `http.route`.\n\n#### 1.2 egress metrics\n\n| Category | Metric name (suggested) | Type | Description |\n|----------|-------------------------|------|-------------|\n| **DNS** | `egress.dns.queries.count` | Counter | DNS query count (QPS derivable) |\n| | `egress.dns.query.duration` | Histogram | Per-query latency |\n| | `egress.dns.cache.hits_total` | Counter | Cache hits |\n| | `egress.dns.cache.misses_total` | Counter | Cache misses (hit rate = hits / (hits + misses)) |\n| **Policy** | `egress.policy.evaluations.count` | Counter | Evaluations by action (allow/deny) |\n| | `egress.policy.denied_total` | Counter | Denials; block rate derivable with evaluations |\n| **nftables** | `egress.nftables.rules.count` | Gauge | Current rule count |\n| | `egress.nftables.updates.count` | Counter | Rule update count (update frequency observable) |\n| **System** | `egress.system.cpu.usage` | Gauge | CPU usage |\n| | `egress.system.memory.usage_bytes` | Gauge | Memory usage |\n\n#### 1.3 ingress metrics\n\n| Category | Metric name (suggested) | Type | Description |\n|----------|-------------------------|------|-------------|\n| **HTTP** | `ingress.http.request.count` | Counter | Request count by method, status_code, proxy_type (http/websocket) (QPS derivable) |\n| | `ingress.http.request.duration` | Histogram | Request duration (including routing and proxy) by method, proxy_type |\n| **Routing** | `ingress.routing.resolutions.count` | Counter | Resolutions by result (success/not_found/not_ready/error) |\n| | `ingress.routing.resolution.duration` | Histogram | Time to resolve sandbox target (from cache or API) |\n| **Proxy type** | `ingress.proxy.http.requests_total` | Counter | HTTP proxy request count |\n| | `ingress.proxy.websocket.connections_total` | Counter | WebSocket connection count |\n| **System** | `ingress.system.cpu.usage` | Gauge | CPU usage |\n| | `ingress.system.memory.usage_bytes` | Gauge | Memory usage |\n\nNote: Ingress typically returns 200 (success), 400 (bad request), 404 (sandbox not found), 502 (upstream error), 503 (sandbox not ready); aggregate by `http.status_code` for error-rate monitoring.\n\nMetric namespaces are `execd.*`, `egress.*`, and `ingress.*` for easy filtering in a shared backend.\n\n**Custom metric dimensions (env):** Provide an env-based hook so users can define **extra metric dimensions** (not limited to sandbox_id). For example, support **`OPENSANDBOX_OTEL_METRICS_EXTRA_ATTRIBUTES`** (or equivalent): a comma-separated list of attribute names (e.g. `sandbox_id`, `tenant_id`, or custom keys). When recording metrics, if the current context or request carries those attributes, they are reported as dimensions on that data point; when unset or empty, no extra dimensions are added. This lets users opt in to “aggregate by sandbox_id” or any custom dimension and accept the cardinality and cost. Implementations must document that this option increases cardinality and should be used only when entity count is bounded.\n\n### 2. Logging\n\n- **Zap enhancement**: In `components/internal/logger` (zap implementation), add the ability to read the current span’s `TraceID` and `SpanID` from `context.Context` and inject them as zap fields, e.g.:\n  - Add `LoggerWithContext(ctx context.Context) Logger`, or at call sites use `logger.With(Field{Key: \"trace_id\", Value: trace.SpanFromContext(ctx).SpanContext().TraceID().String()})` (and similarly for span_id).\n- **Context-aware**: Handlers and middleware that receive `context.Context` should use a logger that has trace/span injected so all logs for the same request share the same trace_id.\n- **Filter/query by sandbox_id**: When a request or operation is associated with a sandbox (e.g. execd handling a request for that sandbox, ingress proxying to that sandbox), log records **must** include a filterable sandbox identifier (recommend a consistent attribute name such as `opensandbox.sandbox_id` or `sandbox_id`) so that log backends can filter and query by sandbox_id for per-sandbox debugging.\n- **Correlation**: If OTLP Logs are used, log records can carry trace_id/span_id and link to the Traces backend for “click from log to trace” workflows.\n\nImplementation options:\n\n- A zap `Core` or `Hook` that reads span from `context.Context` and adds fields (requires middleware to propagate context with span through the request path).\n- A `log.Ctx(ctx).Infof(...)`-style helper that gets span from ctx and calls zap.\n\nThe existing `Logger` interface (`Infof`, `With`, `Named`) stays unchanged; only context-based construction or trace-field helpers are added.\n\n### 3. Tracing\n\n- **HTTP (execd: Gin)**\n  execd uses Gin (`components/execd/pkg/web/router.go`). Register OpenTelemetry HTTP middleware (e.g., `otelgin`) on its routes so each request gets a span with `http.method`, `http.route`, `http.status_code`, etc. When the request is associated with a sandbox (e.g. API call for that sandbox), the span **must** include `sandbox_id` (or a consistent name such as `opensandbox.sandbox_id`) so that traces can be filtered and queried by sandbox_id in Jaeger and similar backends. Pass the request’s context downstream so business logic and logging use the same trace.\n\n- **HTTP (egress: net/http)**\n  egress exposes its policy API from a net/http `ServeMux` (`components/egress/policy_server.go`), not Gin. Instrument egress’s HTTP entry points the same way as ingress: wrap the `http.Handler` or use net/http-compatible middleware (e.g., `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`) to create a span per request and pass context with span downstream, so that R1/R4 are met for egress HTTP.\n\n- **ingress HTTP**  \n  Ingress uses `net/http`. Wrap the `http.Handler` (or use middleware) to create a root span per request (e.g., `ingress.proxy`) with `http.method`, `http.route`, `http.status_code`, `ingress.mode` (header/uri). When the request is routed to a sandbox, the span **must** include `sandbox_id` (or `opensandbox.sandbox_id`) so that traces can be filtered and queried by sandbox_id. Pass context with span from `ServeHTTP` into the proxy for logs and child spans.\n\n- **ingress proxy forwarding**  \n  When forwarding to the target sandbox, create a child span (e.g., `ingress.forward`) with target host and proxy_type (http/websocket). Sandbox resolution (sandbox_id → backend address from sandbox provider) can be a separate child span (e.g., `ingress.resolve`) with attribute resolution_result (success/not_found/not_ready/error) to distinguish 404/503/502 in the trace.\n\n- **Code execution**  \n  At execd’s execution entry (e.g., `ExecuteCode`/run), create a child span such as `execution.run` with attributes like `execd.operation=execute` and result. If there are multiple steps (prepare, run, cleanup), add child spans per step.\n\n- **DNS query**  \n  In egress DNS proxy, create a span per query (e.g., `dns.query`) with domain, result (allow/deny), cache hit/miss.\n\n- **Policy evaluation**  \n  In egress policy evaluation, create a span (e.g., `policy.evaluate`) with target (domain/IP) and action (allow/deny).\n\nAll spans are children of the HTTP request span when entered via HTTP, so the full call tree is visible in UIs like Jaeger.\n\n### 4. Initialization and configuration\n\n- **Initialization**  \n  Implement `InitOpenTelemetry(ctx context.Context, opts InitOptions) (shutdown func(), err error)` in main for execd, egress, and ingress (or in a shared `pkg/telemetry`):\n  - Create `MeterProvider` and register an OTLP metric exporter (e.g., `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp` or gRPC).\n  - Create `TracerProvider` with a `TraceIDRatioBased` sampler and register an OTLP trace exporter.\n  - Optionally create `LoggerProvider` and register an OTLP log exporter; otherwise rely on zap enhancement and the standard Logs Bridge.\n  - Set global `otel.SetMeterProvider`, `otel.SetTracerProvider`, etc., and return a `shutdown` function (Flush + ForceFlush) to call on process exit.\n\n- **OTLP exporter**  \n  Support HTTP and gRPC OTLP endpoints via environment variables:\n  - `OTEL_EXPORTER_OTLP_ENDPOINT` (or per-signal `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`).\n  - If unset, do not export or use a Noop provider to avoid connection errors.\n\n- **Sampling**  \n  - Use `OTEL_TRACES_SAMPLER_ARG` (0.0–1.0 for ratio sampler).\n  - Or `OTEL_TRACES_SAMPLER=parentbased_traceidratio` with `OTEL_TRACES_SAMPLER_ARG=0.1`.\n\n- **Environment variables**  \n  Support at least (names follow OpenTelemetry conventions):\n  - `OTEL_SERVICE_NAME`: service name (execd / egress / ingress).\n  - `OTEL_EXPORTER_OTLP_ENDPOINT` (or per-signal endpoints).\n  - `OTEL_TRACES_SAMPLER`, `OTEL_TRACES_SAMPLER_ARG`.\n  - `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER` (e.g., `none` to disable).\n  - `OTEL_RESOURCE_ATTRIBUTES`: key-value pairs for resource attributes (e.g., deployment.env);\n  - **`OPENSANDBOX_OTEL_METRICS_EXTRA_ATTRIBUTES`**: comma-separated list of **custom metric dimension** attribute names (e.g. `sandbox_id`, `tenant_id`, or custom keys). When recording metrics, if the context or request carries an attribute with that name, it is added as an extra dimension on the data point; when unset or empty, no extra dimensions are added. This allows opt-in “aggregate by sandbox_id” or other custom dimensions; users assume cardinality and cost. Document that this increases cardinality and is best when entity count is bounded.\n\nOptionally read some of these from existing config or flags and allow environment variables to override.\n\n## Test Plan\n\n- **Unit tests**\n  - Metrics: Create a MeterProvider with an in-memory or mock exporter, run business logic, assert exported metric count and key attributes.\n  - Logging: Build context with a span, call LoggerWithContext and log, assert output contains trace_id and span_id.\n  - Tracing: Use sdktrace.NewTracerProvider with a SpanRecorder or in-memory exporter, run one request flow, assert span names and parent-child relationships.\n- **Integration tests**\n  - Start execd/egress/ingress with OTLP endpoint pointing at a test Collector or mock; send HTTP requests and trigger execution/DNS/policy/proxy; verify OTLP payloads contain expected metrics and traces.\n- **Configuration**\n  - When `OTEL_EXPORTER_OTLP_*` is unset, no connection is made and no error is raised.\n  - When sampling rate is 0, no spans are produced.\n  - Environment variables override config file where applicable.\n\nAcceptance: With OTLP enabled and sampling configured, Jaeger shows full traces (HTTP → execution/DNS/policy/ingress proxy); Prometheus or the backend shows all execd, egress, and ingress metrics listed above; log lines include trace_id/span_id that link to traces.\n\n## Drawbacks\n\n- Additional dependencies and binary size (OpenTelemetry SDK and OTLP exporters).\n- Under high QPS, even with low sampling, tracing and metrics add some CPU/memory cost; control via sampling and aggregation dimensions.\n- Correct log–trace correlation requires passing `context.Context` through the call chain; some legacy code may need small changes.\n\n## Infrastructure Needed\n\n- **Go dependencies**\n  - `go.opentelemetry.io/otel`\n  - `go.opentelemetry.io/otel/sdk`\n  - `go.opentelemetry.io/otel/exporters/otlp/...` (metrics/traces/logs, HTTP or gRPC as needed)\n  - Optional: `go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin` (execd only); egress and ingress use net/http and require wrapping the Handler with e.g. `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`.\n- **Runtime**\n  - For direct OTLP: an reachable OTLP endpoint (e.g., OpenTelemetry Collector, Jaeger, or an OTLP-capable backend).\n  - For “no export” mode: no extra infrastructure.\n\n## Upgrade & Migration Strategy\n\n- **Backward compatibility**: No changes to existing HTTP metric endpoints or Logger interface; only new initialization and optional env vars. With OpenTelemetry unconfigured, behavior is unchanged.\n- **Rollout**\n  1. Ship initialization and config code with OTLP endpoint unset (noop).\n  2. Enable OTLP and low sampling in test; verify metrics and traces.\n  3. Add metric and span instrumentation in execd/egress/ingress handlers and zap trace injection.\n  4. Enable in production and tune sampling and endpoint as needed.\n- **Rollback**: Unset or clear `OTEL_EXPORTER_OTLP_*` to stop export; no code change required.\n"
  },
  {
    "path": "oseps/CONTRIBUTING.md",
    "content": "# OSEP (OpenSandbox Enhancement Proposals)\n\nUse this directory to draft, review, and store enhancement proposals before they\nundergo broader discussion.\n\n> [!NOTE]\n> The OSEP process and template structure is inspired by\n> [Tekton Enhancement Proposals (TEPs)](https://github.com/tektoncd/community/tree/main/teps).\n\n> [!IMPORTANT]\n> **When is an OSEP required?**\n>\n> Use the OSEP process for changes that:\n> - Introduce new features or major enhancements\n> - Modify the core sandbox API or runtime behavior\n> - Affect the security model or isolation guarantees\n>\n> Small bug fixes, documentation updates, and minor refactors can be submitted\n> directly as Pull Requests without an OSEP.\n\n## Getting started\n\n1. Run the init script to create a new proposal:\n\n   ```bash\n   oseps/init-osep.sh \"Proposal Title\"\n   ```\n\n   This copies the template, fills in metadata, and creates a sequentially\n   numbered `0001-proposal-title.md` draft.\n\n2. Fill in each section from the template (`Summary`, `Motivation`, …).\n3. Once ready, submit the resulting file in a PR for community review.\n\n**Available options:**\n\n```bash\noseps/init-osep.sh --help\noseps/init-osep.sh --status provisional --author \"@username\" \"My Feature\"\n```\n\n## Template\n\nThe template used for new proposals lives at `oseps/osep-template.md.template`\nand mirrors Tekton's TEP structure while capturing the key sections needed\nfor OpenSandbox planning. Each generated file starts with YAML front matter\nfollowed by the title and TOC:\n\n```yaml\n---\ntitle: My First Proposal\nauthors:\n  - \"@your-github-handle\"\ncreation-date: 2025-12-21\nlast-updated: 2025-12-21\nstatus: draft\n---\n\n# OSEP-0001: My First Proposal\n\n<!-- toc -->\n- [Summary](#summary)\n...\n<!-- /toc -->\n```\n\nThis YAML front matter renders as a table on GitHub and keeps the proposal\nmetadata (status, authors, dates) visible at the top of the document.\n\n## Status lifecycle\n\n| Status | Description |\n|--------|-------------|\n| `draft` | Work in progress; not yet under formal review. |\n| `provisional` | Maintainers agree with the direction; design details still pending. |\n| `implementable` | Design approved and compliance checks passed; ready for implementation. |\n| `implementing` | Code is being merged and SDKs are being synchronized. |\n| `implemented` | Feature has reached GA status with complete documentation. |\n| `withdrawn` | Author has withdrawn the proposal. |\n| `rejected` | Maintainers have declined the proposal. |\n"
  },
  {
    "path": "oseps/README.md",
    "content": "# OpenSandbox Enhancement Proposals\n\nSee the [OSEP contributing](CONTRIBUTING.md) for information on OSEPs and how to create and merge them.\n\nThis is the complete list of OpenSandbox Enhancement Proposals:\n\n|                            OSEP                            |                   Title                    |    Status     | Last Updated |\n|:----------------------------------------------------------:|:------------------------------------------:|:-------------:|:------------:|\n|       [OSEP-0001](0001-fqdn-based-egress-control.md)       |         FQDN-based Egress Control          |  implemented  |  2026-01-22  |\n| [OSEP-0002](0002-kubernetes-sigs-agent-sandbox-support.md) |   kubernetes-sigs/agent-sandbox Support    |  implemented  |  2026-01-23  |\n|   [OSEP-0003](0003-volume-and-volumebinding-support.md)    |               Volume Support               | implementing  |  2026-02-11  |\n|       [OSEP-0004](0004-secure-container-runtime.md)        | Pluggable Secure Container Runtime Support | implementing  |  2026-02-09  |\n|       [OSEP-0005](0005-client-side-sandbox-pool.md)        |          Client-Side Sandbox Pool          | implementing  |  2026-03-09  |\n|           [OSEP-0006](0006-developer-console.md)           |  Developer Console for Sandbox Operations  | implementable |  2026-03-06  |\n|     [OSEP-0007](0007-fast-sandbox-runtime-support.md)      |        Fast Sandbox Runtime Support        |  provisional  |  2026-02-08  |\n|   [OSEP-0008](0008-pause-resume-rootfs-snapshot.md)        |     Pause and Resume via Rootfs Snapshot   |     draft     |  2026-03-13  |\n| [OSEP-0009](0009-auto-renew-sandbox-on-ingress-access.md)  |    Auto-Renew Sandbox on Ingress Access    | implementing  |  2026-03-18  |"
  },
  {
    "path": "oseps/init-osep.sh",
    "content": "#!/usr/bin/env bash\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Helper to bootstrap a new OpenSandbox Enhancement Proposal (OSEP).\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nTEMPLATE=\"$SCRIPT_DIR/osep-template.md.template\"\n\n# Valid status values\nVALID_STATUSES=\"draft provisional implementable implementing implemented withdrawn rejected\"\n\nusage() {\n    cat <<EOF\nUsage: $(basename \"$0\") [OPTIONS] <title>\n\nCreate a new OpenSandbox Enhancement Proposal\n\nArguments:\n  title                 Proposal title (will appear in the document header)\n\nOptions:\n  -s, --status STATUS   Initial status of the proposal (default: draft)\n                        Valid: draft, provisional, implementable, implementing,\n                               implemented, withdrawn, rejected\n  -a, --author AUTHOR   Author(s) to attribute in the new proposal\n  -o, --output PATH     Explicit path to write the new proposal\n  -h, --help            Show this help message\n\nExamples:\n  $(basename \"$0\") \"Network Control\"\n  $(basename \"$0\") --status provisional --author \"@user\" \"New Feature\"\nEOF\n}\n\nslugify() {\n    local title=\"$1\"\n    echo \"$title\" \\\n        | tr '[:upper:]' '[:lower:]' \\\n        | sed -E 's/[^a-z0-9 _-]//g' \\\n        | sed -E 's/[ _-]+/-/g' \\\n        | sed -E 's/^-+|-+$//g'\n}\n\ndefault_author() {\n    local author\n    author=$(git config user.name 2>/dev/null || true)\n    if [[ -z \"$author\" ]]; then\n        author=$(git config user.email 2>/dev/null || true)\n    fi\n    if [[ -z \"$author\" ]]; then\n        author=\"${USER:-Unknown Author}\"\n    fi\n    echo \"$author\"\n}\n\nnext_sequence() {\n    local highest=0\n    for file in \"$SCRIPT_DIR\"/[0-9][0-9][0-9][0-9]-*.md; do\n        [[ -f \"$file\" ]] || continue\n        local basename\n        basename=$(basename \"$file\")\n        local num=\"${basename%%-*}\"\n        # Remove leading zeros for arithmetic\n        num=$((10#$num))\n        if (( num > highest )); then\n            highest=$num\n        fi\n    done\n    echo $((highest + 1))\n}\n\nvalidate_status() {\n    local status=\"$1\"\n    for valid in $VALID_STATUSES; do\n        if [[ \"$status\" == \"$valid\" ]]; then\n            return 0\n        fi\n    done\n    echo \"Error: Invalid status '$status'\" >&2\n    echo \"Valid statuses: $VALID_STATUSES\" >&2\n    exit 1\n}\n\n# Parse arguments\nTITLE=\"\"\nSTATUS=\"draft\"\nAUTHOR=\"\"\nOUTPUT=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n    case \"$1\" in\n        -h|--help)\n            usage\n            exit 0\n            ;;\n        -s|--status)\n            STATUS=$(printf '%s' \"$2\" | tr '[:upper:]' '[:lower:]')\n            shift 2\n            ;;\n        -a|--author)\n            AUTHOR=\"$2\"\n            shift 2\n            ;;\n        -o|--output)\n            OUTPUT=\"$2\"\n            shift 2\n            ;;\n        -*)\n            echo \"Error: Unknown option $1\" >&2\n            usage >&2\n            exit 1\n            ;;\n        *)\n            if [[ -z \"$TITLE\" ]]; then\n                TITLE=\"$1\"\n            else\n                echo \"Error: Unexpected argument '$1'\" >&2\n                usage >&2\n                exit 1\n            fi\n            shift\n            ;;\n    esac\ndone\n\n# Validate required arguments\nif [[ -z \"$TITLE\" ]]; then\n    echo \"Error: title is required\" >&2\n    usage >&2\n    exit 1\nfi\n\n# Validate status\nvalidate_status \"$STATUS\"\n\n# Set defaults\nif [[ -z \"$AUTHOR\" ]]; then\n    AUTHOR=$(default_author)\nfi\n\nDATE=$(date +%Y-%m-%d)\nSLUG=$(slugify \"$TITLE\")\n\n# Determine destination\nif [[ -z \"$OUTPUT\" ]]; then\n    SEQ=$(next_sequence)\n    PROPOSAL_ID=$(printf \"%04d\" \"$SEQ\")\n    DESTINATION=\"$SCRIPT_DIR/${PROPOSAL_ID}-${SLUG}.md\"\n\n    # Ensure unique filename\n    while [[ -f \"$DESTINATION\" ]]; do\n        SEQ=$((SEQ + 1))\n        PROPOSAL_ID=$(printf \"%04d\" \"$SEQ\")\n        DESTINATION=\"$SCRIPT_DIR/${PROPOSAL_ID}-${SLUG}.md\"\n    done\nelse\n    DESTINATION=\"$OUTPUT\"\n    PROPOSAL_ID=$(basename \"$DESTINATION\" | sed -E 's/^([0-9]+)-.*/\\1/')\nfi\n\n# Check if destination exists\nif [[ -f \"$DESTINATION\" ]]; then\n    echo \"Refusing to overwrite existing proposal at $DESTINATION\" >&2\n    exit 1\nfi\n\n# Verify template exists\nif [[ ! -f \"$TEMPLATE\" ]]; then\n    echo \"Error: OSEP template not found at $TEMPLATE\" >&2\n    exit 1\nfi\n\n# Render template using pure bash substitution (avoids sed escaping issues)\ncontent=$(<\"$TEMPLATE\")\ncontent=\"${content//\\{\\{title\\}\\}/$TITLE}\"\ncontent=\"${content//\\{\\{author\\}\\}/$AUTHOR}\"\ncontent=\"${content//\\{\\{status_metadata\\}\\}/$STATUS}\"\ncontent=\"${content//\\{\\{date\\}\\}/$DATE}\"\ncontent=\"${content//\\{\\{proposal_id\\}\\}/$PROPOSAL_ID}\"\nprintf '%s\\n' \"$content\" > \"$DESTINATION\"\n\necho \"Created $DESTINATION\"\n"
  },
  {
    "path": "oseps/osep-template.md.template",
    "content": "---\ntitle: {{title}}\nauthors:\n  - \"{{author}}\"\ncreation-date: {{date}}\nlast-updated: {{date}}\nstatus: {{status_metadata}}\n---\n\n# OSEP-{{proposal_id}}: {{title}}\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Requirements](#requirements)\n- [Proposal](#proposal)\n  - [Notes/Constraints/Caveats](#notesconstraintscaveats)\n  - [Risks and Mitigations](#risks-and-mitigations)\n- [Design Details](#design-details)\n- [Test Plan](#test-plan)\n- [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n- [Infrastructure Needed](#infrastructure-needed)\n- [Upgrade & Migration Strategy](#upgrade--migration-strategy)\n<!-- /toc -->\n\n## Summary\n\n<!--\nBrief summary of the proposal. Describe the feature/change and why it matters.\nAim for 2-3 sentences that explain the problem and proposed solution.\n-->\n\n## Motivation\n\n<!--\nExplain why this work matters and the problem it solves.\nWhat is the current pain point? Why should OpenSandbox care?\n-->\n\n### Goals\n\n<!--\nSpecific, measurable objectives. What does success look like?\nExamples:\n- Reduce deployment time by X%\n- Enable feature Y for users\n- Improve reliability of Z\n-->\n\n### Non-Goals\n\n<!--\nClarify what is intentionally out of scope.\nWhat will NOT be addressed by this proposal?\n-->\n\n## Requirements\n\n<!--\nList any constraints that must be satisfied.\nWhat are the hard requirements vs nice-to-haves?\n-->\n\n## Proposal\n\n<!--\nHigh-level description of the proposed approach.\nFocus on what, not how. Avoid implementation details.\nInclude diagrams or examples if helpful.\n-->\n\n### Notes/Constraints/Caveats\n\n<!--\n(Optional) Any additional context that reviewers should know up front.\n-->\n\n### Risks and Mitigations\n\n<!--\nHighlight critical risks and how they will be managed.\nConsider: performance, security, compatibility, operational aspects.\n-->\n\n## Design Details\n\n<!--\nDetailed implementation specifics:\n- APIs and interfaces\n- Data models and schema changes\n- Algorithm or logic flow\n- Configuration changes\n-->\n\n## Test Plan\n\n<!--\nOutline how the change will be verified and tested.\nInclude: unit tests, integration tests, QA/E2E tests, manual testing.\nWhat scenarios must be covered?\n-->\n\n## Drawbacks\n\n<!--\nWhat arguments exist against this direction?\nWhat are the trade-offs?\n-->\n\n## Alternatives\n\n<!--\nSummarize other approaches that were evaluated.\nWhy was this proposal chosen over alternatives?\n-->\n\n## Infrastructure Needed\n\n<!--\n(Optional) List any new tooling, repos, or environments required.\nDo we need new services, storage, or third-party dependencies?\n-->\n\n## Upgrade & Migration Strategy\n\n<!--\n(Optional) Describe the migration path for users/operators if needed.\nHow will existing setups be upgraded? Are there breaking changes?\n-->\n"
  },
  {
    "path": "sandboxes/code-interpreter/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nFROM opensandbox/code-interpreter-base:latest\n\n# Install Python kernels\nRUN set -euo pipefail \\\n    && echo \"Setting up ipykernel for Python 3.10, 3.11, 3.12, 3.13, 3.14\" \\\n    && versions=(\"3.10\" \"3.11\" \"3.12\" \"3.13\" \"3.14\") \\\n    && for version in \"${versions[@]}\"; do \\\n        echo \"Setting up ipykernel for Python $version\" \\\n        && . /opt/opensandbox/code-interpreter-env.sh python $version \\\n        && python3 --version \\\n        && python3 -m pip install ipykernel jupyter bash_kernel --break-system-packages; \\\n    done \\\n    && echo \"Setting up ipykernel complete\"\n\n# Install Java kernel\nRUN set -euo pipefail \\\n    && echo \"Setting up IJava kernel\" \\\n    && curl -L https://github.com/SpencerPark/IJava/releases/download/v1.3.0/ijava-1.3.0.zip -o /tmp/ijava.zip \\\n    && mkdir -p /opt/ijava \\\n    && unzip -o /tmp/ijava.zip -d /opt/ijava \\\n    && rm -rf /tmp/ijava.zip \\\n    && echo \"Setting up IJava kernel done\"\n\n# Install tslab for Node.js versions\nRUN set -euo pipefail \\\n    && echo \"Setting up tslab kernel\" \\\n    && versions=(\"18\" \"20\" \"22\") \\\n    && for version in \"${versions[@]}\"; do \\\n        echo \"Setting up tslab for Node $version\" \\\n        && . /opt/opensandbox/code-interpreter-env.sh node $version \\\n        && node --version \\\n        && npm --version \\\n        && npm install -g tslab; \\\n    done \\\n    && echo \"Setting up tslab kernel done\"\n\n# Install Go tooling for gonb\nRUN set -euo pipefail \\\n    && echo \"Setting up gonb\" \\\n    && versions=(\"1.25\" \"1.24\" \"1.23\") \\\n    && for version in \"${versions[@]}\"; do \\\n        echo \"Setting up gonb for Go $version\" \\\n        && . /opt/opensandbox/code-interpreter-env.sh go $version \\\n        && go version \\\n        && go install github.com/janpfeifer/gonb@latest \\\n        && go install golang.org/x/tools/cmd/goimports@latest \\\n        && go install golang.org/x/tools/gopls@latest; \\\n    done \\\n    && echo \"Setting up gonb done\"\n\nENV JUPYTER_HOST=http://127.0.0.1:44771 \\\n    JUPYTER_PORT=44771 \\\n    JUPYTER_TOKEN=opensandboxcodeinterpreterjupyter \\\n    PYTHON_VERSION=3.14 \\\n    NODE_VERSION=22 \\\n    GO_VERSION=1.25 \\\n    JAVA_VERSION=21\n\nCOPY scripts/code-interpreter.sh /opt/opensandbox/code-interpreter.sh\nCOPY scripts/jupyter_notebook_config.py /root/.jupyter/\nRUN chmod +x /opt/opensandbox/code-interpreter.sh\n\nWORKDIR /workspace\nENTRYPOINT [\"/opt/opensandbox/code-interpreter.sh\"]\n"
  },
  {
    "path": "sandboxes/code-interpreter/Dockerfile_base",
    "content": "# syntax=docker/dockerfile:1\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nFROM ubuntu:24.04\n\nARG TARGETARCH\n\n# Use bash for RUN commands to support source and arrays\nSHELL [\"/bin/bash\", \"-c\"]\n\nENV DEBIAN_FRONTEND=noninteractive \\\n    LANG=C.UTF-8 \\\n    MAVEN_VERSION=3.9.2 \\\n    MAVEN_HOME=/opt/maven \\\n    UV_PYTHON_INSTALL_DIR=/opt/python/versions \\\n    NODE_ROOT=/opt/node \\\n    GO_ROOT=/opt/go \\\n    NODE_V18=18.20.3 \\\n    NODE_V20=20.14.0 \\\n    NODE_V22=22.2.0 \\\n    GO_V1_25=1.25.5 \\\n    GO_V1_24=1.24.11 \\\n    GO_V1_23=1.23.12\n\n# 1. Install common tools\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    ca-certificates curl wget git vim nano unzip zip tar build-essential \\\n    software-properties-common gnupg lsb-release \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 2. Install Java (8, 11, 17, 21)\nRUN add-apt-repository universe && apt-get update && apt-get install -y --no-install-recommends \\\n    openjdk-8-jdk openjdk-11-jdk openjdk-17-jdk openjdk-21-jdk \\\n    && rm -rf /var/lib/apt/lists/*\n\n# 3. Install Maven\nRUN mkdir -p ${MAVEN_HOME} && \\\n    curl -fsSL https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \\\n    | tar -xzC ${MAVEN_HOME} --strip-components=1 && \\\n    ln -s ${MAVEN_HOME}/bin/mvn /usr/local/bin/mvn\n\n# 4. Install Python (3.10 - 3.14) using uv\nRUN curl -LsSf https://astral.sh/uv/install.sh | sh && \\\n    mv /root/.local/bin/uv /usr/local/bin/uv && \\\n    mv /root/.local/bin/uvx /usr/local/bin/uvx && \\\n    mkdir -p /opt/python/versions && \\\n    uv python install 3.10 3.11 3.12 3.13 && \\\n    (uv python install 3.14 || echo \"Python 3.14 skipped\")\n\n# 5. Install Node.js (18, 20, 22)\nRUN mkdir -p ${NODE_ROOT} && \\\n    ARCH=\"\" && \\\n    if [ -z \"${TARGETARCH}\" ]; then \\\n      case \"$(uname -m)\" in \\\n        x86_64) TARGETARCH=\"amd64\" ;; \\\n        aarch64) TARGETARCH=\"arm64\" ;; \\\n        *) echo \"Unsupported architecture: $(uname -m)\"; exit 1 ;; \\\n      esac; \\\n    fi && \\\n    case \"${TARGETARCH}\" in \\\n      \"amd64\") ARCH=\"x64\" ;; \\\n      \"arm64\") ARCH=\"arm64\" ;; \\\n      *) echo \"Unsupported architecture: ${TARGETARCH}\"; exit 1 ;; \\\n    esac && \\\n    cd ${NODE_ROOT} && \\\n    for v in ${NODE_V18} ${NODE_V20} ${NODE_V22}; do \\\n        curl -fsSL https://nodejs.org/dist/v${v}/node-v${v}-linux-${ARCH}.tar.xz | tar -xJ && \\\n        mv node-v${v}-linux-${ARCH} v${v}; \\\n    done\n\n# 6. Install Go (latest three major versions)\nRUN mkdir -p ${GO_ROOT} && \\\n    ARCH=\"\" && \\\n    if [ -z \"${TARGETARCH}\" ]; then \\\n      case \"$(uname -m)\" in \\\n        x86_64) TARGETARCH=\"amd64\" ;; \\\n        aarch64) TARGETARCH=\"arm64\" ;; \\\n        *) echo \"Unsupported architecture: $(uname -m)\"; exit 1 ;; \\\n      esac; \\\n    fi && \\\n    case \"${TARGETARCH}\" in \\\n      \"amd64\") ARCH=\"amd64\" ;; \\\n      \"arm64\") ARCH=\"arm64\" ;; \\\n      *) echo \"Unsupported architecture: ${TARGETARCH}\"; exit 1 ;; \\\n    esac && \\\n    cd ${GO_ROOT} && \\\n    for v in ${GO_V1_25} ${GO_V1_24} ${GO_V1_23}; do \\\n        curl -fsSL https://go.dev/dl/go${v}.linux-${ARCH}.tar.gz | tar -xz && \\\n        mv go ${v}; \\\n    done\n\n# 7. Configure defaults & env script\nENV GOROOT=${GO_ROOT}/${GO_V1_25} \\\n    PATH=\"${NODE_ROOT}/v${NODE_V22}/bin:${GO_ROOT}/${GO_V1_25}/bin:${PATH}\"\n\nRUN mkdir -p /opt/opensandbox\nCOPY scripts/code-interpreter-env.sh /opt/opensandbox/code-interpreter-env.sh\nRUN chmod +x /opt/opensandbox/code-interpreter-env.sh\n\nCMD [\"/bin/bash\"]\n"
  },
  {
    "path": "sandboxes/code-interpreter/README.md",
    "content": "# OpenSandbox Code Interpreter Environment\n\nEnglish | [中文](README_zh.md)\n\nThis directory contains the Docker build files for the Code Interpreter sandbox. The image is based on `Ubuntu 24.04`\nand comes pre-installed with multiple mainstream programming languages and their multi-version environments, designed to\nprovide an out-of-the-box multi-language code execution environment.\n\n## Features\n\n- **Multi-Language Support**: Pre-installed Python, Java, Node.js, and Go with multiple versions\n- **Version Switching**: Easy runtime version switching without rebuilding\n- **Jupyter Integration**: Built-in Jupyter Notebook with multi-language kernels\n- **Multi-Architecture**: Supports both amd64 and arm64 architectures\n- **Production Ready**: Optimized for containerized execution environments\n\n## Supported Languages & Versions\n\nThe image comes pre-installed with the following languages and versions:\n\n| Language    | Supported Versions            | Installation Path      | Notes                                    |\n|:------------|:------------------------------|:-----------------------|:-----------------------------------------|\n| **Python**  | 3.10, 3.11, 3.12, 3.13, 3.14* | `/opt/python/versions` | Installed via `uv`; 3.14 is experimental |\n| **Java**    | 8, 11, 17, 21                 | `/usr/lib/jvm`         | OpenJDK; includes Maven 3.9.2            |\n| **Node.js** | v18, v20, v22                 | `/opt/node`            | Official Linux binaries                  |\n| **Go**      | 1.23, 1.24, 1.25              | `/opt/go`              | Official Linux binaries                  |\n\n*> Note: Version numbers may be updated to the latest patch versions at build time.*\n\n## Quick Start\n\n### 1. Build the Image\n\nSince multi-architecture (amd64/arm64) is supported, it's recommended to use Docker Buildx:\n\n```bash\n# Navigate to the directory\ncd sandboxes/code-interpreter\n\n# Build local image\ndocker build -t opensandbox/code-interpreter:latest .\n\n# For multi-architecture builds (requires Docker Buildx)\ndocker buildx build --platform linux/amd64,linux/arm64 \\\n  -t opensandbox/code-interpreter:latest .\n```\n\n### 2. Run the Container\n\n**With Custom Version Selection:**\n\n```bash\ndocker run -it --rm \\\n  -e PYTHON_VERSION=3.11 \\\n  -e JAVA_VERSION=17 \\\n  -e NODE_VERSION=20 \\\n  -e GO_VERSION=1.24 \\\n  opensandbox/code-interpreter:latest\n```\n\n## Version Switching\n\nThe image includes a built-in version switching script `/opt/opensandbox/code-interpreter-env.sh`. You need to use the\n`source` command to load it to modify the current shell's environment variables.\n\n### Basic Usage\n\n```bash\nsource /opt/opensandbox/code-interpreter-env.sh <language> <version>\n```\n\n### Examples\n\n**Switch Python Version:**\n\n```bash\n# Switch to Python 3.11\nsource /opt/opensandbox/code-interpreter-env.sh python 3.11\npython3 --version\n# Output: Python 3.11.x\n```\n\n**Switch Java Version:**\n\n```bash\n# Switch to Java 8\nsource /opt/opensandbox/code-interpreter-env.sh java 8\njava -version\n```\n\n**Switch Node.js Version:**\n\n```bash\n# Switch to Node 22\nsource /opt/opensandbox/code-interpreter-env.sh node 22\nnode -v\n```\n\n**Switch Go Version:**\n\n```bash\n# Switch to Go 1.25\nsource /opt/opensandbox/code-interpreter-env.sh go 1.25\ngo version\n```\n\n### List Available Versions\n\nIf you don't specify a version number, the script will list all available versions installed in the current image:\n\n```bash\n# List all Python versions\nsource /opt/opensandbox/code-interpreter-env.sh python\n\n# List all Java versions\nsource /opt/opensandbox/code-interpreter-env.sh java\n\n# List all Node.js versions\nsource /opt/opensandbox/code-interpreter-env.sh node\n\n# List all Go versions\nsource /opt/opensandbox/code-interpreter-env.sh go\n```\n\n## Default Versions\n\nThe default version configuration when the container starts:\n\n- **Python**: 3.14\n- **Java**: 21\n- **Node.js**: 22\n- **Go**: 1.25\n\nTo permanently modify the default version at the Dockerfile level, adjust the `ENV PATH` settings at the bottom of the\nDockerfile.\n\n## Jupyter Notebook Integration\n\n### Available Kernels\n\nThe image comes with pre-configured Jupyter kernels for all supported languages:\n\n- **Python**: ipykernel for all Python versions\n- **Java**: IJava kernel\n- **TypeScript/JavaScript**: tslab kernel\n- **Go**: gonb kernel\n- **Bash**: bash_kernel\n\n### Starting Jupyter\n\n```bash\n/opt/opensandbox/code-interpreter.sh\n```\n\n### Environment Variables\n\n- `JUPYTER_HOST`: Jupyter server host (default: `http://127.0.0.1:44771`)\n- `JUPYTER_PORT`: Jupyter server port (default: `44771`)\n- `JUPYTER_TOKEN`: Access token (default: `opensandboxcodeinterpreterjupyter`)\n\n## Advanced Usage\n\n### Persistent Workspace\n\nMount a local directory to persist your work:\n\n```bash\ndocker run -it --rm \\\n  -v $(pwd)/workspace:/workspace \\\n  opensandbox/code-interpreter:latest\n```\n\n### Custom Configuration\n\nOverride Jupyter configuration:\n\n```bash\ndocker run -it --rm \\\n  -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \\\n  opensandbox/code-interpreter:latest\n```\n\n### Install Additional Packages\n\n**Python:**\n\n```bash\npython3 -m pip install pandas numpy --break-system-packages\n```\n\n**Node.js:**\n\n```bash\nnpm install -g typescript\n```\n\n**Go:**\n\n```bash\ngo install github.com/user/package@latest\n```\n\n**Java:**\n\n```bash\nmvn install dependency:copy-dependencies\n```\n\n## Architecture\n\n```\ncode-interpreter/\n├── Dockerfile                          # Main build file\n├── Dockerfile_base                     # Base build file\n├── README.md                           # This file\n├── README_zh.md                        # Chinese README\n└── scripts/\n    ├── code-interpreter-env.sh         # Version switching script\n    ├── code-interpreter.sh             # Jupyter startup script\n    └── jupyter_notebook_config.py      # Jupyter configuration\n```\n\n## Troubleshooting\n\nIf a specific version is not found, list available versions:\n\n```bash\nsource /opt/opensandbox/code-interpreter-env.sh <language>\n```\n\n## License\n\nThis project is part of the OpenSandbox suite. See the main [LICENSE](../../LICENSE) file for details.\n\n## Support\n\nFor issues and questions:\n\n- GitHub Issues: [OpenSandbox Issues](https://github.com/alibaba/OpenSandbox/issues)\n\n## Related Projects\n\n- [OpenSandbox](../../) - Main project\n- [Server](../../server/) - Server implementation\n- [Execd](../../components/execd/) - Runtime execution engine\n"
  },
  {
    "path": "sandboxes/code-interpreter/README_zh.md",
    "content": "# OpenSandbox Code Interpreter 环境\n\n中文 | [English](README.md)\n\n这个目录包含了 Code Interpreter 沙箱的 Docker 构建文件。该镜像基于 `Ubuntu 24.04`\n，并预装了多种主流编程语言及其多版本环境，旨在提供一个开箱即用的多语言代码执行环境。\n\n## 特性\n\n- **多语言支持**：预装 Python、Java、Node.js 和 Go 及其多个版本\n- **版本切换**：无需重新构建，支持运行时快速切换版本\n- **Jupyter 集成**：内置 Jupyter Notebook 并支持多语言内核\n- **多架构支持**：同时支持 amd64 和 arm64 架构\n- **生产就绪**：针对容器化执行环境进行了优化\n\n## 支持的语言与版本\n\n镜像内预置了以下语言和版本：\n\n| 语言          | 支持版本                          | 安装路径                   | 备注                     |\n|:------------|:------------------------------|:-----------------------|:-----------------------|\n| **Python**  | 3.10, 3.11, 3.12, 3.13, 3.14* | `/opt/python/versions` | 使用 `uv` 安装；3.14 为实验性版本 |\n| **Java**    | 8, 11, 17, 21                 | `/usr/lib/jvm`         | OpenJDK; 含 Maven 3.9.2 |\n| **Node.js** | v18, v20, v22                 | `/opt/node`            | 官方 Linux 二进制包          |\n| **Go**      | 1.23, 1.24, 1.25              | `/opt/go`              | 官方 Linux 二进制包          |\n\n*> 注意: 版本号可能会随构建时间更新至小版本的最新版。*\n\n## 快速开始\n\n### 1. 构建镜像\n\n由于支持多架构（amd64/arm64），建议使用 Docker Buildx 构建：\n\n```bash\n# 进入目录\ncd sandboxes/code-interpreter\n\n# 构建本地镜像\ndocker build -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest .\n\n# 多架构构建（需要 Docker Buildx）\ndocker buildx build --platform linux/amd64,linux/arm64 \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest .\n```\n\n### 2. 运行容器\n\n**指定自定义版本：**\n\n```bash\ndocker run -it --rm \\\n  -e PYTHON_VERSION=3.11 \\\n  -e JAVA_VERSION=17 \\\n  -e NODE_VERSION=20 \\\n  -e GO_VERSION=1.24 \\\n  sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\n```\n\n## 如何切换版本\n\n镜像内置了一个环境切换脚本 `/opt/opensandbox/code-interpreter-env.sh`，你需要使用 `source` 命令加载它来修改当前 Shell\n的环境变量。\n\n### 基本用法\n\n```bash\nsource /opt/opensandbox/code-interpreter-env.sh <language> <version>\n```\n\n### 示例\n\n**切换 Python 版本：**\n\n```bash\n# 切换到 Python 3.11\nsource /opt/opensandbox/code-interpreter-env.sh python 3.11\npython3 --version\n# Output: Python 3.11.x\n```\n\n**切换 Java 版本：**\n\n```bash\n# 切换到 Java 8\nsource /opt/opensandbox/code-interpreter-env.sh java 8\njava -version\n```\n\n**切换 Node.js 版本：**\n\n```bash\n# 切换到 Node 22\nsource /opt/opensandbox/code-interpreter-env.sh node 22\nnode -v\n```\n\n**切换 Go 版本：**\n\n```bash\n# 切换到 Go 1.25\nsource /opt/opensandbox/code-interpreter-env.sh go 1.25\ngo version\n```\n\n### 查看可用版本\n\n如果不指定版本号，脚本会列出当前镜像内已安装的可用版本：\n\n```bash\n# 查看所有 Python 版本\nsource /opt/opensandbox/code-interpreter-env.sh python\n\n# 查看所有 Java 版本\nsource /opt/opensandbox/code-interpreter-env.sh java\n\n# 查看所有 Node.js 版本\nsource /opt/opensandbox/code-interpreter-env.sh node\n\n# 查看所有 Go 版本\nsource /opt/opensandbox/code-interpreter-env.sh go\n```\n\n## 默认版本\n\n容器启动时的默认版本配置如下：\n\n- **Python**: 3.14\n- **Java**: 21\n- **Node.js**: 22\n- **Go**: 1.25\n\n如需在 Dockerfile 层面永久修改默认版本，请调整 Dockerfile 底部的 `ENV PATH` 设置。\n\n## Jupyter Notebook 集成\n\n### 可用内核\n\n镜像预装了所有支持语言的 Jupyter 内核：\n\n- **Python**：所有 Python 版本的 ipykernel\n- **Java**：IJava 内核\n- **TypeScript/JavaScript**：tslab 内核\n- **Go**：gonb 内核\n- **Bash**：bash_kernel\n\n### 启动 Jupyter\n\n```bash\n/opt/opensandbox/code-interpreter.sh\n```\n\n### 环境变量\n\n- `JUPYTER_HOST`：Jupyter 服务器地址（默认：`http://127.0.0.1:44771`）\n- `JUPYTER_PORT`：Jupyter 服务器端口（默认：`44771`）\n- `JUPYTER_TOKEN`：访问令牌（默认：`opensandboxcodeinterpreterjupyter`）\n\n## 高级用法\n\n### 持久化工作空间\n\n挂载本地目录以持久化您的工作：\n\n```bash\ndocker run -it --rm \\\n  -v $(pwd)/workspace:/workspace \\\n  sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\n```\n\n### 自定义配置\n\n覆盖 Jupyter 配置：\n\n```bash\ndocker run -it --rm \\\n  -v $(pwd)/jupyter_config.py:/root/.jupyter/jupyter_notebook_config.py \\\n  sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\n```\n\n### 安装额外的包\n\n**Python：**\n\n```bash\npython3 -m pip install pandas numpy --break-system-packages\n```\n\n**Node.js：**\n\n```bash\nnpm install -g typescript\n```\n\n**Go：**\n\n```bash\ngo install github.com/user/package@latest\n```\n\n**Java：**\n\n```bash\nmvn install dependency:copy-dependencies\n```\n\n## 架构说明\n\n```\ncode-interpreter/\n├── Dockerfile                          # 镜像Dockerfile\n├── Dockerfile_base                     # 基础镜像Dockerfile\n├── README.md                           # 英文文档\n├── README_zh.md                        # 本文件\n└── scripts/\n    ├── code-interpreter-env.sh         # 版本切换脚本\n    ├── code-interpreter.sh             # Jupyter 启动脚本\n    └── jupyter_notebook_config.py      # Jupyter 配置文件\n```\n\n## 许可证\n\n此项目是 OpenSandbox 套件的一部分。详情请参阅主 [LICENSE](../../LICENSE) 文件。\n\n## 支持\n\n问题和疑问：\n\n- GitHub Issues: [OpenSandbox Issues](https://github.com/alibaba/OpenSandbox/issues)\n\n## 相关项目\n\n- [OpenSandbox](../../) - 主项目\n- [Server](../../server/) - 服务器实现\n- [Execd](../../components/execd/) - 运行时执行引擎\n"
  },
  {
    "path": "sandboxes/code-interpreter/build.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm code-interpreter-builder || true\n\ndocker buildx create --use --name code-interpreter-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\n#docker buildx build -t opensandbox/code-interpreter-base:${TAG} \\\n#  --platform linux/amd64,linux/arm64 \\\n#  -f Dockerfile_base \\\n#  --push \\\n#  .\n\ndocker buildx build \\\n  -t opensandbox/code-interpreter:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:${TAG} \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  .\n"
  },
  {
    "path": "sandboxes/code-interpreter/scripts/code-interpreter-env.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# This script is used to switch versions of different languages in the OpenSandbox environment\n# Usage: source /opt/opensandbox/code-interpreter-env.sh <language> <version>\n# Examples:\n#   source /opt/opensandbox/code-interpreter-env.sh python 3.13\n#   source /opt/opensandbox/code-interpreter-env.sh java 21\n#   source /opt/opensandbox/code-interpreter-env.sh node 22\n#   source /opt/opensandbox/code-interpreter-env.sh go 1.25\n\nfunction usage() {\n\techo \"Usage: source code-interpreter-env.sh <language> <version>\"\n\techo \"Supported languages: python, java, node, go\"\n}\n\nDEFAULT_PY_VERSION=${DEFAULT_PY_VERSION:-3.13}\nDEFAULT_JAVA_VERSION=${DEFAULT_JAVA_VERSION:-21}\nDEFAULT_NODE_VERSION=${DEFAULT_NODE_VERSION:-22}\nDEFAULT_GO_VERSION=${DEFAULT_GO_VERSION:-1.25}\n\nappend_env_if_needed() {\n\tlocal key=$1\n\tlocal value=$2\n\tif [ -z \"${EXECD_ENVS:-}\" ]; then\n\t\treturn\n\tfi\n\t# Best-effort: ensure parent dir exists, ignore errors.\n\tmkdir -p \"$(dirname \"$EXECD_ENVS\")\" 2>/dev/null || true\n\tprintf '%s=%s\\n' \"$key\" \"$value\" >>\"$EXECD_ENVS\" 2>/dev/null || true\n}\n\nfunction switch_python() {\n\tlocal version=$1\n\tif [ -z \"$version\" ]; then\n\t\techo \"Available Python versions:\"\n\t\tfind /opt/python/versions -maxdepth 1 -name \"cpython-*\" -type d -printf \"%f\\n\" | cut -d'-' -f2 | sort -V\n\t\treturn\n\tfi\n\n\t# Find matching version directory\n\tlocal target_dir=$(find /opt/python/versions -maxdepth 1 -type d -name \"cpython-${version}*\" | sort -V | tail -n 1)\n\n\tif [ -d \"$target_dir\" ]; then\n\t\texport PATH=\"$target_dir/bin:$PATH\"\n\t\tappend_env_if_needed PATH \"$PATH\"\n\t\techo \"Switched to Python $(python3 --version)\"\n\telse\n\t\techo \"Python version $version not found.\"\n\tfi\n}\n\nfunction switch_java() {\n\tlocal version=$1\n\tif [ -z \"$version\" ]; then\n\t\techo \"Available Java versions:\"\n\t\tls /usr/lib/jvm/ | grep -E '^java-[0-9]+-openjdk' | cut -d'-' -f2 | sort -V | uniq\n\t\treturn\n\tfi\n\n\t# Match openjdk path\n\tlocal java_home=\"\"\n\tif [ -d \"/usr/lib/jvm/java-${version}-openjdk-amd64\" ]; then\n\t\tjava_home=\"/usr/lib/jvm/java-${version}-openjdk-amd64\"\n\telif [ -d \"/usr/lib/jvm/java-${version}-openjdk-arm64\" ]; then # ARM compatibility\n\t\tjava_home=\"/usr/lib/jvm/java-${version}-openjdk-arm64\"\n\tfi\n\n\tif [ -n \"$java_home\" ]; then\n\t\texport JAVA_HOME=\"$java_home\"\n\t\texport PATH=\"$JAVA_HOME/bin:$PATH\"\n\t\tappend_env_if_needed JAVA_HOME \"$JAVA_HOME\"\n\t\tappend_env_if_needed PATH \"$PATH\"\n\t\techo \"Switched to Java $version ($JAVA_HOME)\"\n\telse\n\t\techo \"Java version $version not found.\"\n\tfi\n}\n\nfunction switch_node() {\n\tlocal version=$1\n\tif [ -z \"$version\" ]; then\n\t\techo \"Available Node versions:\"\n\t\tls /opt/node/\n\t\treturn\n\tfi\n\n\t# Find matching version (e.g. v18 -> v18.x.x)\n\tlocal target_dir=$(find /opt/node -maxdepth 1 -type d -name \"v${version}*\" | sort -V | tail -n 1)\n\n\tif [ -d \"$target_dir\" ]; then\n\t\texport PATH=\"$target_dir/bin:$PATH\"\n\t\tappend_env_if_needed PATH \"$PATH\"\n\t\techo \"Switched to Node $(node --version)\"\n\telse\n\t\techo \"Node version $version not found.\"\n\tfi\n}\n\nfunction switch_go() {\n\tlocal version=$1\n\tif [ -z \"$version\" ]; then\n\t\techo \"Available Go versions:\"\n\t\tls /opt/go/\n\t\treturn\n\tfi\n\n\t# Find matching version\n\tlocal target_dir=$(find /opt/go -maxdepth 1 -type d -name \"${version}*\" | sort -V | tail -n 1)\n\n\tif [ -d \"$target_dir\" ]; then\n\t\texport GOROOT=\"$target_dir\"\n\t\texport PATH=\"$GOROOT/bin:$PATH\"\n\t\tappend_env_if_needed GOROOT \"$GOROOT\"\n\t\tappend_env_if_needed PATH \"$PATH\"\n\t\techo \"Switched to Go $(go version)\"\n\telse\n\t\techo \"Go version $version not found.\"\n\tfi\n}\n\n# Main logic\nLANG=$1\nVER=$2\n\nif [ -z \"$LANG\" ]; then\n\tusage\n\treturn\nfi\n\ncase $LANG in\npython)\n\tif [ -z \"$VER\" ]; then\n\t\tVER=$DEFAULT_PY_VERSION\n\tfi\n\tswitch_python $VER\n\t;;\njava)\n\tif [ -z \"$VER\" ]; then\n\t\tVER=$DEFAULT_JAVA_VERSION\n\tfi\n\tswitch_java $VER\n\t;;\nnode)\n\tif [ -z \"$VER\" ]; then\n\t\tVER=$DEFAULT_NODE_VERSION\n\tfi\n\tswitch_node $VER\n\t;;\ngo)\n\tif [ -z \"$VER\" ]; then\n\t\tVER=$DEFAULT_GO_VERSION\n\tfi\n\tswitch_go $VER\n\t;;\n*)\n\techo \"Unsupported language: $LANG\"\n\tusage\n\t;;\nesac\n"
  },
  {
    "path": "sandboxes/code-interpreter/scripts/code-interpreter.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\ndeclare -a pids=()\nBASHRC_FILE=${BASHRC_FILE:-/root/.bashrc}\n\nrecord_env_selection() {\n\tlocal lang=$1\n\tlocal version=$2\n\n\tif [ -z \"$version\" ]; then\n\t\treturn\n\tfi\n\n\tlocal tmp_file\n\ttmp_file=$(mktemp)\n\n\tif [ -f \"$BASHRC_FILE\" ]; then\n\t\tgrep -vE \"^source /opt/opensandbox/code-interpreter-env.sh ${lang}(\\\\s|$)\" \"$BASHRC_FILE\" >\"$tmp_file\" || true\n\telse\n\t\t: >\"$tmp_file\"\n\tfi\n\n\techo \"source /opt/opensandbox/code-interpreter-env.sh ${lang} ${version}\" >>\"$tmp_file\"\n\tmv \"$tmp_file\" \"$BASHRC_FILE\"\n}\n\nif [ -n \"${PYTHON_VERSION:-}\" ]; then\n\tsource /opt/opensandbox/code-interpreter-env.sh python \"${PYTHON_VERSION}\"\n\trecord_env_selection python \"${PYTHON_VERSION}\"\nelse\n\tsource /opt/opensandbox/code-interpreter-env.sh python\nfi\n\nif [ -n \"${JAVA_VERSION:-}\" ]; then\n\tsource /opt/opensandbox/code-interpreter-env.sh java \"${JAVA_VERSION}\"\n\trecord_env_selection java \"${JAVA_VERSION}\"\nelse\n\tsource /opt/opensandbox/code-interpreter-env.sh java\nfi\n\nif [ -n \"${NODE_VERSION:-}\" ]; then\n\tsource /opt/opensandbox/code-interpreter-env.sh node \"${NODE_VERSION}\"\n\trecord_env_selection node \"${NODE_VERSION}\"\nelse\n\tsource /opt/opensandbox/code-interpreter-env.sh node\nfi\n\nif [ -n \"${GO_VERSION:-}\" ]; then\n\tsource /opt/opensandbox/code-interpreter-env.sh go \"${GO_VERSION}\"\n\trecord_env_selection go \"${GO_VERSION}\"\nelse\n\tsource /opt/opensandbox/code-interpreter-env.sh go\nfi\n\nsetup_python() {\n\tpython --version\n\ttime {\n\t\tpython3 -m ipykernel install --name python --display-name \"Python\"\n\t}\n}\n\nsetup_java() {\n\ttime {\n\t\tpython3 /opt/ijava/install.py --sys-prefix\n\t}\n}\n\n# setup node\nsetup_node() {\n\ttime {\n\t\tnpm install -g tslab\n\t\ttslab install\n\t}\n}\n\nsetup_go() {\n\ttime {\n\t\t# shellcheck disable=SC2155\n\t\tgonb --install\n\t}\n}\n\nsetup_bash() {\n\ttime {\n\t\tpython3 -m bash_kernel.install\n\t}\n}\n\n# export go bin path\nexport PATH=\"$(go env GOPATH)/bin:$PATH\"\nif [ -n \"${EXECD_ENVS:-}\" ]; then\n\tmkdir -p \"$(dirname \"$EXECD_ENVS\")\" 2>/dev/null || true\n\tprintf 'PATH=%s\\n' \"$PATH\" >>\"$EXECD_ENVS\" 2>/dev/null || true\nfi\n\nsetup_python &\npids+=($!)\nsetup_java &\npids+=($!)\nsetup_node &\npids+=($!)\nsetup_go &\npids+=($!)\nsetup_bash &\npids+=($!)\n\njupyter notebook --ip=127.0.0.1 --port=\"${JUPYTER_PORT:-44771}\" --allow-root --no-browser --NotebookApp.token=\"${JUPYTER_TOKEN:-opensandboxcodeinterpreterjupyter}\" >/opt/opensandbox/jupyter.log\n"
  },
  {
    "path": "sandboxes/code-interpreter/scripts/jupyter_notebook_config.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# This code is based on or derived from Jupyter Notebook\n# Copyright (c) 2015 Jupyter Development Team\n# Licensed under BSD 3-Clause License\n# https://github.com/jupyter/notebook/blob/main/LICENSE\n\n# Configuration file for notebook.\n\nc = get_config()  #noqa\n"
  },
  {
    "path": "scripts/add-license.sh",
    "content": "#!/bin/bash\n\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Add Apache 2.0 license headers to source files that are missing them.\n# Usage: run from repo root: ./scripts/add-license.sh\n\nset -euo pipefail\n\nLICENSE_YEAR=$(date +%Y)\nLICENSE_OWNER=\"Alibaba Group Holding Ltd.\"\nLICENSE_MARKER_REGEX=\"Copyright [0-9]{4} ${LICENSE_OWNER// / }\"\nLICENSE_TEXT_TEMPLATE=$(\n  cat <<'EOF'\nCopyright __YEAR__ Alibaba Group Holding Ltd.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\nEOF\n)\n\n# Basenames to include regardless of extension.\nINCLUDE_BASENAMES=(\"Dockerfile\")\n\n# Paths to ignore.\nIGNORES=(\n  \".git\"\n  \"node_modules\"\n  \".venv\"\n  \"venv\"\n  \"dist\"\n  \"build\"\n  \"__pycache__\"\n  \"LICENSE\"\n  \"NOTICE\"\n  \"README.md\"\n  \"README_zh.md\"\n  \"scripts/spec-doc/index.html\"\n)\n\nhas_license() {\n  local file=\"$1\"\n  head -n 40 \"$file\" | grep -Eq \"$LICENSE_MARKER_REGEX\"\n}\n\ncomment_header() {\n  local style=\"$1\"\n  local text=\"$2\"\n  case \"$style\" in\n    \"line:#\")\n      printf '%s\\n' \"$text\" | sed 's/^/# /'\n      ;;\n    \"line://\")\n      printf '%s\\n' \"$text\" | sed 's:^:// :'\n      ;;\n    \"block:html\")\n      printf \"<!--\\n%s\\n-->\\n\" \"$text\"\n      ;;\n    \"block:css\")\n      printf \"/*\\n%s\\n*/\\n\" \"$text\"\n      ;;\n    *)\n      return 1\n      ;;\n  esac\n}\n\nshould_ignore_path() {\n  local file=\"$1\"\n  for ignore in \"${IGNORES[@]}\"; do\n    if [[ \"$file\" == \"$ignore\" || \"$file\" == \"$ignore\"/* ]]; then\n      return 0\n    fi\n  done\n  return 1\n}\n\nis_k8s_mock_go() {\n  local file=\"${1-}\"\n  [[ -z \"$file\" ]] && return 1\n  # Skip any Go mocks under kubernetes/internal:\n  # - filenames ending with _mock.go\n  # - any file under a /mock/ directory\n  if [[ \"$file\" != kubernetes/internal/* ]]; then\n    return 1\n  fi\n  if [[ \"$file\" == *\"_mock.go\" ]]; then\n    return 0\n  fi\n  if [[ \"$file\" == */mock/*.go ]]; then\n    return 0\n  fi\n  return 1\n}\n\nis_generated_go() {\n  local file=\"${1-}\"\n  [[ -z \"$file\" ]] && return 1\n  [[ \"${file##*.}\" != \"go\" ]] && return 1\n\n  local base\n  base=\"$(basename \"$file\")\"\n  case \"$base\" in\n    zz_generated.*|*.pb.go|*_pb.go|*_gen.go|*_generated.go)\n      return 0\n      ;;\n  esac\n\n  if head -n 5 \"$file\" | grep -qi \"code generated\"; then\n    return 0\n  fi\n\n  return 1\n}\n\nstyle_for_file() {\n  local file=\"${1-}\"\n  [[ -z \"$file\" ]] && { echo \"\"; return; }\n  local base ext\n  base=\"$(basename \"$file\")\"\n  ext=\"${file##*.}\"\n\n  case \"$ext\" in\n    sh|py|toml|tf|sql) echo \"line:#\"; return ;;\n    go|java|kt|kts|ts|tsx|js|jsx|mjs|cjs|mts|cts) echo \"line://\"; return ;;\n    css) echo \"block:css\"; return ;;\n    html) echo \"block:html\"; return ;;\n  esac\n\n  for b in \"${INCLUDE_BASENAMES[@]}\"; do\n    if [[ \"$base\" == \"$b\" ]]; then\n      echo \"line:#\"\n      return\n    fi\n  done\n\n  echo \"\"\n}\n\nprocess_file() {\n  local file=\"${1-}\"\n  [[ -z \"$file\" ]] && return\n  local style\n\n  if should_ignore_path \"$file\"; then\n    return\n  fi\n\n  if is_k8s_mock_go \"$file\"; then\n    return\n  fi\n\n  style=\"$(style_for_file \"$file\")\"\n  [[ -z \"$style\" ]] && return\n\n  if is_generated_go \"$file\"; then\n    return\n  fi\n\n  if has_license \"$file\"; then\n    return\n  fi\n\n  local license_text header\n  license_text=\"${LICENSE_TEXT_TEMPLATE/__YEAR__/$LICENSE_YEAR}\"\n  header=\"$(comment_header \"$style\" \"$license_text\")\"\n\n  # Respect shebang: insert after the first line if it starts with #!\n  if head -n1 \"$file\" | grep -q \"^#!\"; then\n    local first rest\n    first=\"$(head -n1 \"$file\")\"\n    rest=\"$(tail -n +2 \"$file\")\"\n    printf '%s\\n\\n%s\\n\\n%s' \"$first\" \"$header\" \"$rest\" >\"$file\"\n  # Place before DOCTYPE for HTML to avoid breaking rendering.\n  elif head -n1 \"$file\" | grep -qi \"^<!doctype\"; then\n    local body\n    body=\"$(cat \"$file\")\"\n    printf '%s\\n\\n%s' \"$header\" \"$body\" >\"$file\"\n  else\n    local body\n    body=\"$(cat \"$file\")\"\n    printf '%s\\n\\n%s' \"$header\" \"$body\" >\"$file\"\n  fi\n  echo \"Added license: $file\"\n}\n\nmain() {\n  local files\n  if [[ \"$#\" -gt 0 ]]; then\n    IFS=$'\\n' read -r -d '' -a files < <(git ls-files -- \"$@\" && printf '\\0')\n  else\n    IFS=$'\\n' read -r -d '' -a files < <(git ls-files && printf '\\0')\n  fi\n  for f in \"${files[@]}\"; do\n    process_file \"$f\"\n  done\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "scripts/bump-component-version.sh",
    "content": "#!/bin/bash\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Bump egress or execd image version across the entire project.\n# Usage: from repo root:\n#   ./scripts/bump-component-version.sh egress v1.0.2\n#   ./scripts/bump-component-version.sh execd v1.0.7\n#   ./scripts/bump-component-version.sh v1.0.2              # same as: egress v1.0.2\n\nset -euo pipefail\n\nREPO_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\ncd \"$REPO_ROOT\"\n\n# Parse args: [egress|execd] NEW_VERSION  or  NEW_VERSION (default egress)\nCOMPONENT=\"\"\nNEW_VERSION=\"\"\nif [ $# -eq 1 ]; then\n  COMPONENT=\"egress\"\n  NEW_VERSION=\"$1\"\nelif [ $# -eq 2 ]; then\n  COMPONENT=\"$1\"\n  NEW_VERSION=\"$2\"\nelse\n  echo \"Usage: $0 [egress|execd] NEW_VERSION\" >&2\n  echo \"       $0 NEW_VERSION   # bumps egress\" >&2\n  echo \"Example: $0 egress v1.0.2\" >&2\n  echo \"Example: $0 execd 1.0.7\" >&2\n  exit 1\nfi\n\ncase \"$COMPONENT\" in\n  egress|execd|code-interpreter) ;;\n  *)\n    echo \"Error: unsupported component: $COMPONENT\" >&2\n    exit 0\n    ;;\nesac\n\n# Normalize version: ensure 'v' prefix\nif [[ ! \"$NEW_VERSION\" =~ ^v ]]; then\n  NEW_VERSION=\"v${NEW_VERSION}\"\nfi\n\n# Pattern and replacement for this component (e.g. egress:vX.Y.Z -> egress:NEW_VERSION)\nPATTERN=\"${COMPONENT}:v[0-9]+\\.[0-9]+\\.[0-9]+\"\nREPLACEMENT=\"${COMPONENT}:${NEW_VERSION}\"\n\nfiles=()\nwhile IFS= read -r f; do\n  [ -n \"$f\" ] && files+=(\"$f\")\ndone < <(grep -rEl --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=node_modules \"$PATTERN\" . 2>/dev/null || true)\n\nupdated=0\nfor f in \"${files[@]}\"; do\n  [ -f \"$f\" ] || continue\n  if perl -i -pe \"s/$PATTERN/$REPLACEMENT/g\" \"$f\" 2>/dev/null; then\n    echo \"Updated $f\"\n    ((updated++)) || true\n  fi\ndone\n\nif [ \"$updated\" -eq 0 ]; then\n  echo \"No files were updated (no matches for $PATTERN).\" >&2\n  exit 1\nfi\n\necho \"Done. Bumped $COMPONENT version to $NEW_VERSION in $updated file(s).\"\n"
  },
  {
    "path": "scripts/csharp-e2e.sh",
    "content": "#!/bin/bash\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -euxo pipefail\n\nTAG=${TAG:-latest}\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\n# build execd image locally (context must include internal/)\ndocker build -f components/execd/Dockerfile -t opensandbox/execd:local \"${REPO_ROOT}\"\n\n# prepare required images from registry\ndocker pull opensandbox/code-interpreter:${TAG}\necho \"-------- Eval test images --------\"\ndocker images\n\n# prepare hostpath volume for e2e test\nmkdir -p /tmp/opensandbox-e2e/host-volume-test\nmkdir -p /tmp/opensandbox-e2e/logs\necho \"opensandbox-e2e-marker\" > /tmp/opensandbox-e2e/host-volume-test/marker.txt\nchmod -R 755 /tmp/opensandbox-e2e\n\n# prepare Docker named volume for pvc e2e test\ndocker volume rm opensandbox-e2e-pvc-test 2>/dev/null || true\ndocker volume create opensandbox-e2e-pvc-test\ndocker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c \"\\\n  echo 'pvc-marker-data' > /data/marker.txt && \\\n  mkdir -p /data/datasets/train && \\\n  echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt\"\necho \"-------- CSHARP E2E test logs for execd --------\" > /tmp/opensandbox-e2e/logs/execd.log\n\n# setup server\ncd server\n: > server.log\n(uv sync && uv run python -m src.main) > server.log 2>&1 &\ncd ..\n\n# wait for server\nsleep 10\n\n# test env for C# fixture\nexport OPENSANDBOX_TEST_DOMAIN=\"localhost:8080\"\nexport OPENSANDBOX_TEST_PROTOCOL=\"http\"\nexport OPENSANDBOX_TEST_API_KEY=\"\"\nexport OPENSANDBOX_SANDBOX_DEFAULT_IMAGE=\"opensandbox/code-interpreter:${TAG}\"\n\nmkdir -p tests/csharp/build/test-results\ndotnet restore \"tests/csharp/OpenSandbox.E2ETests/OpenSandbox.E2ETests.csproj\"\ndotnet test \"tests/csharp/OpenSandbox.E2ETests/OpenSandbox.E2ETests.csproj\" \\\n  --configuration Release \\\n  --no-restore \\\n  --results-directory \"tests/csharp/build/test-results\" \\\n  --logger \"trx;LogFileName=csharp-e2e.trx\"\n"
  },
  {
    "path": "scripts/java-e2e.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -euxo pipefail\n\nTAG=${TAG:-latest}\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\n# build execd image locally (context must include internal/)\ndocker build -f components/execd/Dockerfile -t opensandbox/execd:local \"${REPO_ROOT}\"\n\n# prepare required images from registry\ndocker pull opensandbox/code-interpreter:${TAG}\necho \"-------- Eval test images --------\"\ndocker images\n\n# prepare hostpath volume for e2e test\nmkdir -p /tmp/opensandbox-e2e/host-volume-test\nmkdir -p /tmp/opensandbox-e2e/logs\necho \"opensandbox-e2e-marker\" > /tmp/opensandbox-e2e/host-volume-test/marker.txt\nchmod -R 755 /tmp/opensandbox-e2e\n\n# prepare Docker named volume for pvc e2e test\ndocker volume rm opensandbox-e2e-pvc-test 2>/dev/null || true\ndocker volume create opensandbox-e2e-pvc-test\n# seed the named volume with a marker file and subpath test data via a temporary container\ndocker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c \"\\\n  echo 'pvc-marker-data' > /data/marker.txt && \\\n  mkdir -p /data/datasets/train && \\\n  echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt\"\necho \"-------- JAVA E2E test logs for execd --------\" > /tmp/opensandbox-e2e/logs/execd.log\n\n# setup server\ncd server\nuv sync && uv run python -m src.main > server.log 2>&1 &\ncd ..\n\n# wait for server\nsleep 10\n\ncd sdks/sandbox/kotlin\n./gradlew clean publishToMavenLocal --no-build-cache\ncd ../../../\n\ncd sdks/code-interpreter/kotlin\n./gradlew clean publishToMavenLocal --no-build-cache -PuseMavenLocal\ncd ../../../\n\n# run Java e2e\ncd tests/java\n./gradlew test\n"
  },
  {
    "path": "scripts/javascript-e2e.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -euxo pipefail\n\nTAG=${TAG:-latest}\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\n# build execd image locally (context must include internal/)\ndocker build -f components/execd/Dockerfile -t opensandbox/execd:local \"${REPO_ROOT}\"\n\n# prepare required images from registry\ndocker pull opensandbox/code-interpreter:${TAG}\necho \"-------- Eval test images --------\"\ndocker images\n\n# prepare hostpath volume for e2e test\nmkdir -p /tmp/opensandbox-e2e/host-volume-test\nmkdir -p /tmp/opensandbox-e2e/logs\necho \"opensandbox-e2e-marker\" > /tmp/opensandbox-e2e/host-volume-test/marker.txt\nchmod -R 755 /tmp/opensandbox-e2e\n\n# prepare Docker named volume for pvc e2e test\ndocker volume rm opensandbox-e2e-pvc-test 2>/dev/null || true\ndocker volume create opensandbox-e2e-pvc-test\n# seed the named volume with a marker file and subpath test data via a temporary container\ndocker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c \"\\\n  echo 'pvc-marker-data' > /data/marker.txt && \\\n  mkdir -p /data/datasets/train && \\\n  echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt\"\necho \"-------- JAVASCRIPT E2E test logs for execd --------\" > /tmp/opensandbox-e2e/logs/execd.log\n\n# setup server\ncd server\nuv sync && uv run python -m src.main > server.log 2>&1 &\ncd ..\n\n# wait for server\nsleep 10\n\n# run JavaScript/TypeScript e2e (SDK builds are handled by the test script)\ncd tests/javascript\n\n# Pin pnpm via corepack (repo expects pnpm@9.x)\ncorepack enable\ncorepack prepare pnpm@9.15.0 --activate\n\npnpm install\n\n# Ensure SDK workspace deps exist before running build steps (CI does not have prebuilt node_modules).\npnpm -C ../../sdks install --frozen-lockfile\n\n# Align with other E2E jobs: local server does not require API key by default.\n# Ensure tests do not send an auth header.\nexport OPENSANDBOX_TEST_API_KEY=\"\"\nexport OPENSANDBOX_SANDBOX_DEFAULT_IMAGE=\"opensandbox/code-interpreter:${TAG}\"\n\npnpm test:ci\n\n"
  },
  {
    "path": "scripts/python-e2e.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# This script verifies that required files contain the Apache 2.0 license header.\n# It scans tracked source files and fails with a list of violations if any header\n# is missing.\n\nset -euxo pipefail\n\nTAG=${TAG:-latest}\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\n# build execd image locally (context must include internal/)\ndocker build -f components/execd/Dockerfile -t opensandbox/execd:local \"${REPO_ROOT}\"\n\n# prepare required images from registry\ndocker pull opensandbox/code-interpreter:${TAG}\necho \"-------- Eval test images --------\"\ndocker images\n\n# prepare hostpath volume for e2e test\nmkdir -p /tmp/opensandbox-e2e/host-volume-test\nmkdir -p /tmp/opensandbox-e2e/logs\necho \"opensandbox-e2e-marker\" > /tmp/opensandbox-e2e/host-volume-test/marker.txt\nchmod -R 755 /tmp/opensandbox-e2e\n\n# prepare Docker named volume for pvc e2e test\ndocker volume rm opensandbox-e2e-pvc-test 2>/dev/null || true\ndocker volume create opensandbox-e2e-pvc-test\n# seed the named volume with a marker file and subpath test data via a temporary container\ndocker run --rm -v opensandbox-e2e-pvc-test:/data alpine sh -c \"\\\n  echo 'pvc-marker-data' > /data/marker.txt && \\\n  mkdir -p /data/datasets/train && \\\n  echo 'pvc-subpath-marker' > /data/datasets/train/marker.txt\"\necho \"-------- PYTHON E2E test logs for execd --------\" > /tmp/opensandbox-e2e/logs/execd.log\n\n# setup server\ncd server\nuv sync && uv run python -m src.main > server.log 2>&1 &\ncd ..\n\n# wait for server\nsleep 10\n\n# build local api\ncd sdks/sandbox/python && make generate-api\ncd ../../..\n\n# run real python e2e\ncd tests/python\nuv sync --all-extras --refresh && make test\n"
  },
  {
    "path": "scripts/spec-doc/generate-spec.js",
    "content": "#!/usr/bin/env node\n/**\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\n/**\n * Generate spec-inline.js from sandbox-lifecycle.yml\n *\n * Usage:\n *   node scripts/spec-doc/generate-spec.js\n *   node scripts/spec-doc/generate-spec.js --output docs/public/api/spec-inline.js\n *\n * This script:\n * 1. Reads specs/sandbox-lifecycle.yml\n * 2. Escapes backticks\n * 3. Wraps in JavaScript template literal\n * 4. Writes to docs/public/api/spec-inline.js (by default)\n */\n\nconst fs = require('fs');\nconst path = require('path');\n\n// Find project root\nfunction findProjectRoot() {\n  let dir = __dirname;\n  while (dir !== path.dirname(dir)) {\n    if (fs.existsSync(path.join(dir, 'specs', 'sandbox-lifecycle.yml'))) {\n      return dir;\n    }\n    dir = path.dirname(dir);\n  }\n  throw new Error('Could not find project root (sandbox-lifecycle.yml not found)');\n}\n\nfunction parseOutputPathArg(projectRoot) {\n  const outputFlagIndex = process.argv.indexOf('--output');\n  if (outputFlagIndex === -1) {\n    return path.join(projectRoot, 'docs', 'public', 'api', 'spec-inline.js');\n  }\n  const outputValue = process.argv[outputFlagIndex + 1];\n  if (!outputValue) {\n    throw new Error('Missing value for --output');\n  }\n  if (path.isAbsolute(outputValue)) {\n    return outputValue;\n  }\n  return path.join(projectRoot, outputValue);\n}\n\nfunction main() {\n  try {\n    const projectRoot = findProjectRoot();\n    const yamlPath = path.join(projectRoot, 'specs', 'sandbox-lifecycle.yml');\n    const outputPath = parseOutputPathArg(projectRoot);\n\n    // Validate input file exists\n    if (!fs.existsSync(yamlPath)) {\n      throw new Error(`YAML file not found: ${yamlPath}`);\n    }\n\n    console.log('Generating spec-inline.js...');\n    console.log(`   Input:  ${yamlPath}`);\n    console.log(`   Output: ${outputPath}`);\n    fs.mkdirSync(path.dirname(outputPath), { recursive: true });\n\n\n    // Read YAML\n    const yamlContent = fs.readFileSync(yamlPath, 'utf-8');\n    const yamlSize = Math.round(yamlContent.length / 1024);\n\n    // Escape backticks for template literal\n    const escapedYaml = yamlContent.replace(/`/g, '\\\\`');\n\n    // Generate JavaScript\n    const jsContent = `const OPENAPI_SPEC_YAML = \\`${escapedYaml}\\`;`;\n    const jsSize = Math.round(jsContent.length / 1024);\n\n    // Write output\n    fs.writeFileSync(outputPath, jsContent, 'utf-8');\n\n    console.log('\\nSuccessfully generated spec-inline.js');\n    console.log(`   YAML size: ${yamlSize} KB`);\n    console.log(`   JS size:   ${jsSize} KB`);\n    console.log(`   Compression ratio: ${((jsSize / yamlSize) * 100).toFixed(1)}%`);\n\n    // Verify\n    const generated = fs.readFileSync(outputPath, 'utf-8');\n    if (generated.startsWith('const OPENAPI_SPEC_YAML = `')) {\n      console.log('\\nFile validated successfully');\n      process.exit(0);\n    } else {\n      throw new Error('Generated file validation failed');\n    }\n  } catch (error) {\n    console.error(`\\nError: ${error.message}`);\n    console.error(error.stack);\n    process.exit(1);\n  }\n}\n\n// Run\nmain();\n"
  },
  {
    "path": "scripts/spec-doc/index.html",
    "content": "<!DOCTYPE html>\n<!--\n Copyright 2025 Alibaba Group Holding Ltd.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n     http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n-->\n\n<html>\n<head>\n  <title>OpenSandbox Lifecycle API Documentation</title>\n  <meta charset=\"utf-8\"/>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link href=\"https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700\" rel=\"stylesheet\">\n  <style>\n    body {\n      margin: 0;\n      padding: 0;\n      font-family: 'Roboto', sans-serif;\n    }\n  </style>\n</head>\n<body>\n  <div id=\"redoc-container\"></div>\n\n  <!-- Load js-yaml first for YAML parsing -->\n  <script src=\"https://cdn.jsdelivr.net/npm/js-yaml@4/dist/js-yaml.min.js\"></script>\n\n  <!-- Load ReDoc (stable version) -->\n  <script src=\"https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js\"></script>\n\n  <!-- Load generated inline spec -->\n  <script src=\"../../docs/public/api/spec-inline.js\"></script>\n\n  <!-- Initialize ReDoc after all libraries are loaded -->\n  <script>\n    console.log('📄 OpenSandbox API Documentation - Initializing');\n\n    window.addEventListener('load', function() {\n      console.log('🔍 Page fully loaded, checking dependencies...');\n      console.log('   - Redoc available?', typeof Redoc !== 'undefined');\n      console.log('   - jsyaml available?', typeof jsyaml !== 'undefined');\n      console.log('   - OPENAPI_SPEC_YAML available?', typeof OPENAPI_SPEC_YAML !== 'undefined');\n\n      // Check if Redoc is available\n      if (typeof Redoc === 'undefined') {\n        console.error('❌ Redoc library failed to load');\n        document.getElementById('redoc-container').innerHTML =\n          '<div style=\"padding: 20px; color: red;\"><strong>❌ Error:</strong> Redoc library failed to load.</div>';\n        return;\n      }\n\n      // Check if js-yaml is available\n      if (typeof jsyaml === 'undefined') {\n        console.error('❌ js-yaml library failed to load');\n        document.getElementById('redoc-container').innerHTML =\n          '<div style=\"padding: 20px; color: red;\"><strong>❌ Error:</strong> js-yaml library failed to load.</div>';\n        return;\n      }\n\n      // Check if spec is available\n      if (typeof OPENAPI_SPEC_YAML === 'undefined') {\n        console.error('❌ API specification not loaded');\n        document.getElementById('redoc-container').innerHTML =\n          '<div style=\"padding: 20px; color: red;\"><strong>❌ Error:</strong> API specification not loaded. Make sure spec-inline.js exists.</div>';\n        return;\n      }\n\n      try {\n        console.log('📝 Parsing OpenAPI YAML spec...');\n        const spec = jsyaml.load(OPENAPI_SPEC_YAML);\n        console.log('✅ YAML parsed successfully');\n\n        // Log spec info\n        const pathCount = Object.keys(spec.paths || {}).length;\n        console.log(`📊 Spec contains ${pathCount} paths`);\n\n        // Check for endpoints/{port}\n        if (spec.paths && spec.paths['/sandboxes/{sandboxId}/endpoints/{port}']) {\n          console.log('✨ Found /sandboxes/{sandboxId}/endpoints/{port} route');\n        } else {\n          console.warn('⚠️  /sandboxes/{sandboxId}/endpoints/{port} route not found in spec');\n        }\n\n        console.log('🎨 Initializing Redoc...');\n        Redoc.init(spec, {\n          scrollYOffset: 50,\n          theme: {\n            colors: {\n              primary: {\n                main: '#1f2937'\n              }\n            }\n          }\n        }, document.getElementById('redoc-container'));\n\n        console.log('✅ Redoc initialized successfully!');\n      } catch (error) {\n        console.error('❌ Error during initialization:', error);\n        document.getElementById('redoc-container').innerHTML =\n          '<div style=\"padding: 20px; color: red;\">' +\n          '<strong>❌ Error:</strong> Failed to parse OpenAPI specification.<br/>' +\n          '<code>' + error.message + '</code></div>';\n      }\n    });\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "scripts/verify-license.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# This script verifies that required files contain the Apache 2.0 license header.\n# It scans tracked source files and fails with a list of violations if any header\n# is missing.\n\nset -euo pipefail\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nCURRENT_YEAR=\"$(date +%Y)\"\nMIN_YEAR=\"2025\"\nLICENSE_OWNER=\"Alibaba Group Holding Ltd.\"\nLICENSE_REGEX=\"Copyright [0-9]{4} ${LICENSE_OWNER// / }\"\n\n# File extensions that are expected to carry a license header.\nLICENSE_EXTS=(\n  go py sh kt kts java ts tsx js jsx toml html css sql tf\n)\n\n# Explicit file basenames that should also be checked (e.g., Dockerfile)\nLICENSE_BASENAMES=(\n  Dockerfile\n)\n\n# Paths to ignore entirely.\nIGNORED_PATHS=(\n  \"LICENSE\"\n  \"NOTICE\"\n  \"docs/\"\n  \"scripts/spec-doc/index.html\" # Generated doc\n)\n\nis_k8s_mock_go() {\n  local file=\"${1-}\"\n  [[ -z \"$file\" ]] && return 1\n  # Skip any Go mocks under kubernetes/internal:\n  # - filenames ending with _mock.go\n  # - any file under a /mock/ directory\n  if [[ \"$file\" != kubernetes/internal/* ]]; then\n    return 1\n  fi\n  if [[ \"$file\" == *\"_mock.go\" ]]; then\n    return 0\n  fi\n  if [[ \"$file\" == */mock/*.go ]]; then\n    return 0\n  fi\n  return 1\n}\n\nis_generated_to_skip() {\n  local file=\"$1\"\n  # Skip common generated files\n  if [[ \"$file\" == *\"deepcopy.go\" ]]; then\n    return 0\n  fi\n  return 1\n}\n\ncd \"$REPO_ROOT\"\n\nis_ignored() {\n  local file=\"$1\"\n  for ignore in \"${IGNORED_PATHS[@]}\"; do\n    if [[ \"$ignore\" == */ ]]; then\n      if [[ \"$file\" == \"$ignore\"* ]]; then\n        return 0\n      fi\n    elif [[ \"$file\" == \"$ignore\" ]]; then\n      return 0\n    fi\n  done\n  return 1\n}\n\nhas_expected_extension() {\n  local file=\"$1\"\n  local ext=\"${file##*.}\"\n  for candidate in \"${LICENSE_EXTS[@]}\"; do\n    if [[ \"$ext\" == \"$candidate\" ]]; then\n      return 0\n    fi\n  done\n  return 1\n}\n\nhas_expected_basename() {\n  local file=\"$1\"\n  local base\n  base=\"$(basename \"$file\")\"\n  for candidate in \"${LICENSE_BASENAMES[@]}\"; do\n    if [[ \"$base\" == \"$candidate\" ]]; then\n      return 0\n    fi\n  done\n  return 1\n}\n\nmissing=()\n\nwhile IFS= read -r file; do\n  # Skip ignored paths\n  if is_ignored \"$file\"; then\n    continue\n  fi\n  # Skip kubernetes internal mock go files\n  if is_k8s_mock_go \"$file\"; then\n    continue\n  fi\n  # Skip generated files\n  if is_generated_to_skip \"$file\"; then\n    continue\n  fi\n\n  # Only check files with expected extensions or basenames\n  if ! has_expected_extension \"$file\" && ! has_expected_basename \"$file\"; then\n    continue\n  fi\n\n  # Limit scan to the first 25 lines to allow shebangs/DOCTYPE above the header.\n  header=\"$(head -n 25 \"$file\")\"\n  if ! echo \"$header\" | grep -Eq \"$LICENSE_REGEX\"; then\n    missing+=(\"$file\")\n    continue\n  fi\n  found_year=\"$(echo \"$header\" | grep -Eo \"$LICENSE_REGEX\" | head -n1 | grep -Eo '[0-9]{4}')\"\n  if [[ -z \"$found_year\" || \"$found_year\" -gt \"$CURRENT_YEAR\" || \"$found_year\" -lt \"$MIN_YEAR\" ]]; then\n    missing+=(\"$file\")\n  fi\ndone < <(git -C \"$REPO_ROOT\" ls-files)\n\nif ((${#missing[@]} > 0)); then\n  echo \"Missing license header in the following files:\"\n  printf ' - %s\\n' \"${missing[@]}\"\n  exit 1\nfi\n\necho \"License headers verified.\"\n"
  },
  {
    "path": "sdks/Directory.Build.props",
    "content": "<Project>\n  <PropertyGroup>\n    <!-- Shared C# SDK packaging metadata -->\n    <OpenSandboxPackageVersion>0.1.0</OpenSandboxPackageVersion>\n    <OpenSandboxCodeInterpreterPackageVersion>0.1.0</OpenSandboxCodeInterpreterPackageVersion>\n    <OpenSandboxDependencyVersionRange>[$(OpenSandboxPackageVersion),0.2.0)</OpenSandboxDependencyVersionRange>\n  </PropertyGroup>\n</Project>\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/OpenSandbox.CodeInterpreter.sln",
    "content": "\nMicrosoft Visual Studio Solution File, Format Version 12.00\n# Visual Studio Version 17\nVisualStudioVersion = 17.0.31903.59\nMinimumVisualStudioVersion = 10.0.40219.1\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"OpenSandbox.CodeInterpreter\", \"src\\OpenSandbox.CodeInterpreter\\OpenSandbox.CodeInterpreter.csproj\", \"{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}\"\nEndProject\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"OpenSandbox.CodeInterpreter.Tests\", \"tests\\OpenSandbox.CodeInterpreter.Tests\\OpenSandbox.CodeInterpreter.Tests.csproj\", \"{B2C3D4E5-F6A7-8901-BCDE-F12345678901}\"\nEndProject\nGlobal\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n\t\tDebug|Any CPU = Debug|Any CPU\n\t\tRelease|Any CPU = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU\n\tEndGlobalSection\nEndGlobal\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/README.md",
    "content": "# OpenSandbox Code Interpreter SDK for C#\n\nEnglish | [中文](README_zh.md)\n\nA C# SDK for code interpretation with OpenSandbox. Provides high-level APIs for executing code in multiple languages (Python, JavaScript, TypeScript, Go, Java, Bash) within secure sandbox environments.\n\n## Prerequisites\n\nThis SDK requires a Docker image containing the Code Interpreter runtime environment. You must use\n`opensandbox/code-interpreter` (or a derivative image) with pre-installed runtimes for Python, Java, Go,\nNode.js, and others.\n\nFor supported languages and versions, see the\n[Environment Documentation](../../../sandboxes/code-interpreter/README.md).\n\n## Installation\n\n```bash\ndotnet add package Alibaba.OpenSandbox.CodeInterpreter\n```\n\n## Quick Start\n\n```csharp\nusing OpenSandbox;\nusing OpenSandbox.CodeInterpreter;\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\n\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-api-key\"\n});\n\ntry\n{\n    // Create sandbox with code-interpreter runtime image and entrypoint.\n    await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n    {\n        ConnectionConfig = config,\n        Image = \"opensandbox/code-interpreter:v1.0.2\",\n        Entrypoint = new[] { \"/opt/opensandbox/code-interpreter.sh\" },\n        Env = new Dictionary<string, string>\n        {\n            [\"PYTHON_VERSION\"] = \"3.11\",\n            [\"JAVA_VERSION\"] = \"17\",\n            [\"NODE_VERSION\"] = \"20\",\n            [\"GO_VERSION\"] = \"1.24\"\n        },\n        TimeoutSeconds = 15 * 60\n    });\n\n    var interpreter = await CodeInterpreter.CreateAsync(sandbox);\n    var execution = await interpreter.Codes.RunAsync(\n        \"print('Hello, World!')\",\n        new RunCodeOptions { Language = SupportedLanguage.Python });\n\n    foreach (var msg in execution.Logs.Stdout)\n    {\n        Console.Write(msg.Text);\n    }\n\n    await sandbox.KillAsync();\n}\ncatch (SandboxException ex)\n{\n    Console.Error.WriteLine($\"Sandbox Error: [{ex.Error.Code}] {ex.Error.Message}\");\n}\n```\n\n## Logging (ILogger)\n\nThe SDK uses `Microsoft.Extensions.Logging` abstractions. Pass your own `ILoggerFactory`\nthrough diagnostics options when creating the sandbox/code interpreter:\n\n```csharp\nusing Microsoft.Extensions.Logging;\nusing OpenSandbox.Config;\n\nusing var loggerFactory = LoggerFactory.Create(builder =>\n{\n    builder.SetMinimumLevel(LogLevel.Debug);\n    builder.AddConsole();\n});\n\nawait using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = new ConnectionConfig(),\n    Image = \"opensandbox/code-interpreter:v1.0.2\",\n    Entrypoint = new[] { \"/opt/opensandbox/code-interpreter.sh\" },\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n\nvar interpreter = await CodeInterpreter.CreateAsync(sandbox, new CodeInterpreterCreateOptions\n{\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n```\n\n## Runtime Configuration\n\n### Docker Image\n\nThe Code Interpreter SDK relies on a specialized runtime image. Ensure your sandbox provider has\n`opensandbox/code-interpreter` available.\n\n### Language Version Selection\n\nYou can specify language versions through environment variables when creating the sandbox:\n\n| Language | Environment Variable | Example Value | Default (if unset) |\n| --- | --- | --- | --- |\n| Python | `PYTHON_VERSION` | `3.11` | Image default |\n| Java | `JAVA_VERSION` | `17` | Image default |\n| Node.js | `NODE_VERSION` | `20` | Image default |\n| Go | `GO_VERSION` | `1.24` | Image default |\n\n## Features\n\n### Run with `Language` (default language context)\n\nIf you do not need explicit context IDs, run code by setting only `Language`.\nWhen `Context` is omitted, execd creates/reuses a default session for that language, so state can persist across runs.\n\n```csharp\nawait interpreter.Codes.RunAsync(\n    \"x = 42\",\n    new RunCodeOptions { Language = SupportedLanguage.Python });\n\nvar execution = await interpreter.Codes.RunAsync(\n    \"result = x\\nresult\",\n    new RunCodeOptions { Language = SupportedLanguage.Python });\n\nConsole.WriteLine(execution.Results.FirstOrDefault()?.Text); // \"42\"\n```\n\n### Supported Languages\n\n- Python (`SupportedLanguage.Python`)\n- JavaScript (`SupportedLanguage.JavaScript`)\n- TypeScript (`SupportedLanguage.TypeScript`)\n- Go (`SupportedLanguage.Go`)\n- Java (`SupportedLanguage.Java`)\n- Bash (`SupportedLanguage.Bash`)\n\n### Context Management\n\nContexts allow you to maintain state between code executions:\n\n```csharp\n// Create a context for Python\nvar context = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n\n// Run code in the context - variables persist\nawait interpreter.Codes.RunAsync(\"x = 42\", new RunCodeOptions { Context = context });\nvar result = await interpreter.Codes.RunAsync(\"print(x)\", new RunCodeOptions { Context = context });\n// Output: 42\n\n// List contexts for a specific language\nvar pythonContexts = await interpreter.Codes.ListContextsAsync(SupportedLanguage.Python);\n\n// Delete a specific context\nawait interpreter.Codes.DeleteContextAsync(context.Id!);\n\n// Delete all contexts for a language\nawait interpreter.Codes.DeleteContextsAsync(SupportedLanguage.Python);\n```\n\n### Streaming Execution\n\nFor real-time output, use streaming:\n\n```csharp\nvar request = new RunCodeRequest\n{\n    Code = \"for i in range(5): print(i)\",\n    Context = new CodeContext { Language = SupportedLanguage.Python }\n};\n\nawait foreach (var ev in interpreter.Codes.RunStreamAsync(request))\n{\n    switch (ev.Type)\n    {\n        case \"stdout\":\n            Console.Write(ev.Text);\n            break;\n        case \"stderr\":\n            Console.Error.Write(ev.Text);\n            break;\n        case \"result\":\n            var text = ev.Results != null\n                && ev.Results.TryGetValue(\"text/plain\", out var value)\n                ? value?.ToString()\n                : null;\n            Console.WriteLine($\"Result: {text ?? \"(no text/plain)\"}\");\n            break;\n        case \"error\":\n            Console.WriteLine($\"Error: {ev.Error}\");\n            break;\n    }\n}\n```\n\n### Event Handlers\n\nUse handlers for fine-grained control over execution events:\n\n```csharp\nvar execution = await interpreter.Codes.RunAsync(\n    \"print('Hello')\\nprint('World')\",\n    new RunCodeOptions\n    {\n        Language = SupportedLanguage.Python,\n        Handlers = new ExecutionHandlers\n        {\n            OnStdout = async msg => Console.Write($\"[OUT] {msg.Text}\"),\n            OnStderr = async msg => Console.Error.Write($\"[ERR] {msg.Text}\"),\n            OnResult = async result => Console.WriteLine($\"[RESULT] {result.Text}\"),\n            OnError = async error => Console.WriteLine($\"[ERROR] {error.Name}: {error.Value}\"),\n            OnExecutionComplete = async complete => Console.WriteLine($\"[DONE] Took {complete.ExecutionTimeMs}ms\")\n        }\n    });\n```\n\n### Interrupt Execution\n\nStop a running code execution:\n\n```csharp\nvar context = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n\n// Start a long-running task\nvar task = interpreter.Codes.RunAsync(\n    \"import time\\nwhile True: time.sleep(1)\",\n    new RunCodeOptions { Context = context });\n\n// Interrupt after some time\nawait Task.Delay(2000);\nawait interpreter.Codes.InterruptAsync(context.Id!);\n```\n\n### Access Sandbox Services\n\nThe code interpreter provides convenient access to underlying sandbox services:\n\n```csharp\n// File operations\nawait interpreter.Files.WriteFilesAsync(new[]\n{\n    new WriteEntry { Path = \"/tmp/data.txt\", Data = \"Hello, World!\" }\n});\nvar content = await interpreter.Files.ReadFileAsync(\"/tmp/data.txt\");\n\n// Shell commands\nvar commandExecution = await interpreter.Commands.RunAsync(\"ls -la /tmp\");\nforeach (var msg in commandExecution.Logs.Stdout)\n{\n    Console.Write(msg.Text);\n}\n\n// Metrics\nvar metrics = await interpreter.Sandbox.GetMetricsAsync();\nConsole.WriteLine($\"CPU: {metrics.CpuUsedPercentage}%, Memory: {metrics.MemoryUsedMiB}MiB\");\n```\n\n## API Reference\n\n### CodeInterpreter\n\n| Method | Description |\n|--------|-------------|\n| `CreateAsync(sandbox, options?)` | Creates a code interpreter from a sandbox |\n\n| Property | Description |\n|----------|-------------|\n| `Sandbox` | The underlying sandbox instance |\n| `Codes` | The codes service for code execution |\n| `Id` | The sandbox ID |\n| `Files` | File system operations |\n| `Commands` | Shell command execution |\n| `Metrics` | Resource metrics |\n\n### ICodes\n\n| Method | Description |\n|--------|-------------|\n| `CreateContextAsync(language)` | Creates a new execution context |\n| `GetContextAsync(contextId)` | Gets an existing context |\n| `ListContextsAsync(language)` | Lists contexts for a specific language |\n| `DeleteContextAsync(contextId)` | Deletes a specific context |\n| `DeleteContextsAsync(language)` | Deletes all contexts for a language |\n| `RunAsync(code, options?)` | Executes code and returns the result |\n| `RunStreamAsync(request)` | Executes code with streaming output |\n| `InterruptAsync(contextId)` | Interrupts a running execution |\n\n> All async methods support `CancellationToken`.\n\n## Requirements\n\n- .NET Standard 2.0+ / .NET 6.0+\n- OpenSandbox Sandbox SDK (`Alibaba.OpenSandbox`)\n\n## Notes\n\n- **Lifecycle**: `CodeInterpreter` wraps an existing `Sandbox` and reuses its connection and services.\n- **Default context behavior**: `RunAsync(..., new RunCodeOptions { Language = ... })` uses the language default context.\n- **Cleanup**: `DisposeAsync` only cleans local resources. Call `KillAsync()` to terminate the remote sandbox instance.\n\n## License\n\nApache License 2.0\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/README_zh.md",
    "content": "# Alibaba Code Interpreter SDK for C#\n\n[English](README.md) | 中文\n\n一个用于在安全隔离沙箱中执行代码的 C# SDK。它提供了高级 API，用于安全地运行 Python、Java、Go、TypeScript 等语言，并支持代码执行上下文管理。\n\n## 前置条件\n\n此 SDK 需要包含 Code Interpreter 运行时环境的 Docker 镜像。您必须使用 `opensandbox/code-interpreter` 镜像（或其衍生版本），该镜像预装了 Python、Java、Go、Node.js 等运行时。\n\n有关支持的语言和版本的详细信息，请参阅[环境文档](../../../sandboxes/code-interpreter/README.md)。\n\n## 安装\n\n### NuGet\n\n```bash\ndotnet add package Alibaba.OpenSandbox.CodeInterpreter\n```\n\n### PackageReference\n\n```xml\n<PackageReference Include=\"Alibaba.OpenSandbox.CodeInterpreter\" Version=\"0.1.0\" />\n```\n\n## 快速开始\n\n以下示例演示如何创建具有特定运行时配置的沙箱并执行简单脚本。\n\n> **注意**：运行此示例之前，请确保 OpenSandbox 服务正在运行。有关启动说明，请参阅根目录的 [README.md](../../../README.md)。\n\n```csharp\nusing OpenSandbox;\nusing OpenSandbox.CodeInterpreter;\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Config;\n\n// 1. 配置连接\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-api-key\"\n});\n\n// 2. 创建带有 code-interpreter 镜像和运行时版本的 Sandbox\nawait using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"opensandbox/code-interpreter:v1.0.2\",\n    Entrypoint = new[] { \"/opt/opensandbox/code-interpreter.sh\" },\n    Env = new Dictionary<string, string>\n    {\n        [\"PYTHON_VERSION\"] = \"3.11\",\n        [\"JAVA_VERSION\"] = \"17\",\n        [\"NODE_VERSION\"] = \"20\",\n        [\"GO_VERSION\"] = \"1.24\"\n    },\n    TimeoutSeconds = 15 * 60\n});\n\n// 3. 创建 CodeInterpreter 包装器\nvar ci = await CodeInterpreter.CreateAsync(sandbox);\n\n// 4. 创建执行上下文 (Python)\nvar ctx = await ci.Codes.CreateContextAsync(SupportedLanguage.Python);\n\n// 5. 运行代码\nvar result = await ci.Codes.RunAsync(\n    \"import sys\\nprint(sys.version)\\nresult = 2 + 2\\nresult\",\n    new RunCodeOptions { Context = ctx });\n\n// 6. 打印输出\nConsole.WriteLine(result.Results.FirstOrDefault()?.Text);\n\n// 7. 清理远程实例（可选但推荐）\nawait sandbox.KillAsync();\n```\n\n## 日志（ILogger）\n\nSDK 使用 `Microsoft.Extensions.Logging` 抽象。创建 Sandbox/CodeInterpreter 时可通过\ndiagnostics 传入你自己的 `ILoggerFactory`：\n\n```csharp\nusing Microsoft.Extensions.Logging;\nusing OpenSandbox.Config;\n\nusing var loggerFactory = LoggerFactory.Create(builder =>\n{\n    builder.SetMinimumLevel(LogLevel.Debug);\n    builder.AddConsole();\n});\n\nawait using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    Image = \"opensandbox/code-interpreter:v1.0.2\",\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n\nvar ci = await CodeInterpreter.CreateAsync(sandbox, new CodeInterpreterCreateOptions\n{\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n```\n\n## 运行时配置\n\n### Docker 镜像\n\nCode Interpreter SDK 依赖于专门的环境。请确保您的沙箱提供者有可用的 `opensandbox/code-interpreter` 镜像。\n\n### 语言版本选择\n\n您可以通过在创建 `Sandbox` 时设置相应的环境变量来指定所需的编程语言版本。\n\n| 语言 | 环境变量 | 示例值 | 默认值（如未设置） |\n| --- | --- | --- | --- |\n| Python | `PYTHON_VERSION` | `3.11` | 镜像默认值 |\n| Java | `JAVA_VERSION` | `17` | 镜像默认值 |\n| Node.js | `NODE_VERSION` | `20` | 镜像默认值 |\n| Go | `GO_VERSION` | `1.24` | 镜像默认值 |\n\n```csharp\nawait using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"opensandbox/code-interpreter:v1.0.2\",\n    Entrypoint = new[] { \"/opt/opensandbox/code-interpreter.sh\" },\n    Env = new Dictionary<string, string>\n    {\n        [\"JAVA_VERSION\"] = \"17\",\n        [\"GO_VERSION\"] = \"1.24\"\n    }\n});\n```\n\n## 使用示例\n\n### 0. 使用 `Language` 运行（默认语言上下文）\n\n如果您不需要管理显式的上下文 ID，可以仅通过指定 `Language` 来运行代码。\n当省略 `Context.Id` 时，execd 可以为该语言创建/重用默认会话，因此状态可以在多次运行之间持久化。\n\n```csharp\nawait ci.Codes.RunAsync(\"x = 42\", new RunCodeOptions { Language = SupportedLanguage.Python });\nvar execution = await ci.Codes.RunAsync(\"result = x\\nresult\", new RunCodeOptions { Language = SupportedLanguage.Python });\nConsole.WriteLine(execution.Results.FirstOrDefault()?.Text); // \"42\"\n```\n\n### 0.1 上下文管理（列出/获取/删除）\n\n您可以显式管理上下文（与 Python/Kotlin SDK 对齐）：\n\n```csharp\nvar ctx = await ci.Codes.CreateContextAsync(SupportedLanguage.Python);\n\nvar same = await ci.Codes.GetContextAsync(ctx.Id!);\nConsole.WriteLine($\"{same.Id}, {same.Language}\");\n\nvar pyOnly = await ci.Codes.ListContextsAsync(SupportedLanguage.Python);\n\nawait ci.Codes.DeleteContextAsync(ctx.Id!);\nawait ci.Codes.DeleteContextsAsync(SupportedLanguage.Python); // 批量清理\n```\n\n### 1. Java 代码执行\n\n```csharp\nvar javaCtx = await ci.Codes.CreateContextAsync(SupportedLanguage.Java);\nvar execution = await ci.Codes.RunAsync(\n    @\"System.out.println(\"\"Calculating sum...\"\");\nint a = 10;\nint b = 20;\nint sum = a + b;\nSystem.out.println(\"\"Sum: \"\" + sum);\nsum\",\n    new RunCodeOptions { Context = javaCtx });\n\nforeach (var msg in execution.Logs.Stdout)\n{\n    Console.WriteLine(msg.Text);\n}\n```\n\n### 2. 流式输出处理\n\n实时处理 stdout/stderr 和执行事件。\n\n```csharp\nusing OpenSandbox.Models;\n\nvar handlers = new ExecutionHandlers\n{\n    OnStdout = async msg => Console.WriteLine($\"STDOUT: {msg.Text}\"),\n    OnStderr = async msg => Console.Error.WriteLine($\"STDERR: {msg.Text}\"),\n    OnResult = async r => Console.WriteLine($\"RESULT: {r.Text}\")\n};\n\nvar pyCtx = await ci.Codes.CreateContextAsync(SupportedLanguage.Python);\nawait ci.Codes.RunAsync(\n    \"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.2)\",\n    new RunCodeOptions { Context = pyCtx, Handlers = handlers });\n```\n\n### 3. 使用 IAsyncEnumerable 流式处理\n\n```csharp\nvar request = new RunCodeRequest\n{\n    Code = \"for i in range(10): print(i)\",\n    Context = new CodeContext { Language = SupportedLanguage.Python }\n};\n\nawait foreach (var ev in ci.Codes.RunStreamAsync(request))\n{\n    switch (ev.Type)\n    {\n        case \"stdout\":\n            Console.Write(ev.Text);\n            break;\n        case \"stderr\":\n            Console.Error.Write(ev.Text);\n            break;\n        case \"result\":\n            Console.WriteLine($\"结果: {ev.Results}\");\n            break;\n        case \"error\":\n            Console.WriteLine($\"错误: {ev.Error}\");\n            break;\n    }\n}\n```\n\n### 4. 中断执行\n\n```csharp\nvar ctx = await ci.Codes.CreateContextAsync(SupportedLanguage.Python);\n\n// 启动长时间运行的任务\nvar task = ci.Codes.RunAsync(\n    \"import time\\nwhile True: time.sleep(1)\",\n    new RunCodeOptions { Context = ctx });\n\n// 一段时间后中断\nawait Task.Delay(2000);\nawait ci.Codes.InterruptAsync(ctx.Id!);\n```\n\n## API 参考\n\n### CodeInterpreter\n\n| 方法 | 描述 |\n|------|------|\n| `CreateAsync(sandbox, options?)` | 从沙箱创建代码解释器 |\n\n| 属性 | 描述 |\n|------|------|\n| `Sandbox` | 底层沙箱实例 |\n| `Codes` | 代码执行服务 |\n| `Id` | 沙箱 ID |\n| `Files` | 文件系统操作 |\n| `Commands` | Shell 命令执行 |\n| `Metrics` | 资源指标 |\n\n### ICodes\n\n| 方法 | 描述 |\n|------|------|\n| `CreateContextAsync(language)` | 创建新的执行上下文 |\n| `GetContextAsync(contextId)` | 获取现有上下文 |\n| `ListContextsAsync(language)` | 列出指定语言的上下文 |\n| `DeleteContextAsync(contextId)` | 删除特定上下文 |\n| `DeleteContextsAsync(language)` | 删除某语言的所有上下文 |\n| `RunAsync(code, options?)` | 执行代码并返回结果 |\n| `RunStreamAsync(request)` | 执行代码并流式输出 |\n| `InterruptAsync(contextId)` | 中断正在运行的执行 |\n\n## 注意事项\n\n- **生命周期**：`CodeInterpreter` 包装现有的 `Sandbox` 实例并重用其连接配置。完成后调用 `sandbox.KillAsync()` 以释放资源。\n- **默认上下文**：`Codes.RunAsync(..., new RunCodeOptions { Language = ... })` 使用语言默认上下文（状态可以在多次运行之间持久化）。\n- **取消支持**：所有异步方法都支持 `CancellationToken`。\n\n## 系统要求\n\n- .NET Standard 2.0+ / .NET 6.0+\n- OpenSandbox Sandbox SDK (`Alibaba.OpenSandbox`)\n\n## 许可证\n\nApache License 2.0\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/Adapters/CodesAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing OpenSandbox.Adapters;\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.CodeInterpreter.Services;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.CodeInterpreter.Adapters;\n\n/// <summary>\n/// Adapter implementation for the codes service.\n/// </summary>\ninternal sealed class CodesAdapter : ICodes\n{\n    private readonly HttpClientWrapper _client;\n    private readonly HttpClient _sseHttpClient;\n    private readonly string _baseUrl;\n    private readonly IReadOnlyDictionary<string, string> _headers;\n    private readonly ILogger _logger;\n\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        PropertyNameCaseInsensitive = true,\n        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n    };\n\n    public CodesAdapter(\n        HttpClientWrapper client,\n        HttpClient sseHttpClient,\n        string baseUrl,\n        IReadOnlyDictionary<string, string> headers,\n        ILogger logger)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n        _sseHttpClient = sseHttpClient ?? throw new ArgumentNullException(nameof(sseHttpClient));\n        _baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));\n        _headers = headers ?? new Dictionary<string, string>();\n        _logger = logger ?? throw new ArgumentNullException(nameof(logger));\n    }\n\n    public async Task<CodeContext> CreateContextAsync(string language, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(language))\n        {\n            throw new InvalidArgumentException(\"Language cannot be empty\");\n        }\n\n        var request = new CreateContextRequest { Language = language };\n        _logger.LogDebug(\"Creating code context (language={Language})\", language);\n        var response = await _client.PostAsync<CodeContext>(\"/code/context\", request, cancellationToken).ConfigureAwait(false);\n\n        if (response == null || string.IsNullOrEmpty(response.Language))\n        {\n            throw new SandboxApiException(\n                message: \"Create code context failed: unexpected response shape\",\n                error: new SandboxError(SandboxErrorCodes.UnexpectedResponse, \"Create code context failed: unexpected response shape\"));\n        }\n\n        return response;\n    }\n\n    public async Task<CodeContext> GetContextAsync(string contextId, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(contextId))\n        {\n            throw new InvalidArgumentException(\"contextId cannot be empty\");\n        }\n\n        _logger.LogDebug(\"Fetching code context: {ContextId}\", contextId);\n        var response = await _client.GetAsync<CodeContext>($\"/code/contexts/{Uri.EscapeDataString(contextId)}\", cancellationToken: cancellationToken).ConfigureAwait(false);\n\n        if (response == null || string.IsNullOrEmpty(response.Language))\n        {\n            throw new SandboxApiException(\n                message: \"Get code context failed: unexpected response shape\",\n                error: new SandboxError(SandboxErrorCodes.UnexpectedResponse, \"Get code context failed: unexpected response shape\"));\n        }\n\n        return response;\n    }\n\n    public async Task<IReadOnlyList<CodeContext>> ListContextsAsync(string language, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(language))\n        {\n            throw new InvalidArgumentException(\"Language cannot be empty\");\n        }\n\n        _logger.LogDebug(\"Listing code contexts (language={Language})\", language);\n        var queryParams = new Dictionary<string, string?> { [\"language\"] = language };\n\n        var response = await _client.GetAsync<List<CodeContext>>(\"/code/contexts\", queryParams, cancellationToken).ConfigureAwait(false);\n\n        if (response == null)\n        {\n            throw new SandboxApiException(\n                message: \"List code contexts failed: unexpected response shape\",\n                error: new SandboxError(SandboxErrorCodes.UnexpectedResponse, \"List code contexts failed: unexpected response shape\"));\n        }\n\n        return response;\n    }\n\n    public async Task DeleteContextAsync(string contextId, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(contextId))\n        {\n            throw new InvalidArgumentException(\"contextId cannot be empty\");\n        }\n\n        _logger.LogInformation(\"Deleting code context: {ContextId}\", contextId);\n        await _client.DeleteAsync($\"/code/contexts/{Uri.EscapeDataString(contextId)}\", cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task DeleteContextsAsync(string language, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(language))\n        {\n            throw new InvalidArgumentException(\"Language cannot be empty\");\n        }\n\n        _logger.LogInformation(\"Deleting code contexts (language={Language})\", language);\n        var queryParams = new Dictionary<string, string?> { [\"language\"] = language };\n        await _client.DeleteAsync(\"/code/contexts\", queryParams, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task InterruptAsync(string contextId, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(contextId))\n        {\n            throw new InvalidArgumentException(\"contextId cannot be empty\");\n        }\n\n        _logger.LogInformation(\"Interrupting code execution for context: {ContextId}\", contextId);\n        var queryParams = new Dictionary<string, string?> { [\"id\"] = contextId };\n        await _client.DeleteAsync(\"/code\", queryParams, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async IAsyncEnumerable<ServerStreamEvent> RunStreamAsync(\n        RunCodeRequest request,\n        [EnumeratorCancellation] CancellationToken cancellationToken = default)\n    {\n        if (request == null)\n        {\n            throw new InvalidArgumentException(\"request cannot be null\");\n        }\n\n        if (string.IsNullOrWhiteSpace(request.Code))\n        {\n            throw new InvalidArgumentException(\"Code cannot be empty\");\n        }\n\n        var url = $\"{_baseUrl}/code\";\n        _logger.LogDebug(\"Running code stream (codeLength={CodeLength})\", request.Code.Length);\n        var json = JsonSerializer.Serialize(request, JsonOptions);\n\n        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)\n        {\n            Content = new StringContent(json, Encoding.UTF8, \"application/json\")\n        };\n\n        httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(\"text/event-stream\"));\n\n        foreach (var header in _headers)\n        {\n            httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);\n        }\n\n        using var response = await _sseHttpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);\n\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response, \"Run code failed\", cancellationToken).ConfigureAwait(false))\n        {\n            yield return ev;\n        }\n    }\n\n    public async Task<Execution> RunAsync(string code, RunCodeOptions? options = null, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(code))\n        {\n            throw new InvalidArgumentException(\"Code cannot be empty\");\n        }\n\n        if (options?.Context != null && options.Language != null)\n        {\n            throw new InvalidArgumentException(\"Provide either options.Context or options.Language, not both\");\n        }\n\n        var context = options?.Context\n            ?? (options?.Language != null\n                ? new CodeContext { Language = options.Language }\n                : new CodeContext { Language = SupportedLanguage.Python });\n\n        var request = new RunCodeRequest\n        {\n            Code = code,\n            Context = context\n        };\n\n        var execution = new Execution();\n        _logger.LogDebug(\"Running code (codeLength={CodeLength})\", code.Length);\n        var dispatcher = new ExecutionEventDispatcher(execution, options?.Handlers);\n\n        await foreach (var ev in RunStreamAsync(request, cancellationToken).ConfigureAwait(false))\n        {\n            await dispatcher.DispatchAsync(ev).ConfigureAwait(false);\n        }\n\n        return execution;\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/CodeInterpreter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Factory;\nusing OpenSandbox.CodeInterpreter.Services;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Services;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace OpenSandbox.CodeInterpreter;\n\n/// <summary>\n/// Options for creating a code interpreter.\n/// </summary>\npublic class CodeInterpreterCreateOptions\n{\n    /// <summary>\n    /// Gets or sets the adapter factory. If not provided, a default factory is used.\n    /// </summary>\n    public ICodeInterpreterAdapterFactory? AdapterFactory { get; set; }\n\n    /// <summary>\n    /// Gets or sets diagnostics options such as logging.\n    /// </summary>\n    public SdkDiagnosticsOptions? Diagnostics { get; set; }\n}\n\n/// <summary>\n/// Code interpreter facade for executing code in multiple languages.\n/// </summary>\n/// <remarks>\n/// This class wraps an existing <see cref=\"Sandbox\"/> and provides a high-level API for code execution.\n/// Use <see cref=\"Codes\"/> to create contexts and run code.\n/// <see cref=\"Files\"/>, <see cref=\"Commands\"/>, and <see cref=\"Metrics\"/> are exposed for convenience\n/// and are the same instances as on the underlying <see cref=\"Sandbox\"/>.\n/// This type does not own the remote sandbox lifecycle. Call <see cref=\"Sandbox.KillAsync\"/> when you want to terminate\n/// the remote instance. Dispose the wrapped <see cref=\"Sandbox\"/> to release local SDK resources.\n/// </remarks>\npublic sealed class CodeInterpreter\n{\n    /// <summary>\n    /// Gets the underlying sandbox instance.\n    /// </summary>\n    public Sandbox Sandbox { get; }\n\n    /// <summary>\n    /// Gets the codes service for code execution operations.\n    /// </summary>\n    public ICodes Codes { get; }\n\n    /// <summary>\n    /// Gets the sandbox ID.\n    /// </summary>\n    public string Id => Sandbox.Id;\n\n    /// <summary>\n    /// Gets the filesystem service.\n    /// </summary>\n    public ISandboxFiles Files => Sandbox.Files;\n\n    /// <summary>\n    /// Gets the command execution service.\n    /// </summary>\n    public IExecdCommands Commands => Sandbox.Commands;\n\n    /// <summary>\n    /// Gets the metrics service.\n    /// </summary>\n    public IExecdMetrics Metrics => Sandbox.Metrics;\n\n    private readonly ILogger _logger;\n\n    private CodeInterpreter(Sandbox sandbox, ICodes codes, ILogger logger)\n    {\n        Sandbox = sandbox ?? throw new ArgumentNullException(nameof(sandbox));\n        Codes = codes ?? throw new ArgumentNullException(nameof(codes));\n        _logger = logger ?? throw new ArgumentNullException(nameof(logger));\n        _logger.LogDebug(\"Code interpreter initialized for sandbox: {SandboxId}\", sandbox.Id);\n    }\n\n    /// <summary>\n    /// Creates a new code interpreter from an existing sandbox.\n    /// </summary>\n    /// <param name=\"sandbox\">The sandbox to wrap.</param>\n    /// <param name=\"options\">Optional creation options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>A new code interpreter instance.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandbox\"/> is null.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when endpoint discovery or adapter initialization fails.</exception>\n    public static async Task<CodeInterpreter> CreateAsync(\n        Sandbox sandbox,\n        CodeInterpreterCreateOptions? options = null,\n        CancellationToken cancellationToken = default)\n    {\n        if (sandbox == null)\n        {\n            throw new InvalidArgumentException(\"sandbox cannot be null\");\n        }\n\n        var loggerFactory = options?.Diagnostics?.LoggerFactory ?? sandbox.SharedLoggerFactory ?? NullLoggerFactory.Instance;\n        var logger = loggerFactory.CreateLogger(\"OpenSandbox.CodeInterpreter.CodeInterpreter\");\n        var endpoint = await sandbox.GetEndpointAsync(Constants.DefaultExecdPort, cancellationToken).ConfigureAwait(false);\n        logger.LogInformation(\"Creating code interpreter for sandbox: {SandboxId}\", sandbox.Id);\n        var protocol = sandbox.ConnectionConfig.Protocol == ConnectionProtocol.Https ? \"https\" : \"http\";\n        var execdBaseUrl = $\"{protocol}://{endpoint.EndpointAddress}\";\n        var execdHeaders = MergeHeaders(sandbox.ConnectionConfig.Headers, endpoint.Headers);\n        var adapterFactory = options?.AdapterFactory ?? DefaultCodeInterpreterAdapterFactory.Create();\n\n        var codes = adapterFactory.CreateCodes(new CreateCodesStackOptions\n        {\n            ConnectionConfig = sandbox.ConnectionConfig,\n            ExecdBaseUrl = execdBaseUrl,\n            ExecdHeaders = execdHeaders,\n            HttpClientProvider = sandbox.SharedHttpClientProvider,\n            LoggerFactory = loggerFactory\n        });\n\n        return new CodeInterpreter(sandbox, codes, logger);\n    }\n\n    private static IReadOnlyDictionary<string, string> MergeHeaders(\n        IReadOnlyDictionary<string, string> baseHeaders,\n        IReadOnlyDictionary<string, string>? overrideHeaders)\n    {\n        var merged = baseHeaders.ToDictionary(header => header.Key, header => header.Value);\n        if (overrideHeaders != null)\n        {\n            foreach (var header in overrideHeaders)\n            {\n                merged[header.Key] = header.Value;\n            }\n        }\n\n        return merged;\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/Factory/DefaultCodeInterpreterAdapterFactory.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Adapters;\nusing OpenSandbox.CodeInterpreter.Services;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.CodeInterpreter.Factory;\n\n/// <summary>\n/// Default implementation of the code interpreter adapter factory.\n/// </summary>\npublic class DefaultCodeInterpreterAdapterFactory : ICodeInterpreterAdapterFactory\n{\n    /// <summary>\n    /// Creates a new instance of the default adapter factory.\n    /// </summary>\n    /// <returns>A new factory instance.</returns>\n    public static DefaultCodeInterpreterAdapterFactory Create() => new();\n\n    /// <inheritdoc />\n    public ICodes CreateCodes(CreateCodesStackOptions options)\n    {\n        if (options == null)\n        {\n            throw new InvalidArgumentException(\"options cannot be null\");\n        }\n\n        if (options.ConnectionConfig == null)\n        {\n            throw new InvalidArgumentException(\"options.ConnectionConfig cannot be null\");\n        }\n\n        if (string.IsNullOrWhiteSpace(options.ExecdBaseUrl))\n        {\n            throw new InvalidArgumentException(\"options.ExecdBaseUrl cannot be null or empty\");\n        }\n\n        if (options.ExecdHeaders == null)\n        {\n            throw new InvalidArgumentException(\"options.ExecdHeaders cannot be null\");\n        }\n\n        if (options.HttpClientProvider == null)\n        {\n            throw new InvalidArgumentException(\"options.HttpClientProvider cannot be null\");\n        }\n\n        if (options.LoggerFactory == null)\n        {\n            throw new InvalidArgumentException(\"options.LoggerFactory cannot be null\");\n        }\n\n        var client = new HttpClientWrapper(\n            options.HttpClientProvider.HttpClient,\n            options.ExecdBaseUrl,\n            options.ExecdHeaders,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.HttpClientWrapper\"));\n\n        return new CodesAdapter(\n            client,\n            options.HttpClientProvider.SseHttpClient,\n            options.ExecdBaseUrl,\n            options.ExecdHeaders,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.CodeInterpreter.CodesAdapter\"));\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/Factory/ICodeInterpreterAdapterFactory.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Services;\nusing OpenSandbox.Config;\nusing OpenSandbox;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.CodeInterpreter.Factory;\n\n/// <summary>\n/// Options for creating a codes service stack.\n/// </summary>\npublic class CreateCodesStackOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public required ConnectionConfig ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execd API base URL.\n    /// </summary>\n    public required string ExecdBaseUrl { get; set; }\n\n    /// <summary>\n    /// Gets or sets headers to apply to execd requests.\n    /// </summary>\n    public required IReadOnlyDictionary<string, string> ExecdHeaders { get; set; }\n\n    /// <summary>\n    /// Gets or sets the HTTP client provider for this SDK instance.\n    /// </summary>\n    public required HttpClientProvider HttpClientProvider { get; set; }\n\n    /// <summary>\n    /// Gets or sets the logger factory for this SDK instance.\n    /// </summary>\n    public required ILoggerFactory LoggerFactory { get; set; }\n}\n\n/// <summary>\n/// Factory interface for creating code interpreter adapters.\n/// </summary>\npublic interface ICodeInterpreterAdapterFactory\n{\n    /// <summary>\n    /// Creates a codes service instance.\n    /// </summary>\n    /// <param name=\"options\">The creation options.</param>\n    /// <returns>The codes service.</returns>\n    ICodes CreateCodes(CreateCodesStackOptions options);\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/Models/CodeModels.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json.Serialization;\n\nnamespace OpenSandbox.CodeInterpreter.Models;\n\n/// <summary>\n/// Supported programming languages for code execution.\n/// </summary>\npublic static class SupportedLanguage\n{\n    /// <summary>\n    /// Python language.\n    /// </summary>\n    public const string Python = \"python\";\n\n    /// <summary>\n    /// Java language.\n    /// </summary>\n    public const string Java = \"java\";\n\n    /// <summary>\n    /// Go language.\n    /// </summary>\n    public const string Go = \"go\";\n\n    /// <summary>\n    /// TypeScript language.\n    /// </summary>\n    public const string TypeScript = \"typescript\";\n\n    /// <summary>\n    /// JavaScript language.\n    /// </summary>\n    public const string JavaScript = \"javascript\";\n\n    /// <summary>\n    /// Bash shell.\n    /// </summary>\n    public const string Bash = \"bash\";\n}\n\n/// <summary>\n/// Represents a code execution context.\n/// </summary>\npublic class CodeContext\n{\n    /// <summary>\n    /// Gets or sets the context ID.\n    /// </summary>\n    [JsonPropertyName(\"id\")]\n    public string? Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the programming language.\n    /// </summary>\n    [JsonPropertyName(\"language\")]\n    public required string Language { get; set; }\n}\n\n/// <summary>\n/// Request to run code.\n/// </summary>\npublic class RunCodeRequest\n{\n    /// <summary>\n    /// Gets or sets the code to execute.\n    /// </summary>\n    [JsonPropertyName(\"code\")]\n    public required string Code { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution context.\n    /// </summary>\n    [JsonPropertyName(\"context\")]\n    public required CodeContext Context { get; set; }\n}\n\n/// <summary>\n/// Options for running code.\n/// </summary>\npublic class RunCodeOptions\n{\n    /// <summary>\n    /// Gets or sets the execution context. If provided, code runs in this context.\n    /// </summary>\n    public CodeContext? Context { get; set; }\n\n    /// <summary>\n    /// Gets or sets the language for a new ephemeral context.\n    /// Cannot be used together with Context.\n    /// </summary>\n    /// <remarks>\n    /// When only <see cref=\"Language\"/> is provided and <see cref=\"Context\"/> is null, execd creates or reuses\n    /// a default session for that language, so state can persist across runs.\n    /// </remarks>\n    public string? Language { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution event handlers.\n    /// </summary>\n    public OpenSandbox.Models.ExecutionHandlers? Handlers { get; set; }\n}\n\n/// <summary>\n/// Request to create a code context.\n/// </summary>\ninternal class CreateContextRequest\n{\n    /// <summary>\n    /// Gets or sets the programming language.\n    /// </summary>\n    [JsonPropertyName(\"language\")]\n    public required string Language { get; set; }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/OpenSandbox.CodeInterpreter.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFrameworks>netstandard2.0;netstandard2.1;net6.0;net7.0;net8.0;net9.0;net10.0</TargetFrameworks>\n    <LangVersion>latest</LangVersion>\n    <Nullable>enable</Nullable>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <RootNamespace>OpenSandbox.CodeInterpreter</RootNamespace>\n    <AssemblyName>OpenSandbox.CodeInterpreter</AssemblyName>\n\n    <!-- Package Information -->\n    <PackageId>Alibaba.OpenSandbox.CodeInterpreter</PackageId>\n    <Version>$(OpenSandboxCodeInterpreterPackageVersion)</Version>\n    <Authors>Alibaba Group</Authors>\n    <Company>Alibaba Group Holding Ltd.</Company>\n    <Product>OpenSandbox Code Interpreter SDK</Product>\n    <Description>A C# SDK for code interpretation with OpenSandbox. Provides high-level APIs for executing code in multiple languages (Python, JavaScript, TypeScript, Go, Java, Bash) within secure sandbox environments.</Description>\n    <Copyright>Copyright 2026 Alibaba Group Holding Ltd.</Copyright>\n    <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>\n    <PackageProjectUrl>https://open-sandbox.ai</PackageProjectUrl>\n    <RepositoryUrl>https://github.com/alibaba/OpenSandbox.git</RepositoryUrl>\n    <RepositoryType>git</RepositoryType>\n    <PackageTags>sandbox;code-interpreter;execution;opensandbox;alibaba;python;javascript</PackageTags>\n    <PackageReadmeFile>README.md</PackageReadmeFile>\n\n    <!-- Build Settings -->\n    <GenerateDocumentationFile>true</GenerateDocumentationFile>\n    <NoWarn>$(NoWarn);CS1591</NoWarn>\n    <TreatWarningsAsErrors Condition=\"'$(Configuration)' == 'Release'\">true</TreatWarningsAsErrors>\n\n    <!--\n      Default to local source reference for development.\n      For release packing with NuGet version range, run:\n      dotnet pack -c Release -p:UseLocalOpenSandboxProjectReference=false\n    -->\n    <UseLocalOpenSandboxProjectReference Condition=\"'$(UseLocalOpenSandboxProjectReference)' == ''\">true</UseLocalOpenSandboxProjectReference>\n  </PropertyGroup>\n\n  <!-- Expose internals to test project -->\n  <ItemGroup>\n    <InternalsVisibleTo Include=\"OpenSandbox.CodeInterpreter.Tests\" />\n  </ItemGroup>\n\n  <!-- Use local project reference for day-to-day development -->\n  <ItemGroup Condition=\"'$(UseLocalOpenSandboxProjectReference)' == 'true'\">\n    <ProjectReference Include=\"..\\..\\..\\..\\sandbox\\csharp\\src\\OpenSandbox\\OpenSandbox.csproj\" />\n  </ItemGroup>\n\n  <!-- Use NuGet dependency range for release packaging -->\n  <ItemGroup Condition=\"'$(UseLocalOpenSandboxProjectReference)' != 'true'\">\n    <PackageReference Include=\"Alibaba.OpenSandbox\" Version=\"$(OpenSandboxDependencyVersionRange)\" />\n  </ItemGroup>\n\n  <!-- Common Dependencies -->\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.Extensions.Logging.Abstractions\" Version=\"8.0.2\" />\n    <PackageReference Include=\"System.Text.Json\" Version=\"8.0.5\" />\n    <PackageReference Include=\"PolySharp\" Version=\"1.14.1\">\n      <PrivateAssets>all</PrivateAssets>\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n    </PackageReference>\n  </ItemGroup>\n\n  <!-- .NET Standard 2.0 specific dependencies -->\n  <ItemGroup Condition=\"'$(TargetFramework)' == 'netstandard2.0'\">\n    <PackageReference Include=\"Microsoft.Bcl.AsyncInterfaces\" Version=\"8.0.0\" />\n  </ItemGroup>\n\n  <!-- Package files -->\n  <ItemGroup>\n    <None Include=\"..\\..\\README.md\" Pack=\"true\" PackagePath=\"\\\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/Services/ICodes.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Core;\nusing OpenSandbox.Models;\n\nnamespace OpenSandbox.CodeInterpreter.Services;\n\n/// <summary>\n/// Service interface for code execution operations.\n/// </summary>\npublic interface ICodes\n{\n    /// <summary>\n    /// Creates a new code execution context for the specified language.\n    /// </summary>\n    /// <param name=\"language\">The programming language (use <see cref=\"SupportedLanguage\"/> constants).</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The created context.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"language\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<CodeContext> CreateContextAsync(string language, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Gets an existing context by ID.\n    /// </summary>\n    /// <param name=\"contextId\">The context ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The context.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"contextId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<CodeContext> GetContextAsync(string contextId, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Lists active contexts for the specified language.\n    /// </summary>\n    /// <param name=\"language\">Required language filter.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>List of contexts.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"language\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<IReadOnlyList<CodeContext>> ListContextsAsync(string language, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Deletes a context by ID.\n    /// </summary>\n    /// <param name=\"contextId\">The context ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"contextId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task DeleteContextAsync(string contextId, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Deletes all contexts for the specified language.\n    /// </summary>\n    /// <param name=\"language\">The programming language.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"language\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task DeleteContextsAsync(string language, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Runs code and returns the execution result.\n    /// </summary>\n    /// <param name=\"code\">The code to execute.</param>\n    /// <param name=\"options\">Optional execution options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The execution result.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when required request fields are missing.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<Execution> RunAsync(string code, RunCodeOptions? options = null, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Runs code and streams execution events.\n    /// </summary>\n    /// <param name=\"request\">The run code request.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>An async enumerable of server stream events.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when the request is invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    IAsyncEnumerable<ServerStreamEvent> RunStreamAsync(RunCodeRequest request, CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Interrupts a running code execution.\n    /// </summary>\n    /// <param name=\"contextId\">The context ID to interrupt.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"contextId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task InterruptAsync(string contextId, CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/tests/OpenSandbox.CodeInterpreter.Tests/CodeInterpreterTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Core;\nusing Xunit;\n\nnamespace OpenSandbox.CodeInterpreter.Tests;\n\npublic class CodeInterpreterTests\n{\n    [Fact]\n    public async Task CreateAsync_ThrowsOnNullSandbox()\n    {\n        await Assert.ThrowsAsync<InvalidArgumentException>(\n            () => CodeInterpreter.CreateAsync(null!));\n    }\n\n    [Fact]\n    public void CodeInterpreterCreateOptions_DefaultsAreNull()\n    {\n        var options = new CodeInterpreterCreateOptions();\n\n        Assert.Null(options.AdapterFactory);\n    }\n\n    [Fact]\n    public void CodeInterpreterCreateOptions_CanSetAdapterFactory()\n    {\n        var factory = new TestAdapterFactory();\n        var options = new CodeInterpreterCreateOptions\n        {\n            AdapterFactory = factory\n        };\n\n        Assert.Same(factory, options.AdapterFactory);\n    }\n\n    private class TestAdapterFactory : Factory.ICodeInterpreterAdapterFactory\n    {\n        public Services.ICodes CreateCodes(Factory.CreateCodesStackOptions options)\n        {\n            throw new NotImplementedException();\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/tests/OpenSandbox.CodeInterpreter.Tests/CodesAdapterTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Net;\nusing System.Net.Http.Headers;\nusing System.Text;\nusing OpenSandbox.CodeInterpreter.Adapters;\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\nusing Xunit;\n\nnamespace OpenSandbox.CodeInterpreter.Tests;\n\npublic class CodesAdapterTests\n{\n    [Fact]\n    public async Task ListContextsAsync_ThrowsOnEmptyLanguage()\n    {\n        var adapter = CreateAdapter(\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))),\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))));\n\n        await Assert.ThrowsAsync<InvalidArgumentException>(() => adapter.ListContextsAsync(\" \"));\n    }\n\n    [Fact]\n    public async Task ListContextsAsync_SendsLanguageQuery()\n    {\n        var httpHandler = new StubHttpMessageHandler((request, _) =>\n        {\n            var body = \"[{\\\"id\\\":\\\"ctx-1\\\",\\\"language\\\":\\\"python\\\"}]\";\n            var response = new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(body, Encoding.UTF8, \"application/json\")\n            };\n            return Task.FromResult(response);\n        });\n\n        var adapter = CreateAdapter(\n            httpHandler,\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))));\n\n        var contexts = await adapter.ListContextsAsync(\"python\");\n\n        Assert.Single(contexts);\n        Assert.Equal(\"python\", contexts[0].Language);\n        Assert.Contains(httpHandler.RequestUris, uri => uri.Contains(\"/code/contexts?language=python\", StringComparison.Ordinal));\n    }\n\n    [Fact]\n    public async Task RunStreamAsync_ThrowsOnEmptyCode()\n    {\n        var adapter = CreateAdapter(\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))),\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))));\n\n        var request = new RunCodeRequest\n        {\n            Code = \"   \",\n            Context = new CodeContext { Language = SupportedLanguage.Python }\n        };\n\n        await Assert.ThrowsAsync<InvalidArgumentException>(() => DrainAsync(adapter.RunStreamAsync(request)));\n    }\n\n    [Fact]\n    public async Task RunStreamAsync_ParsesSseEvent()\n    {\n        var sseHandler = new StubHttpMessageHandler((request, _) =>\n        {\n            var response = new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(\n                    \"data: {\\\"type\\\":\\\"stdout\\\",\\\"text\\\":\\\"hello\\\",\\\"timestamp\\\":1}\\n\\n\",\n                    Encoding.UTF8,\n                    \"text/event-stream\")\n            };\n            return Task.FromResult(response);\n        });\n\n        var adapter = CreateAdapter(\n            new StubHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))),\n            sseHandler);\n\n        var request = new RunCodeRequest\n        {\n            Code = \"print('hello')\",\n            Context = new CodeContext { Language = SupportedLanguage.Python }\n        };\n\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in adapter.RunStreamAsync(request))\n        {\n            events.Add(ev);\n        }\n\n        Assert.Single(events);\n        Assert.Equal(ServerStreamEventTypes.Stdout, events[0].Type);\n        Assert.Equal(\"hello\", events[0].Text);\n        Assert.Contains(sseHandler.AcceptHeaders, value => value.Contains(\"text/event-stream\", StringComparison.OrdinalIgnoreCase));\n    }\n\n    private static async Task DrainAsync<T>(IAsyncEnumerable<T> source)\n    {\n        await foreach (var _ in source)\n        {\n        }\n    }\n\n    private static CodesAdapter CreateAdapter(HttpMessageHandler httpHandler, HttpMessageHandler sseHandler)\n    {\n        var baseUrl = \"http://execd.local\";\n        var headers = new Dictionary<string, string> { [\"X-Test\"] = \"true\" };\n        var client = new HttpClientWrapper(new HttpClient(httpHandler), baseUrl, headers);\n        var sseHttpClient = new HttpClient(sseHandler);\n        var logger = NullLoggerFactory.Instance.CreateLogger(\"CodesAdapterTests\");\n        return new CodesAdapter(client, sseHttpClient, baseUrl, headers, logger);\n    }\n\n    private sealed class StubHttpMessageHandler : HttpMessageHandler\n    {\n        private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;\n\n        public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)\n        {\n            _handler = handler;\n        }\n\n        public List<string> RequestUris { get; } = new();\n        public List<string> AcceptHeaders { get; } = new();\n\n        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n        {\n            RequestUris.Add(request.RequestUri?.ToString() ?? string.Empty);\n            AcceptHeaders.Add(string.Join(\",\", request.Headers.Accept.Select(MediaTypeToString)));\n            return await _handler(request, cancellationToken).ConfigureAwait(false);\n        }\n\n        private static string MediaTypeToString(MediaTypeWithQualityHeaderValue value)\n        {\n            return value.MediaType ?? string.Empty;\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/tests/OpenSandbox.CodeInterpreter.Tests/FactoryTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter.Factory;\nusing OpenSandbox.Core;\nusing OpenSandbox;\nusing Microsoft.Extensions.Logging.Abstractions;\nusing Xunit;\n\nnamespace OpenSandbox.CodeInterpreter.Tests;\n\npublic class FactoryTests\n{\n    [Fact]\n    public void DefaultCodeInterpreterAdapterFactory_Create_ReturnsInstance()\n    {\n        var factory = DefaultCodeInterpreterAdapterFactory.Create();\n\n        Assert.NotNull(factory);\n        Assert.IsType<DefaultCodeInterpreterAdapterFactory>(factory);\n    }\n\n    [Fact]\n    public void DefaultCodeInterpreterAdapterFactory_CreateCodes_ThrowsOnNullOptions()\n    {\n        var factory = DefaultCodeInterpreterAdapterFactory.Create();\n\n        Assert.Throws<InvalidArgumentException>(() => factory.CreateCodes(null!));\n    }\n\n    [Fact]\n    public void DefaultCodeInterpreterAdapterFactory_CreateCodes_ThrowsOnNullConnectionConfig()\n    {\n        var factory = DefaultCodeInterpreterAdapterFactory.Create();\n        var options = new CreateCodesStackOptions\n        {\n            ConnectionConfig = null!,\n            ExecdBaseUrl = \"http://localhost:44772\",\n            ExecdHeaders = new Dictionary<string, string>(),\n            HttpClientProvider = new HttpClientProvider(new OpenSandbox.Config.ConnectionConfig(), NullLoggerFactory.Instance),\n            LoggerFactory = NullLoggerFactory.Instance\n        };\n\n        Assert.Throws<InvalidArgumentException>(() => factory.CreateCodes(options));\n    }\n\n    [Fact]\n    public void DefaultCodeInterpreterAdapterFactory_CreateCodes_ThrowsOnEmptyBaseUrl()\n    {\n        var factory = DefaultCodeInterpreterAdapterFactory.Create();\n\n        var options = new CreateCodesStackOptions\n        {\n            ConnectionConfig = new OpenSandbox.Config.ConnectionConfig(),\n            ExecdBaseUrl = \"\",\n            ExecdHeaders = new Dictionary<string, string>(),\n            HttpClientProvider = new HttpClientProvider(new OpenSandbox.Config.ConnectionConfig(), NullLoggerFactory.Instance),\n            LoggerFactory = NullLoggerFactory.Instance\n        };\n\n        Assert.Throws<InvalidArgumentException>(() => factory.CreateCodes(options));\n    }\n\n    [Fact]\n    public void CreateCodesStackOptions_RequiredProperties()\n    {\n        var options = new CreateCodesStackOptions\n        {\n            ConnectionConfig = new OpenSandbox.Config.ConnectionConfig(),\n            ExecdBaseUrl = \"http://test:8080\",\n            ExecdHeaders = new Dictionary<string, string> { [\"X-Test\"] = \"value\" },\n            HttpClientProvider = new HttpClientProvider(new OpenSandbox.Config.ConnectionConfig(), NullLoggerFactory.Instance),\n            LoggerFactory = NullLoggerFactory.Instance\n        };\n\n        Assert.NotNull(options.ConnectionConfig);\n        Assert.Equal(\"http://test:8080\", options.ExecdBaseUrl);\n        Assert.Equal(\"value\", options.ExecdHeaders[\"X-Test\"]);\n        Assert.NotNull(options.HttpClientProvider);\n        Assert.NotNull(options.LoggerFactory);\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/tests/OpenSandbox.CodeInterpreter.Tests/ModelsTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json;\nusing OpenSandbox.CodeInterpreter.Models;\nusing Xunit;\n\nnamespace OpenSandbox.CodeInterpreter.Tests;\n\npublic class ModelsTests\n{\n    [Fact]\n    public void SupportedLanguage_HasCorrectValues()\n    {\n        Assert.Equal(\"python\", SupportedLanguage.Python);\n        Assert.Equal(\"java\", SupportedLanguage.Java);\n        Assert.Equal(\"go\", SupportedLanguage.Go);\n        Assert.Equal(\"typescript\", SupportedLanguage.TypeScript);\n        Assert.Equal(\"javascript\", SupportedLanguage.JavaScript);\n        Assert.Equal(\"bash\", SupportedLanguage.Bash);\n    }\n\n    [Fact]\n    public void CodeContext_SerializesToJson()\n    {\n        var context = new CodeContext\n        {\n            Id = \"ctx-123\",\n            Language = SupportedLanguage.Python\n        };\n\n        var json = JsonSerializer.Serialize(context);\n        Assert.Contains(\"\\\"id\\\":\\\"ctx-123\\\"\", json);\n        Assert.Contains(\"\\\"language\\\":\\\"python\\\"\", json);\n    }\n\n    [Fact]\n    public void CodeContext_DeserializesFromJson()\n    {\n        var json = \"{\\\"id\\\":\\\"ctx-456\\\",\\\"language\\\":\\\"javascript\\\"}\";\n        var context = JsonSerializer.Deserialize<CodeContext>(json);\n\n        Assert.NotNull(context);\n        Assert.Equal(\"ctx-456\", context.Id);\n        Assert.Equal(\"javascript\", context.Language);\n    }\n\n    [Fact]\n    public void CodeContext_DeserializesWithNullId()\n    {\n        var json = \"{\\\"language\\\":\\\"python\\\"}\";\n        var context = JsonSerializer.Deserialize<CodeContext>(json);\n\n        Assert.NotNull(context);\n        Assert.Null(context.Id);\n        Assert.Equal(\"python\", context.Language);\n    }\n\n    [Fact]\n    public void RunCodeRequest_SerializesToJson()\n    {\n        var request = new RunCodeRequest\n        {\n            Code = \"print(\\\"hello\\\")\",\n            Context = new CodeContext\n            {\n                Id = \"ctx-789\",\n                Language = SupportedLanguage.Python\n            }\n        };\n\n        var json = JsonSerializer.Serialize(request);\n        Assert.Contains(\"\\\"code\\\":\", json);\n        Assert.Contains(\"print\", json);\n        Assert.Contains(\"\\\"context\\\":\", json);\n        Assert.Contains(\"\\\"id\\\":\\\"ctx-789\\\"\", json);\n        Assert.Contains(\"\\\"language\\\":\\\"python\\\"\", json);\n    }\n\n    [Fact]\n    public void RunCodeRequest_DeserializesFromJson()\n    {\n        var json = \"{\\\"code\\\":\\\"console.log('test')\\\",\\\"context\\\":{\\\"id\\\":\\\"ctx-abc\\\",\\\"language\\\":\\\"javascript\\\"}}\";\n        var request = JsonSerializer.Deserialize<RunCodeRequest>(json);\n\n        Assert.NotNull(request);\n        Assert.Equal(\"console.log('test')\", request.Code);\n        Assert.NotNull(request.Context);\n        Assert.Equal(\"ctx-abc\", request.Context.Id);\n        Assert.Equal(\"javascript\", request.Context.Language);\n    }\n\n    [Fact]\n    public void RunCodeOptions_DefaultsAreNull()\n    {\n        var options = new RunCodeOptions();\n\n        Assert.Null(options.Context);\n        Assert.Null(options.Language);\n        Assert.Null(options.Handlers);\n    }\n\n    [Fact]\n    public void RunCodeOptions_CanSetProperties()\n    {\n        var context = new CodeContext { Language = SupportedLanguage.Go };\n        var handlers = new OpenSandbox.Models.ExecutionHandlers();\n\n        var options = new RunCodeOptions\n        {\n            Context = context,\n            Handlers = handlers\n        };\n\n        Assert.Same(context, options.Context);\n        Assert.Same(handlers, options.Handlers);\n    }\n\n    [Fact]\n    public void RunCodeOptions_CanSetLanguageOnly()\n    {\n        var options = new RunCodeOptions\n        {\n            Language = SupportedLanguage.TypeScript\n        };\n\n        Assert.Null(options.Context);\n        Assert.Equal(SupportedLanguage.TypeScript, options.Language);\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/csharp/tests/OpenSandbox.CodeInterpreter.Tests/OpenSandbox.CodeInterpreter.Tests.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <LangVersion>latest</LangVersion>\n    <Nullable>enable</Nullable>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <IsPackable>false</IsPackable>\n    <IsTestProject>true</IsTestProject>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.NET.Test.Sdk\" Version=\"17.11.1\" />\n    <PackageReference Include=\"xunit\" Version=\"2.9.2\" />\n    <PackageReference Include=\"xunit.runner.visualstudio\" Version=\"2.8.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n    <PackageReference Include=\"coverlet.collector\" Version=\"6.0.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n  </ItemGroup>\n\n  <ItemGroup>\n    <ProjectReference Include=\"..\\..\\src\\OpenSandbox.CodeInterpreter\\OpenSandbox.CodeInterpreter.csproj\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/.nvmrc",
    "content": "20\n\n\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/README.md",
    "content": "# Alibaba Code Interpreter SDK for JavaScript/TypeScript\n\nEnglish | [中文](README_zh.md)\n\nA TypeScript/JavaScript SDK for executing code in secure, isolated sandboxes. It provides a high-level API for running Python, Java, Go, TypeScript, and other languages safely, with support for code execution contexts.\n\n## Prerequisites\n\nThis SDK requires a Docker image containing the Code Interpreter runtime environment. You must use the `opensandbox/code-interpreter` image (or a derivative) which includes pre-installed runtimes for Python, Java, Go, Node.js, etc.\n\nFor detailed information about supported languages and versions, please refer to the [Environment Documentation](../../../sandboxes/code-interpreter/README.md).\n\n## Installation\n\n### npm\n\n```bash\nnpm install @alibaba-group/opensandbox-code-interpreter\n```\n\n### pnpm\n\n```bash\npnpm add @alibaba-group/opensandbox-code-interpreter\n```\n\n### yarn\n\n```bash\nyarn add @alibaba-group/opensandbox-code-interpreter\n```\n\n## Quick Start\n\nThe following example demonstrates how to create a sandbox with a specific runtime configuration and execute a simple script.\n\n> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions.\n\n```ts\nimport { ConnectionConfig, Sandbox } from \"@alibaba-group/opensandbox\";\nimport { CodeInterpreter, SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\n// 1. Configure connection\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-api-key\",\n});\n\n// 2. Create a Sandbox with the code-interpreter image + runtime versions\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"opensandbox/code-interpreter:v1.0.2\",\n  entrypoint: [\"/opt/opensandbox/code-interpreter.sh\"],\n  env: {\n    PYTHON_VERSION: \"3.11\",\n    JAVA_VERSION: \"17\",\n    NODE_VERSION: \"20\",\n    GO_VERSION: \"1.24\",\n  },\n  timeoutSeconds: 15 * 60,\n});\n\n// 3. Create CodeInterpreter wrapper\nconst ci = await CodeInterpreter.create(sandbox);\n\n// 4. Create an execution context (Python)\nconst ctx = await ci.codes.createContext(SupportedLanguages.PYTHON);\n\n// 5. Run code\nconst result = await ci.codes.run(\"import sys\\nprint(sys.version)\\nresult = 2 + 2\\nresult\", {\n  context: ctx,\n});\n\n// 6. Print output\nconsole.log(result.result[0]?.text);\n\n// 7. Cleanup remote instance (optional but recommended)\nawait sandbox.kill();\nawait sandbox.close();\n```\n\n## Runtime Configuration\n\n### Docker Image\n\nThe Code Interpreter SDK relies on a specialized environment. Ensure your sandbox provider has the `opensandbox/code-interpreter` image available.\n\n### Language Version Selection\n\nYou can specify the desired version of a programming language by setting the corresponding environment variable when creating the `Sandbox`.\n\n| Language | Environment Variable | Example Value | Default (if unset) |\n| --- | --- | --- | --- |\n| Python | `PYTHON_VERSION` | `3.11` | Image default |\n| Java | `JAVA_VERSION` | `17` | Image default |\n| Node.js | `NODE_VERSION` | `20` | Image default |\n| Go | `GO_VERSION` | `1.24` | Image default |\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"opensandbox/code-interpreter:v1.0.2\",\n  entrypoint: [\"/opt/opensandbox/code-interpreter.sh\"],\n  env: {\n    JAVA_VERSION: \"17\",\n    GO_VERSION: \"1.24\",\n  },\n});\n```\n\n## Usage Examples\n\n### 0. Run with `language` (default language context)\n\nIf you don't need to manage explicit context IDs, you can run code by specifying only `language`.\nWhen `context.id` is omitted, execd can create/reuse a default session for that language, so state can persist across runs.\n\n```ts\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nawait ci.codes.run(\"x = 42\", { language: SupportedLanguages.PYTHON });\nconst execution = await ci.codes.run(\"result = x\\nresult\", { language: SupportedLanguages.PYTHON });\nconsole.log(execution.result[0]?.text); // \"42\"\n```\n\n### 0.1 Context management (list/get/delete)\n\nYou can manage contexts explicitly (aligned with Python/Kotlin SDKs):\n\n```ts\nconst ctx = await ci.codes.createContext(SupportedLanguages.PYTHON);\n\nconst same = await ci.codes.getContext(ctx.id!);\nconsole.log(same.id, same.language);\n\nconst all = await ci.codes.listContexts();\nconst pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON);\n\nawait ci.codes.deleteContext(ctx.id!);\nawait ci.codes.deleteContexts(SupportedLanguages.PYTHON); // bulk cleanup\n```\n\n### 1. Java Code Execution\n\n```ts\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nconst javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA);\nconst execution = await ci.codes.run(\n  [\n    'System.out.println(\"Calculating sum...\");',\n    \"int a = 10;\",\n    \"int b = 20;\",\n    \"int sum = a + b;\",\n    'System.out.println(\"Sum: \" + sum);',\n    \"sum\",\n  ].join(\"\\n\"),\n  { context: javaCtx },\n);\nconsole.log(execution.logs.stdout.map((m) => m.text));\n```\n\n### 2. Streaming Output Handling\n\nHandle stdout/stderr and execution events in real-time.\n\n```ts\nimport type { ExecutionHandlers } from \"@alibaba-group/opensandbox\";\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nconst handlers: ExecutionHandlers = {\n  onStdout: (m) => console.log(\"STDOUT:\", m.text),\n  onStderr: (m) => console.error(\"STDERR:\", m.text),\n  onResult: (r) => console.log(\"RESULT:\", r.text),\n};\n\nconst pyCtx = await ci.codes.createContext(SupportedLanguages.PYTHON);\nawait ci.codes.run(\"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.2)\", {\n  context: pyCtx,\n  handlers,\n});\n```\n\n## Notes\n\n- **Lifecycle**: `CodeInterpreter` wraps an existing `Sandbox` instance and reuses its connection configuration. Each sandbox instance clones the transport via `ConnectionConfig.withTransportIfMissing()`, so call `sandbox.close()` when you are finished to release the Node.js keep-alive agent and avoid leak.\n- **Default context**: `codes.run(..., { language })` uses a language default context (state can persist across runs).\n\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/README_zh.md",
    "content": "# Alibaba Code Interpreter JavaScript/TypeScript SDK\n\n中文 | [English](README.md)\n\n一个用于在安全、隔离的沙箱环境中执行代码的 TypeScript/JavaScript SDK。该 SDK 提供了高级 API，支持安全地运行 Python、Java、Go、TypeScript 等语言，并具备“代码执行上下文（Context）”能力。\n\n## 前置要求\n\n本 SDK 需要配合包含 Code Interpreter 运行时环境的特定 Docker 镜像使用。请务必使用 `opensandbox/code-interpreter` 镜像（或其衍生镜像），其中预装了 Python、Java、Go、Node.js 等语言的运行环境。\n\n关于支持的语言与具体版本信息，请参考 [环境文档](../../../sandboxes/code-interpreter/README_zh.md)。\n\n## 安装指南\n\n### npm\n\n```bash\nnpm install @alibaba-group/opensandbox-code-interpreter\n```\n\n### pnpm\n\n```bash\npnpm add @alibaba-group/opensandbox-code-interpreter\n```\n\n### yarn\n\n```bash\nyarn add @alibaba-group/opensandbox-code-interpreter\n```\n\n## 快速开始\n\n以下示例展示了如何创建带指定运行时配置的 Sandbox，并执行一段简单脚本。\n\n> **注意**: 在运行此示例之前，请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。\n\n```ts\nimport { ConnectionConfig, Sandbox } from \"@alibaba-group/opensandbox\";\nimport { CodeInterpreter, SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\n// 1. 配置连接信息\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-api-key\",\n});\n\n// 2. 创建 Sandbox（必须使用 code-interpreter 镜像），并指定语言版本\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"opensandbox/code-interpreter:v1.0.2\",\n  entrypoint: [\"/opt/opensandbox/code-interpreter.sh\"],\n  env: {\n    PYTHON_VERSION: \"3.11\",\n    JAVA_VERSION: \"17\",\n    NODE_VERSION: \"20\",\n    GO_VERSION: \"1.24\",\n  },\n  timeoutSeconds: 15 * 60,\n});\n\n// 3. 创建 CodeInterpreter 包装器\nconst ci = await CodeInterpreter.create(sandbox);\n\n// 4. 创建执行上下文（Python）\nconst ctx = await ci.codes.createContext(SupportedLanguages.PYTHON);\n\n// 5. 运行代码\nconst result = await ci.codes.run(\"import sys\\nprint(sys.version)\\nresult = 2 + 2\\nresult\", {\n  context: ctx,\n});\n\n// 6. 打印输出\nconsole.log(result.result[0]?.text);\n\n// 7. 清理远程实例（可选，但推荐）\nawait sandbox.kill();\nawait sandbox.close();\n```\n\n## 运行时配置\n\n### Docker 镜像\n\nCode Interpreter SDK 依赖于特定的运行环境。请确保你的沙箱服务提供商支持 `opensandbox/code-interpreter` 镜像。\n\n### 语言版本选择\n\n你可以在创建 `Sandbox` 时通过环境变量指定所需的编程语言版本。\n\n| 语言 | 环境变量 | 示例值 | 默认值（若不设置） |\n| --- | --- | --- | --- |\n| Python | `PYTHON_VERSION` | `3.11` | 镜像默认值 |\n| Java | `JAVA_VERSION` | `17` | 镜像默认值 |\n| Node.js | `NODE_VERSION` | `20` | 镜像默认值 |\n| Go | `GO_VERSION` | `1.24` | 镜像默认值 |\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"opensandbox/code-interpreter:v1.0.2\",\n  entrypoint: [\"/opt/opensandbox/code-interpreter.sh\"],\n  env: {\n    JAVA_VERSION: \"17\",\n    GO_VERSION: \"1.24\",\n  },\n});\n```\n\n## 核心功能示例\n\n### 0. 直接传 `language`（使用该语言默认上下文）\n\n如果你不需要显式管理 context id，可以只传 `language` 来执行代码。\n当 `context.id` 省略时，execd 可以为该语言创建/复用默认 session，因此状态可以跨次执行保持：\n\n```ts\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nawait ci.codes.run(\"x = 42\", { language: SupportedLanguages.PYTHON });\nconst execution = await ci.codes.run(\"result = x\\nresult\", { language: SupportedLanguages.PYTHON });\nconsole.log(execution.result[0]?.text); // \"42\"\n```\n\n### 0.1 Context 管理（list/get/delete）\n\n你也可以显式管理 context（与 Python/Kotlin SDK 对齐）：\n\n```ts\nconst ctx = await ci.codes.createContext(SupportedLanguages.PYTHON);\n\nconst same = await ci.codes.getContext(ctx.id!);\nconsole.log(same.id, same.language);\n\nconst all = await ci.codes.listContexts();\nconst pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON);\n\nawait ci.codes.deleteContext(ctx.id!);\nawait ci.codes.deleteContexts(SupportedLanguages.PYTHON); // 批量清理\n```\n\n### 1. Java 代码执行\n\n```ts\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nconst javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA);\nconst execution = await ci.codes.run(\n  [\n    'System.out.println(\"Calculating sum...\");',\n    \"int a = 10;\",\n    \"int b = 20;\",\n    \"int sum = a + b;\",\n    'System.out.println(\"Sum: \" + sum);',\n    \"sum\",\n  ].join(\"\\n\"),\n  { context: javaCtx },\n);\nconsole.log(execution.logs.stdout.map((m) => m.text));\n```\n\n### 2. 流式输出处理\n\n实时处理 stdout/stderr 等事件。\n\n```ts\nimport type { ExecutionHandlers } from \"@alibaba-group/opensandbox\";\nimport { SupportedLanguages } from \"@alibaba-group/opensandbox-code-interpreter\";\n\nconst handlers: ExecutionHandlers = {\n  onStdout: (m) => console.log(\"STDOUT:\", m.text),\n  onStderr: (m) => console.error(\"STDERR:\", m.text),\n  onResult: (r) => console.log(\"RESULT:\", r.text),\n};\n\nconst pyCtx = await ci.codes.createContext(SupportedLanguages.PYTHON);\nawait ci.codes.run(\"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.2)\", {\n  context: pyCtx,\n  handlers,\n});\n```\n\n## 说明\n\n- **生命周期**：`CodeInterpreter` 基于既有的 `Sandbox` 实例进行包装，并复用其连接配置。SDK 会通过 `ConnectionConfig.withTransportIfMissing()` 为每个实例复刻 Transport，完成交互后请调用 `sandbox.close()` 释放 Node.js 的 keep-alive agent，以避免资源泄漏。\n- **默认上下文**：`codes.run(..., { language })` 会使用语言默认 context（同语言的状态可跨次执行保持）。\n\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/eslint.config.mjs",
    "content": "import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createBaseConfig } from \"../../eslint.base.mjs\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nexport default createBaseConfig({\n  tsconfigRootDir: __dirname,\n  tsconfigPath: \"./tsconfig.json\",\n  extraIgnores: [\"src/**/*.d.ts\", \"src/**/*.js\"],\n});\n\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/package.json",
    "content": "{\n  \"name\": \"@alibaba-group/opensandbox-code-interpreter\",\n  \"version\": \"0.1.3\",\n  \"description\": \"OpenSandbox Code Interpreter TypeScript/JavaScript SDK\",\n  \"license\": \"Apache-2.0\",\n  \"type\": \"module\",\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"default\": \"./dist/index.js\"\n    }\n  },\n  \"browser\": \"./dist/index.js\",\n  \"sideEffects\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/alibaba/OpenSandbox.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/alibaba/OpenSandbox/issues\"\n  },\n  \"homepage\": \"https://open-sandbox.ai\",\n  \"files\": [\n    \"dist\",\n    \"src\"\n  ],\n  \"engines\": {\n    \"node\": \">=20\"\n  },\n  \"packageManager\": \"pnpm@9.15.0\",\n  \"scripts\": {\n    \"build\": \"tsup\",\n    \"test\": \"pnpm run build && node --test tests/*.test.mjs\",\n    \"lint\": \"eslint src --max-warnings 0\",\n    \"clean\": \"rm -rf dist\"\n  },\n  \"dependencies\": {\n    \"@alibaba-group/opensandbox\": \"workspace:^\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"eslint\": \"^9.39.2\",\n    \"tsup\": \"^8.5.0\",\n    \"typescript\": \"^5.7.2\",\n    \"typescript-eslint\": \"^8.52.0\"\n  }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/adapters/codesAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecdClient, ExecdPaths } from \"@alibaba-group/opensandbox/internal\";\nimport type { ServerStreamEvent } from \"@alibaba-group/opensandbox\";\nimport type { Execution, ExecutionHandlers } from \"@alibaba-group/opensandbox\";\nimport {\n  ExecutionEventDispatcher,\n  InvalidArgumentException,\n} from \"@alibaba-group/opensandbox\";\n\nimport type { Codes } from \"../services/codes.js\";\nimport type { CodeContext, SupportedLanguage } from \"../models.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport { parseJsonEventStream } from \"./sse.js\";\n\ntype ApiCreateContextRequest =\n  ExecdPaths[\"/code/context\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"];\ntype ApiCreateContextOk =\n  ExecdPaths[\"/code/context\"][\"post\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiGetContextOk =\n  ExecdPaths[\"/code/contexts/{context_id}\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiListContextsOk =\n  ExecdPaths[\"/code/contexts\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiRunCodeRequest =\n  ExecdPaths[\"/code\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"];\n\n/**\n * Single-layer codes adapter for the Code Interpreter SDK.\n *\n * - Handles HTTP/SSE streaming via the underlying execd adapter\n * - Builds the structured {@link Execution} result for `run(...)`\n */\nfunction joinUrl(baseUrl: string, pathname: string): string {\n  const base = baseUrl.endsWith(\"/\") ? baseUrl.slice(0, -1) : baseUrl;\n  const path = pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n  return `${base}${path}`;\n}\n\nexport class CodesAdapter implements Codes {\n  private readonly fetch: typeof fetch;\n\n  constructor(\n    private readonly client: ExecdClient,\n    private readonly opts: { baseUrl: string; fetch?: typeof fetch; headers?: Record<string, string> },\n  ) {\n    this.fetch = opts.fetch ?? fetch;\n  }\n\n  async createContext(language: SupportedLanguage): Promise<CodeContext> {\n    const body: ApiCreateContextRequest = { language };\n    const { data, error, response } = await this.client.POST(\"/code/context\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Create code context failed\");\n    const ok = data as ApiCreateContextOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Create code context failed: unexpected response shape\");\n    }\n    if (typeof ok.language !== \"string\" || !ok.language) {\n      throw new Error(\"Create code context failed: missing language\");\n    }\n    return { id: ok.id, language: ok.language };\n  }\n\n  async getContext(contextId: string): Promise<CodeContext> {\n    if (!contextId?.trim()) {\n      throw new InvalidArgumentException({ message: \"contextId cannot be empty\" });\n    }\n    const { data, error, response } = await this.client.GET(\"/code/contexts/{context_id}\", {\n      params: { path: { context_id: contextId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get code context failed\");\n    const ok = data as ApiGetContextOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Get code context failed: unexpected response shape\");\n    }\n    if (typeof (ok as any).language !== \"string\" || !(ok as any).language) {\n      throw new Error(\"Get code context failed: missing language\");\n    }\n    return { id: (ok as any).id, language: (ok as any).language };\n  }\n\n  async listContexts(language?: SupportedLanguage): Promise<CodeContext[]> {\n    const { data, error, response } = await this.client.GET(\"/code/contexts\", {\n      params: language ? { query: { language } } : undefined,\n    } as any);\n    throwOnOpenApiFetchError({ error, response }, \"List code contexts failed\");\n    const ok = data as ApiListContextsOk | undefined;\n    if (!Array.isArray(ok)) {\n      throw new Error(\"List code contexts failed: unexpected response shape\");\n    }\n    return ok\n      .filter((c) => c && typeof c === \"object\")\n      .map((c: any) => ({ id: c.id, language: c.language as any }));\n  }\n\n  async deleteContext(contextId: string): Promise<void> {\n    if (!contextId?.trim()) {\n      throw new InvalidArgumentException({ message: \"contextId cannot be empty\" });\n    }\n    const { error, response } = await this.client.DELETE(\"/code/contexts/{context_id}\", {\n      params: { path: { context_id: contextId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Delete code context failed\");\n  }\n\n  async deleteContexts(language: SupportedLanguage): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/code/contexts\", {\n      params: { query: { language } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Delete code contexts failed\");\n  }\n\n  async interrupt(contextId: string): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/code\", {\n      params: { query: { id: contextId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Interrupt code failed\");\n  }\n\n  async *runStream(req: ApiRunCodeRequest, signal?: AbortSignal): AsyncIterable<ServerStreamEvent> {\n    const url = joinUrl(this.opts.baseUrl, \"/code\");\n    const body = JSON.stringify(req);\n    const res = await this.fetch(url, {\n      method: \"POST\",\n      headers: {\n        \"accept\": \"text/event-stream\",\n        \"content-type\": \"application/json\",\n        ...(this.opts.headers ?? {}),\n      },\n      body,\n      signal,\n    });\n\n    for await (const ev of parseJsonEventStream<ServerStreamEvent>(res, { fallbackErrorMessage: \"Run code failed\" })) {\n      yield ev;\n    }\n  }\n\n  async run(\n    code: string,\n    opts: { context?: CodeContext; language?: SupportedLanguage; handlers?: ExecutionHandlers; signal?: AbortSignal } = {},\n  ): Promise<Execution> {\n    if (!code.trim()) {\n      throw new InvalidArgumentException({ message: \"Code cannot be empty\" });\n    }\n\n    if (opts.context && opts.language) {\n      throw new InvalidArgumentException({ message: \"Provide either opts.context or opts.language, not both\" });\n    }\n\n    const context: CodeContext =\n      opts.context ??\n      (opts.language\n        ? { language: opts.language }\n        : { language: \"python\" });\n\n    // Make the OpenAPI contract explicit so backend schema changes surface quickly.\n    const req: ApiRunCodeRequest = {\n      code,\n      context: { id: context.id, language: context.language },\n    };\n\n    const execution: Execution = {\n      logs: { stdout: [], stderr: [] },\n      result: [],\n    };\n    const dispatcher = new ExecutionEventDispatcher(execution, opts.handlers);\n\n    for await (const ev of this.runStream(req, opts.signal)) {\n      await dispatcher.dispatch(ev as any);\n    }\n\n    return execution;\n  }\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/adapters/openapiError.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { SandboxApiException, SandboxError } from \"@alibaba-group/opensandbox\";\n\nexport function throwOnOpenApiFetchError(\n  result: { error?: unknown; response: Response },\n  fallbackMessage: string,\n): void {\n  if (!result.error) return;\n\n  const requestId = result.response.headers.get(\"x-request-id\") ?? undefined;\n  const status = (result.response as any).status ?? 0;\n\n  const err = result.error as any;\n  const message =\n    err?.message ??\n    err?.error?.message ??\n    fallbackMessage;\n\n  const code = err?.code ?? err?.error?.code;\n  const msg = err?.message ?? err?.error?.message ?? message;\n\n  throw new SandboxApiException({\n    message: msg,\n    statusCode: status,\n    requestId,\n    error: code\n      ? new SandboxError(String(code), String(msg ?? \"\"))\n      : new SandboxError(SandboxError.UNEXPECTED_RESPONSE, String(msg ?? \"\")),\n    rawBody: result.error,\n  });\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/adapters/sse.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { SandboxApiException, SandboxError } from \"@alibaba-group/opensandbox\";\n\nfunction tryParseJson(line: string): unknown | undefined {\n  try {\n    return JSON.parse(line);\n  } catch {\n    return undefined;\n  }\n}\n\n/**\n * Parses an SSE-like stream that may be either:\n * - standard SSE frames (`data: {...}\\n\\n`)\n * - newline-delimited JSON (one JSON object per line)\n */\nexport async function* parseJsonEventStream<T>(\n  res: Response,\n  opts?: { fallbackErrorMessage?: string },\n): AsyncIterable<T> {\n  if (!res.ok) {\n    const text = await res.text().catch(() => \"\");\n    const parsed = tryParseJson(text);\n    const err = parsed && typeof parsed === \"object\" ? (parsed as any) : undefined;\n    const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n    const message = err?.message ?? opts?.fallbackErrorMessage ?? `Stream request failed (status=${res.status})`;\n    const code = err?.code ? String(err.code) : SandboxError.UNEXPECTED_RESPONSE;\n    throw new SandboxApiException({\n      message,\n      statusCode: res.status,\n      requestId,\n      error: new SandboxError(code, err?.message ? String(err.message) : message),\n      rawBody: parsed ?? text,\n    });\n  }\n\n  if (!res.body) return;\n\n  const reader = res.body.getReader();\n  const decoder = new TextDecoder(\"utf-8\");\n  let buf = \"\";\n\n  while (true) {\n    const { value, done } = await reader.read();\n    if (done) break;\n\n    buf += decoder.decode(value, { stream: true });\n    let idx: number;\n\n    while ((idx = buf.indexOf(\"\\n\")) >= 0) {\n      const rawLine = buf.slice(0, idx);\n      buf = buf.slice(idx + 1);\n\n      const line = rawLine.trim();\n      if (!line) continue;\n\n      // Support standard SSE \"data:\" prefix\n      if (line.startsWith(\":\")) continue;\n      if (line.startsWith(\"event:\") || line.startsWith(\"id:\") || line.startsWith(\"retry:\")) continue;\n\n      const jsonLine = line.startsWith(\"data:\") ? line.slice(\"data:\".length).trim() : line;\n      if (!jsonLine) continue;\n\n      const parsed = tryParseJson(jsonLine);\n      if (!parsed) continue;\n      yield parsed as T;\n    }\n  }\n\n  // flush last line if exists\n  const last = buf.trim();\n  if (last) {\n    const jsonLine = last.startsWith(\"data:\") ? last.slice(\"data:\".length).trim() : last;\n    const parsed = tryParseJson(jsonLine);\n    if (parsed) yield parsed as T;\n  }\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/factory/adapterFactory.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { Sandbox } from \"@alibaba-group/opensandbox\";\nimport type { Codes } from \"../services/codes.js\";\n\nexport interface CreateCodesStackOptions {\n  sandbox: Sandbox;\n  execdBaseUrl: string;\n  endpointHeaders?: Record<string, string>;\n}\n\n/**\n * Factory abstraction for Code Interpreter SDK to decouple from concrete adapters/clients.\n */\nexport interface AdapterFactory {\n  createCodes(opts: CreateCodesStackOptions): Codes;\n}\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/factory/defaultAdapterFactory.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { createExecdClient } from \"@alibaba-group/opensandbox/internal\";\nimport type { AdapterFactory, CreateCodesStackOptions } from \"./adapterFactory.js\";\nimport { CodesAdapter } from \"../adapters/codesAdapter.js\";\nimport type { Codes } from \"../services/codes.js\";\n\nexport class DefaultAdapterFactory implements AdapterFactory {\n  createCodes(opts: CreateCodesStackOptions): Codes {\n    const headers: Record<string, string> = {\n      ...(opts.sandbox.connectionConfig.headers ?? {}),\n      ...(opts.endpointHeaders ?? {}),\n    };\n    const client = createExecdClient({\n      baseUrl: opts.execdBaseUrl,\n      headers,\n      fetch: opts.sandbox.connectionConfig.fetch,\n    });\n\n    return new CodesAdapter(client, {\n      baseUrl: opts.execdBaseUrl,\n      headers,\n      // Streaming calls (SSE) use a dedicated fetch, aligned with Kotlin/Python SDKs.\n      fetch: opts.sandbox.connectionConfig.sseFetch,\n    });\n  }\n}\n\nexport function createDefaultAdapterFactory(): AdapterFactory {\n  return new DefaultAdapterFactory();\n}\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/index.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport { CodeInterpreter } from \"./interpreter.js\";\nexport type { CodeInterpreterCreateOptions } from \"./interpreter.js\";\n\nexport type { AdapterFactory } from \"./factory/adapterFactory.js\";\nexport { DefaultAdapterFactory, createDefaultAdapterFactory } from \"./factory/defaultAdapterFactory.js\";\n\nexport type { CodeContext, SupportedLanguage } from \"./models.js\";\nexport { SupportedLanguage as SupportedLanguages } from \"./models.js\";\n\nexport type { Codes } from \"./services/codes.js\";\n\nexport type {\n  Execution,\n  ExecutionComplete,\n  ExecutionError,\n  ExecutionHandlers,\n  ExecutionInit,\n  ExecutionResult,\n  OutputMessage,\n} from \"@alibaba-group/opensandbox\";"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/interpreter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { DEFAULT_EXECD_PORT } from \"@alibaba-group/opensandbox\";\nimport type { Sandbox } from \"@alibaba-group/opensandbox\";\n\nimport { createDefaultAdapterFactory } from \"./factory/defaultAdapterFactory.js\";\nimport type { AdapterFactory } from \"./factory/adapterFactory.js\";\nimport type { Codes } from \"./services/codes.js\";\n\nexport interface CodeInterpreterCreateOptions {\n  adapterFactory?: AdapterFactory;\n}\n\n/**\n * Code interpreter facade (JS/TS).\n *\n * This class wraps an existing {@link Sandbox} and provides a high-level API for code execution.\n *\n * - Use {@link codes} to create contexts and run code.\n * - {@link files}, {@link commands}, and {@link metrics} are exposed for convenience and are\n *   the same instances as on the underlying {@link Sandbox}.\n */\nexport class CodeInterpreter {\n  private constructor(\n    readonly sandbox: Sandbox,\n    readonly codes: Codes,\n  ) {}\n\n  static async create(sandbox: Sandbox, opts: CodeInterpreterCreateOptions = {}): Promise<CodeInterpreter> {\n    const endpoint = await sandbox.getEndpoint(DEFAULT_EXECD_PORT);\n    const execdBaseUrl = `${sandbox.connectionConfig.protocol}://${endpoint.endpoint}`;\n    const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory();\n    const codes = adapterFactory.createCodes({\n      sandbox,\n      execdBaseUrl,\n      endpointHeaders: endpoint.headers,\n    });\n\n    return new CodeInterpreter(sandbox, codes);\n  }\n\n  get id() {\n    return this.sandbox.id;\n  }\n\n  get files() {\n    return this.sandbox.files;\n  }\n\n  get commands() {\n    return this.sandbox.commands;\n  }\n\n  get metrics() {\n    return this.sandbox.metrics;\n  }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/models.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport const SupportedLanguage = {\n  PYTHON: \"python\",\n  JAVA: \"java\",\n  GO: \"go\",\n  TYPESCRIPT: \"typescript\",\n  JAVASCRIPT: \"javascript\",\n  BASH: \"bash\",\n} as const;\n\nexport type SupportedLanguage =\n  (typeof SupportedLanguage)[keyof typeof SupportedLanguage];\n\nexport interface CodeContext {\n  id?: string;\n  language: SupportedLanguage | (string & {});\n}\n\nexport interface RunCodeRequest {\n  code: string;\n  context: CodeContext;\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/src/services/codes.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ServerStreamEvent } from \"@alibaba-group/opensandbox\";\nimport type { Execution, ExecutionHandlers } from \"@alibaba-group/opensandbox\";\nimport type { CodeContext, RunCodeRequest, SupportedLanguage } from \"../models.js\";\n\nexport interface Codes {\n  createContext(language: SupportedLanguage): Promise<CodeContext>;\n  /**\n   * Get an existing context by id.\n   */\n  getContext(contextId: string): Promise<CodeContext>;\n  /**\n   * List active contexts. If language is provided, filters by language/runtime.\n   */\n  listContexts(language?: SupportedLanguage): Promise<CodeContext[]>;\n  /**\n   * Delete a context by id.\n   */\n  deleteContext(contextId: string): Promise<void>;\n  /**\n   * Delete all contexts under the specified language/runtime.\n   */\n  deleteContexts(language: SupportedLanguage): Promise<void>;\n\n  run(\n    code: string,\n    opts?: { context?: CodeContext; language?: SupportedLanguage; handlers?: ExecutionHandlers; signal?: AbortSignal },\n  ): Promise<Execution>;\n\n  runStream(\n    req: RunCodeRequest,\n    signal?: AbortSignal,\n  ): AsyncIterable<ServerStreamEvent>;\n\n  interrupt(contextId: string): Promise<void>;\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/tests/defaultAdapterFactory.headers.test.mjs",
    "content": "import assert from \"node:assert/strict\";\nimport test from \"node:test\";\n\nimport { DefaultAdapterFactory } from \"../dist/index.js\";\n\ntest(\"DefaultAdapterFactory merges sandbox and endpoint headers for code requests\", async () => {\n  const recorded = [];\n  const fetchImpl = async (input, init = {}) => {\n    const request = input instanceof Request ? input : new Request(input, init);\n    const url = new URL(request.url);\n    const headers = Object.fromEntries(request.headers.entries());\n    recorded.push({\n      url: request.url,\n      method: request.method,\n      headers,\n    });\n\n    if (url.pathname === \"/code/context\") {\n      return new Response(JSON.stringify({ id: \"ctx-1\", language: \"python\" }), {\n        status: 200,\n        headers: { \"content-type\": \"application/json\" },\n      });\n    }\n\n    return new Response(\n      [\n        JSON.stringify({ type: \"stdout\", text: \"hello\", timestamp: 1 }),\n        JSON.stringify({ type: \"execution_complete\", execution_time: 2, timestamp: 2 }),\n      ].join(\"\\n\"),\n      {\n        status: 200,\n        headers: { \"content-type\": \"text/event-stream\" },\n      }\n    );\n  };\n\n  const sandbox = {\n    connectionConfig: {\n      headers: { \"x-global\": \"global\" },\n      fetch: fetchImpl,\n      sseFetch: fetchImpl,\n    },\n  };\n\n  const factory = new DefaultAdapterFactory();\n  const codes = factory.createCodes({\n    sandbox,\n    execdBaseUrl: \"http://sandbox.internal:3456\",\n    endpointHeaders: { \"x-endpoint\": \"endpoint\" },\n  });\n\n  const context = await codes.createContext(\"python\");\n  assert.equal(context.id, \"ctx-1\");\n\n  const execution = await codes.run(\"print('hello')\");\n  assert.equal(execution.logs.stdout[0]?.text, \"hello\");\n\n  assert.equal(recorded.length, 2);\n  assert.equal(recorded[0].url, \"http://sandbox.internal:3456/code/context\");\n  assert.equal(recorded[0].headers[\"x-global\"], \"global\");\n  assert.equal(recorded[0].headers[\"x-endpoint\"], \"endpoint\");\n  assert.equal(recorded[1].url, \"http://sandbox.internal:3456/code\");\n  assert.equal(recorded[1].headers[\"x-global\"], \"global\");\n  assert.equal(recorded[1].headers[\"x-endpoint\"], \"endpoint\");\n  assert.equal(recorded[1].headers.accept, \"text/event-stream\");\n});\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/tests/interpreter.headers.test.mjs",
    "content": "import assert from \"node:assert/strict\";\nimport test from \"node:test\";\n\nimport { CodeInterpreter } from \"../dist/index.js\";\nimport { DEFAULT_EXECD_PORT } from \"../../../sandbox/javascript/dist/index.js\";\n\ntest(\"CodeInterpreter.create forwards endpoint headers to adapter factory\", async () => {\n  const calls = [];\n  const sandbox = {\n    connectionConfig: {\n      protocol: \"https\",\n      headers: { \"x-global\": \"global\" },\n    },\n    async getEndpoint(port) {\n      assert.equal(port, DEFAULT_EXECD_PORT);\n      return {\n        endpoint: \"sandbox.internal:3456\",\n        headers: { \"x-endpoint\": \"endpoint\" },\n      };\n    },\n  };\n  const codes = { kind: \"codes\" };\n  const adapterFactory = {\n    createCodes(opts) {\n      calls.push(opts);\n      return codes;\n    },\n  };\n\n  const interpreter = await CodeInterpreter.create(sandbox, { adapterFactory });\n\n  assert.equal(interpreter.codes, codes);\n  assert.equal(calls.length, 1);\n  assert.equal(calls[0].execdBaseUrl, \"https://sandbox.internal:3456\");\n  assert.deepEqual(calls[0].endpointHeaders, { \"x-endpoint\": \"endpoint\" });\n});\n"
  },
  {
    "path": "sdks/code-interpreter/javascript/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/*.test.ts\"]\n}"
  },
  {
    "path": "sdks/code-interpreter/javascript/tsup.config.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\nimport { defineConfig } from \"tsup\";\n\nconst entries = [\"src/index.ts\"];\n\nexport default defineConfig([\n  {\n    entry: entries,\n    format: [\"esm\"],\n    dts: true,\n    outDir: \"dist\",\n    clean: true,\n    sourcemap: true,\n    target: \"es2022\",\n  },\n  {\n    entry: entries,\n    format: [\"cjs\"],\n    outDir: \"dist/cjs\",\n    clean: false,\n    sourcemap: true,\n    target: \"es2022\",\n    outExtension: () => ({ js: \".cjs\" }),\n  },\n]);\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/README.md",
    "content": "# Alibaba Code Interpreter SDK for Kotlin\n\nEnglish | [中文](README_zh.md)\n\nA powerful Kotlin SDK for executing code in secure, isolated sandboxes. This SDK provides a high-level API for running Python, Java, Go, TypeScript, and other languages safely, with support for code execution contexts.\n\n## Prerequisites\n\nThis SDK requires a specific Docker image containing the Code Interpreter runtime environment. You must use the `opensandbox/code-interpreter` image (or a derivative) which includes pre-installed runtimes for Python, Java, Go, Node.js, etc.\n\n## Installation\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n    implementation(\"com.alibaba.opensandbox:code-interpreter:{latest_version}\")\n}\n```\n\n### Maven\n\n```xml\n<dependency>\n    <groupId>com.alibaba.opensandbox</groupId>\n    <artifactId>code-interpreter</artifactId>\n    <version>{latest_version}</version>\n</dependency>\n```\n\n## Quick Start\n\nThe following example demonstrates how to initialize the client with a specific Python version and execute a simple script.\n\n```java\nimport com.alibaba.opensandbox.codeinterpreter.CodeInterpreter;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;\n\npublic class QuickStart {\n    public static void main(String[] args) {\n        // 1. Configure connection\n        ConnectionConfig config = ConnectionConfig.builder()\n            .domain(\"api.opensandbox.io\")\n            .apiKey(\"your-api-key\")\n            .build();\n\n        // 2. Create a Sandbox with specific runtime configuration\n        // Note: You must use the code-interpreter image\n        // Use try-with-resources to ensure sandbox is closed\n        try (Sandbox sandbox = Sandbox.builder()\n                .connectionConfig(config)\n                .image(\"opensandbox/code-interpreter:v1.0.2\")\n                .entrypoint(\"/opt/opensandbox/code-interpreter.sh\")\n                .env(\"PYTHON_VERSION\", \"3.11\") // Select specific language version\n                .build()) {\n\n            // 3. Create CodeInterpreter wrapper\n            CodeInterpreter interpreter = CodeInterpreter.builder()\n                .fromSandbox(sandbox)\n                .build();\n\n            // 4. Create an execution context (Python)\n            CodeContext context = interpreter.codes().createContext(SupportedLanguage.PYTHON);\n\n            // 5. Run code\n            Execution result = interpreter.codes().run(\n                RunCodeRequest.builder()\n                    .code(\"import sys; print(f'Running on Python {sys.version}')\")\n                    .context(context)\n                    .build()\n            );\n\n            // 6. Print output\n            if (!result.getLogs().getStdout().isEmpty()) {\n                System.out.println(result.getLogs().getStdout().get(0).getText());\n            }\n\n            // 7. Cleanup\n            // Note: kill() terminates the remote instance; close() (auto-called) cleans up local resources\n            sandbox.kill();\n        } catch (SandboxException e) {\n            // Handle Sandbox specific exceptions\n            System.err.println(\"Sandbox Error: [\" + e.getError().getCode() + \"] \" + e.getError().getMessage());\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n    }\n}\n```\n\n## Runtime Configuration\n\n### Docker Image\n\nThe Code Interpreter SDK relies on a specialized environment. Ensure your sandbox provider has the `opensandbox/code-interpreter` image available.\n\nFor detailed information about supported languages and versions, please refer to the [Environment Documentation](../../../sandboxes/code-interpreter/README.md).\n\n### Language Version Selection\n\nYou can specify the desired version of a programming language by setting the corresponding environment variable when building the `Sandbox`.\n\n| Language | Environment Variable | Example Value | Default (if unset) |\n| -------- | -------------------- | ------------- | ------------------ |\n| Python   | `PYTHON_VERSION`     | `3.11`        | Image default      |\n| Java     | `JAVA_VERSION`       | `17`          | Image default      |\n| Node.js  | `NODE_VERSION`       | `20`          | Image default      |\n| Go       | `GO_VERSION`         | `1.24`        | Image default      |\n\n```java\nSandbox sandbox = Sandbox.builder()\n    .image(\"opensandbox/code-interpreter:v1.0.2\")\n    .entrypoint(\"/opt/opensandbox/code-interpreter.sh\")\n    .env(\"JAVA_VERSION\", \"17\")\n    .env(\"GO_VERSION\", \"1.23\")\n    .build();\n```\n\n## Usage Examples\n\n### 0. Run with `language` (default language context)\n\nIf you don't need to manage explicit session IDs, you can run code by specifying only `language`.\nWhen `context.id` is omitted, **execd will create/reuse a default session for that language**, so\nstate can persist across runs:\n\n```java\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;\n\n// Default Python context: state persists across runs\ninterpreter.codes().run(\"x = 42\", SupportedLanguage.PYTHON);\nExecution execution = interpreter.codes().run(\"result = x\\nresult\", SupportedLanguage.PYTHON);\nSystem.out.println(execution.getResult().get(0).getText()); // 42\n```\n\n### 1. Java Code Execution\n\nExecute Java code snippets dynamically.\n\n```java\nCodeContext javaContext = interpreter.codes().createContext(SupportedLanguage.JAVA);\n\nRunCodeRequest request = RunCodeRequest.builder()\n    .code(\n        \"System.out.println(\\\"Calculating sum...\\\");\\n\" +\n        \"int a = 10;\\n\" +\n        \"int b = 20;\\n\" +\n        \"int sum = a + b;\\n\" +\n        \"System.out.println(\\\"Sum: \\\" + sum);\\n\" +\n        \"sum\" // Return value\n    )\n    .context(javaContext)\n    .build();\n\nExecution execution = interpreter.codes().run(request);\n\n// Handle results\nSystem.out.println(\"Execution ID: \" + execution.getId());\nexecution.getLogs().getStdout().forEach(log -> System.out.println(log.getText()));\n```\n\n### 2. Python with State Persistence\n\nVariables defined in one execution are available in subsequent executions within the same context.\n\n```java\nCodeContext pythonContext = interpreter.codes().createContext(SupportedLanguage.PYTHON);\n\n// Step 1: Define variables\nRunCodeRequest step1 = RunCodeRequest.builder()\n    .code(\n        \"users = ['Alice', 'Bob', 'Charlie']\\n\" +\n        \"print(f'Initialized {len(users)} users')\"\n    )\n    .context(pythonContext)\n    .build();\ninterpreter.codes().run(step1);\n\n// Step 2: Use variables from previous step\nRunCodeRequest step2 = RunCodeRequest.builder()\n    .code(\n        \"users.append('Dave')\\n\" +\n        \"print(f'Updated users: {users}')\"\n    )\n    .context(pythonContext)\n    .build();\n\nExecution result = interpreter.codes().run(step2);\n// Output: Updated users: ['Alice', 'Bob', 'Charlie', 'Dave']\n```\n\n### 3. Streaming Output Handling\n\nHandle standard output, error output, and execution events in real-time.\n\n```java\nExecutionHandlers handlers = ExecutionHandlers.builder()\n    .onStdout(msg -> System.out.println(\"STDOUT: \" + msg.getText()))\n    .onStderr(msg -> System.err.println(\"STDERR: \" + msg.getText()))\n    .onResult(res -> System.out.println(\"Result: \" + res.getText()))\n    .onError(err -> System.err.println(\"Error: \" + err.getValue()))\n    .onExecutionComplete(complete ->\n        System.out.println(\"Finished in \" + complete.getExecutionTimeInMillis() + \"ms\")\n    )\n    .build();\n\nRunCodeRequest request = RunCodeRequest.builder()\n    .code(\"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.5)\")\n    .context(pythonContext)\n    .handlers(handlers)\n    .build();\n\ninterpreter.codes().run(request);\n```\n\n### 4. Multi-Language Context Isolation\n\nDifferent languages run in isolated environments.\n\n```java\nCodeContext pyCtx = interpreter.codes().createContext(SupportedLanguage.PYTHON);\nCodeContext goCtx = interpreter.codes().createContext(SupportedLanguage.GO);\n\n// Python Context\ninterpreter.codes().run(\n    RunCodeRequest.builder()\n        .code(\"print('Running in Python')\")\n        .context(pyCtx)\n        .build()\n);\n\n// Go Context\ninterpreter.codes().run(\n    RunCodeRequest.builder()\n        .code(\n            \"package main\\n\" +\n            \"func main() { println(\\\"Running in Go\\\") }\"\n        )\n        .context(goCtx)\n        .build()\n);\n```\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/README_zh.md",
    "content": "# Alibaba Code Interpreter SDK for Kotlin\n\n中文 | [English](README.md)\n\n一个用于在安全、隔离的沙箱环境中执行代码的 Kotlin SDK。该 SDK 提供了高级 API，支持安全地运行 Python、Java、Go、TypeScript 等语言，并具备代码执行上下文（Context）能力。\n\n## 前置要求\n\n本 SDK 需要配合包含 Code Interpreter 运行时环境的特定 Docker 镜像使用。请务必使用 `opensandbox/code-interpreter` 镜像（或其衍生镜像），其中预装了 Python、Java、Go、Node.js 等语言的运行环境。\n\n## 安装指南\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n    implementation(\"com.alibaba.opensandbox:code-interpreter:{latest_version}\")\n}\n```\n\n### Maven\n\n```xml\n<dependency>\n    <groupId>com.alibaba.opensandbox</groupId>\n    <artifactId>code-interpreter</artifactId>\n    <version>{latest_version}</version>\n</dependency>\n```\n\n## 快速开始\n\n以下示例展示了如何初始化客户端，指定 Python 版本并执行一段简单的脚本。\n\n```java\nimport com.alibaba.opensandbox.codeinterpreter.CodeInterpreter;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;\n\npublic class QuickStart {\n    public static void main(String[] args) {\n        // 1. 配置连接信息\n        ConnectionConfig config = ConnectionConfig.builder()\n            .domain(\"api.opensandbox.io\")\n            .apiKey(\"your-api-key\")\n            .build();\n\n        // 2. 创建 Sandbox 实例\n        // 注意: 必须使用 code-interpreter 专用镜像\n        // 使用 try-with-resources 确保资源正确关闭\n        try (Sandbox sandbox = Sandbox.builder()\n                .connectionConfig(config)\n                .image(\"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\")\n                .entrypoint(\"/opt/opensandbox/code-interpreter.sh\")\n                .env(\"PYTHON_VERSION\", \"3.11\") // 指定语言版本\n                .build()) {\n\n            // 3. 创建 CodeInterpreter 包装器\n            CodeInterpreter interpreter = CodeInterpreter.builder()\n                .fromSandbox(sandbox)\n                .build();\n\n            // 4. 创建执行上下文 (Python)\n            CodeContext context = interpreter.codes().createContext(SupportedLanguage.PYTHON);\n\n            // 5. 运行代码\n            Execution result = interpreter.codes().run(\n                RunCodeRequest.builder()\n                    .code(\"import sys; print(f'Running on Python {sys.version}')\")\n                    .context(context)\n                    .build()\n            );\n\n            // 6. 打印输出\n            if (!result.getLogs().getStdout().isEmpty()) {\n                System.out.println(result.getLogs().getStdout().get(0).getText());\n            }\n\n            // 7. 清理资源\n            // 注意: kill() 会立即终止远程沙箱实例；try-with-resources 会自动调用 close() 清理本地资源\n            sandbox.kill();\n        } catch (SandboxException e) {\n            // 处理 Sandbox 特定异常\n            System.err.println(\"沙箱错误: [\" + e.getError().getCode() + \"] \" + e.getError().getMessage());\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n    }\n}\n```\n\n## 运行时配置\n\n### Docker 镜像\n\nCode Interpreter SDK 依赖于特定的运行环境。请确保你的沙箱服务提供商支持 `opensandbox/code-interpreter` 镜像。\n\n关于支持的语言和具体版本的详细信息，请参考 [环境文档](../../../sandboxes/code-interpreter/README_zh.md)。\n\n### 语言版本选择\n\n你可以在创建 `Sandbox` 时通过环境变量指定所需的编程语言版本。\n\n| 语言    | 环境变量         | 示例值 | 默认值 (若不设置) |\n| ------- | ---------------- | ------ | ----------------- |\n| Python  | `PYTHON_VERSION` | `3.11` | 镜像默认值        |\n| Java    | `JAVA_VERSION`   | `17`   | 镜像默认值        |\n| Node.js | `NODE_VERSION`   | `20`   | 镜像默认值        |\n| Go      | `GO_VERSION`     | `1.24` | 镜像默认值        |\n\n```java\nSandbox sandbox = Sandbox.builder()\n    .image(\"opensandbox/code-interpreter:v1.0.2\")\n    .entrypoint(\"/opt/opensandbox/code-interpreter.sh\")\n    .env(\"JAVA_VERSION\", \"17\")\n    .env(\"GO_VERSION\", \"1.23\")\n    .build();\n```\n\n## 核心功能示例\n\n### 0. 直接传 `language`（使用该语言默认上下文）\n\n如果你不需要显式管理 session id，可以只传 `language` 来执行代码。\n当 `context.id` 省略时，**execd 会为该语言创建/复用默认 session**，因此状态可以跨次执行保持：\n\n```java\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;\n\n// Python 默认上下文：状态会在多次 run 之间保持\ninterpreter.codes().run(\"x = 42\", SupportedLanguage.PYTHON);\nExecution execution = interpreter.codes().run(\"result = x\\nresult\", SupportedLanguage.PYTHON);\nSystem.out.println(execution.getResult().get(0).getText()); // 42\n```\n\n### 1. Java 代码执行\n\n动态执行 Java 代码片段。\n\n```java\nCodeContext javaContext = interpreter.codes().createContext(SupportedLanguage.JAVA);\n\nRunCodeRequest request = RunCodeRequest.builder()\n    .code(\n        \"System.out.println(\\\"Calculating sum...\\\");\\n\" +\n        \"int a = 10;\\n\" +\n        \"int b = 20;\\n\" +\n        \"int sum = a + b;\\n\" +\n        \"System.out.println(\\\"Sum: \\\" + sum);\\n\" +\n        \"sum\" // 返回值\n    )\n    .context(javaContext)\n    .build();\n\nExecution execution = interpreter.codes().run(request);\n\n// 处理结果\nSystem.out.println(\"Execution ID: \" + execution.getId());\nexecution.getLogs().getStdout().forEach(log -> System.out.println(log.getText()));\n```\n\n### 2. Python 持久化状态\n\n在同一个上下文中，变量状态可以跨次执行保持。\n\n```java\nCodeContext pythonContext = interpreter.codes().createContext(SupportedLanguage.PYTHON);\n\n// 步骤 1: 定义变量\nRunCodeRequest step1 = RunCodeRequest.builder()\n    .code(\n        \"users = ['Alice', 'Bob', 'Charlie']\\n\" +\n        \"print(f'Initialized {len(users)} users')\"\n    )\n    .context(pythonContext)\n    .build();\ninterpreter.codes().run(step1);\n\n// 步骤 2: 使用上一步的变量\nRunCodeRequest step2 = RunCodeRequest.builder()\n    .code(\n        \"users.append('Dave')\\n\" +\n        \"print(f'Updated users: {users}')\"\n    )\n    .context(pythonContext)\n    .build();\n\nExecution result = interpreter.codes().run(step2);\n// 输出: Updated users: ['Alice', 'Bob', 'Charlie', 'Dave']\n```\n\n### 3. 流式输出处理\n\n实时处理标准输出、错误输出和执行事件。\n\n```java\nExecutionHandlers handlers = ExecutionHandlers.builder()\n    .onStdout(msg -> System.out.println(\"STDOUT: \" + msg.getText()))\n    .onStderr(msg -> System.err.println(\"STDERR: \" + msg.getText()))\n    .onResult(res -> System.out.println(\"Result: \" + res.getText()))\n    .onError(err -> System.err.println(\"Error: \" + err.getValue()))\n    .onExecutionComplete(complete ->\n        System.out.println(\"Finished in \" + complete.getExecutionTimeInMillis() + \"ms\")\n    )\n    .build();\n\nRunCodeRequest request = RunCodeRequest.builder()\n    .code(\"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.5)\")\n    .context(pythonContext)\n    .handlers(handlers)\n    .build();\n\ninterpreter.codes().run(request);\n```\n\n### 4. 多语言上下文隔离\n\n不同语言在隔离的环境中运行。\n\n```java\nCodeContext pyCtx = interpreter.codes().createContext(SupportedLanguage.PYTHON);\nCodeContext goCtx = interpreter.codes().createContext(SupportedLanguage.GO);\n\n// Python 上下文\ninterpreter.codes().run(\n    RunCodeRequest.builder()\n        .code(\"print('Running in Python')\")\n        .context(pyCtx)\n        .build()\n);\n\n// Go 上下文\ninterpreter.codes().run(\n    RunCodeRequest.builder()\n        .code(\n            \"package main\\n\" +\n            \"func main() { println(\\\"Running in Go\\\") }\"\n        )\n        .context(goCtx)\n        .build()\n);\n```\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"UnstableApiUsage\")\n\nimport org.gradle.api.GradleException\nimport org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension\n\nfun Project.resolveVersionFromTag(expectedTagPrefix: String): String? {\n    val refName = System.getenv(\"GITHUB_REF_NAME\") ?: System.getenv(\"GITHUB_REF\")?.removePrefix(\"refs/tags/\")\n    val fromEnv =\n        refName\n            ?.trim()\n            ?.takeIf { it.startsWith(expectedTagPrefix) }\n            ?.removePrefix(expectedTagPrefix)\n            ?.trim()\n            ?.takeIf { it.isNotEmpty() }\n    return fromEnv\n}\n\nbuildscript {\n    repositories {\n        mavenCentral()\n        gradlePluginPortal()\n    }\n\n    dependencies {\n        classpath(libs.bundles.jackson.build)\n    }\n}\n\nplugins {\n    alias(libs.plugins.kotlin.jvm) apply false\n    alias(libs.plugins.kotlin.serialization) apply false\n    alias(libs.plugins.dokka) apply false\n    alias(libs.plugins.spotless)\n    alias(libs.plugins.mavenPublish) apply false\n}\n\nval manualProjectVersion = project.findProperty(\"project.version\") as String\nval tagVersion =\n    project.resolveVersionFromTag(\n        expectedTagPrefix = \"java/code-interpreter/v\",\n    )\n\nif (tagVersion != null && tagVersion != manualProjectVersion) {\n    throw GradleException(\n        \"Ref/tag version mismatch: expected version '$manualProjectVersion' from gradle.properties, \" +\n            \"but got '$tagVersion' from tag 'java/code-interpreter/v...'. Please align the tag and project.version.\",\n    )\n}\n\nextra[\"project.version\"] = manualProjectVersion\n\nallprojects {\n    group = project.findProperty(\"project.group\") as String\n    version = manualProjectVersion\n\n    repositories {\n        mavenCentral()\n    }\n}\n\nconfigure<com.diffplug.gradle.spotless.SpotlessExtension> {\n    kotlin {\n        target(\"**/*.kt\")\n        targetExclude(\"**/build/**/*.kt\", \"**/bin/**/*.kt\", \"**/generated/**/*.kt\")\n        ktlint()\n    }\n    kotlinGradle {\n        target(\"**/*.gradle.kts\")\n        ktlint()\n    }\n}\n\nval kotlinJvmId = libs.plugins.kotlin.jvm.get().pluginId\nval kotlinSerializationId = libs.plugins.kotlin.serialization.get().pluginId\nval dokkaId = libs.plugins.dokka.get().pluginId\nval mavenPublishId = libs.plugins.mavenPublish.get().pluginId\n\nsubprojects {\n    apply(plugin = mavenPublishId)\n    if (name != \"code-interpreter-bom\") {\n        apply(plugin = kotlinJvmId)\n        apply(plugin = kotlinSerializationId)\n        apply(plugin = dokkaId)\n\n        configure<KotlinJvmProjectExtension> {\n            jvmToolchain(8)\n            compilerOptions {\n                javaParameters.set(true)\n                freeCompilerArgs.add(\"-Xjvm-default=all\")\n            }\n        }\n    }\n\n    // Include license file in published artifacts (jars/sources jars) for compliance and clarity.\n    tasks.withType<Jar>().configureEach {\n        from(rootProject.file(\"LICENSE\")) {\n            into(\"META-INF\")\n        }\n    }\n\n    configure<com.vanniktech.maven.publish.MavenPublishBaseExtension> {\n        coordinates(project.group.toString(), project.name, project.version.toString())\n        publishToMavenCentral()\n        if (!gradle.startParameter.taskNames.any { it.contains(\"publishToMavenLocal\") }) {\n            signAllPublications()\n        }\n        pom {\n            name.set(project.name)\n            description.set(\"Alibaba Code Interpreter SDK\")\n            inceptionYear.set(\"2025\")\n            url.set(\"https://github.com/alibaba/OpenSandbox\")\n            licenses {\n                license {\n                    name.set(\"The Apache License, Version 2.0\")\n                    url.set(\"https://www.apache.org/licenses/LICENSE-2.0.txt\")\n                    distribution.set(\"repo\")\n                }\n            }\n            developers {\n                developer {\n                    id.set(\"alibaba\")\n                    name.set(\"Alibaba Group\")\n                    url.set(\"https://github.com/alibaba\")\n                    email.set(\"ninan.nn@alibaba-inc.com\")\n                }\n            }\n            scm {\n                url.set(\"https://github.com/alibaba/OpenSandbox\")\n                connection.set(\"scm:git:https://github.com/alibaba/OpenSandbox.git\")\n                developerConnection.set(\"scm:git:ssh://git@github.com/alibaba/OpenSandbox.git\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nrepositories {\n    if (project.hasProperty(\"useMavenLocal\")) {\n        mavenLocal()\n    }\n    mavenCentral()\n\n    val sandboxVersion = libs.versions.sandbox.get()\n    if (sandboxVersion.contains(\"SNAPSHOT\", ignoreCase = true)) {\n        maven {\n            url = uri(\"https://central.sonatype.com/repository/maven-snapshots/\")\n            mavenContent {\n                snapshotsOnly()\n            }\n        }\n    }\n}\n\ndependencies {\n    api(libs.sandbox)\n    implementation(libs.sandbox.api)\n\n    api(libs.kotlin.stdlib)\n    api(libs.slf4j.api)\n\n    implementation(libs.okhttp)\n    implementation(libs.okhttp.logging)\n    implementation(libs.bundles.serialization)\n\n    testImplementation(libs.bundles.testing)\n    testRuntimeOnly(libs.junit.platform.launcher)\n}\n\n// Configure test tasks to use JDK 17\ntasks.withType<Test> {\n    javaLauncher.set(\n        javaToolchains.launcherFor {\n            languageVersion.set(JavaLanguageVersion.of(17))\n        },\n    )\n    useJUnitPlatform()\n}\n\n// Configure test compilation to use JDK 17\ntasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {\n    if (name.contains(\"test\", ignoreCase = true)) {\n        compilerOptions {\n            jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)\n        }\n    }\n}\n\ntasks.withType<JavaCompile> {\n    if (name.contains(\"test\", ignoreCase = true)) {\n        javaCompiler.set(\n            javaToolchains.compilerFor {\n                languageVersion.set(JavaLanguageVersion.of(17))\n            },\n        )\n    }\n}\n\ntasks.withType<org.jetbrains.dokka.gradle.DokkaTask>().configureEach {\n    dokkaSourceSets {\n        named(\"main\") {\n            moduleName.set(\"CodeInterpreter\")\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.services.Codes\nimport com.alibaba.opensandbox.codeinterpreter.infrastructure.factory.AdapterFactory\nimport com.alibaba.opensandbox.sandbox.Sandbox\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxInternalException\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EXECD_PORT\nimport org.slf4j.LoggerFactory\n\n/**\n * Code Interpreter SDK providing secure, isolated code execution capabilities.\n *\n * This class extends the basic Sandbox functionality with specialized code execution features,\n * including multi-language support, session management, and variable persistence.\n *\n * ## Key Features\n *\n * - **Multi-language Code Execution**: Support for Python, JavaScript, Bash, Java, Kotlin\n * - **Session Management**: Persistent execution contexts with variable state\n * - **Sandbox Integration**: Full access to underlying sandbox file system and command execution\n * - **Streaming Execution**: Real-time code execution with output streaming\n * - **Variable Inspection**: Access to execution variables and state\n *\n * ## Usage Example\n *\n * ```kotlin\n * // First create a sandbox instance\n * val sandbox = Sandbox.builder()\n *     .image(\"python:3.11\")\n *     .resource { put(\"memory\", \"2Gi\") }\n *     .build()\n *\n * // Then wrap it with code interpreter capabilities\n * val interpreter = CodeInterpreter.builder()\n *     .fromSandbox(sandbox)\n *     .build()\n *\n * // Execute code with context\n * val context = interpreter.codes().createContext(SupportedLanguage.PYTHON)\n * val result = interpreter.codes().run(\n *     RunCodeRequest.builder()\n *         .code(\"print('Hello World')\")\n *         .context(context)\n *         .build()\n * )\n * println(result.stdout) // Output: Hello World\n *\n * // Access underlying sandbox for file operations\n * interpreter.sandbox().files().writeFile(\"data.txt\", \"Hello\")\n * val fileResult = interpreter.codes().run(\n *     RunCodeRequest.builder()\n *         .code(\"with open('data.txt') as f: print(f.read())\")\n *         .context(context)\n *         .build()\n * )\n *\n * // Always clean up resources\n * interpreter.kill()\n * interpreter.sandbox().close()\n * ```\n */\nclass CodeInterpreter internal constructor(\n    private val sandbox: Sandbox,\n    private val codeService: Codes,\n) {\n    private val logger = LoggerFactory.getLogger(CodeInterpreter::class.java)\n\n    /**\n     * Provides access to the underlying sandbox instance.\n     */\n    fun sandbox(): Sandbox = sandbox\n\n    /**\n     * Gets the unique identifier of this code interpreter (same as underlying sandbox ID).\n     */\n    val id: String get() = sandbox.id\n\n    /**\n     * Provides access to file system operations within the sandbox.\n     *\n     * Allows writing, reading, listing, and deleting files and directories.\n     *\n     * @return Service for filesystem manipulation\n     */\n    fun files() = sandbox.files()\n\n    /**\n     * Provides access to command execution operations.\n     *\n     * Allows running shell commands, capturing output, and managing processes.\n     *\n     * @return Service for command execution\n     */\n    fun commands() = sandbox.commands()\n\n    /**\n     * Provides access to sandbox metrics and monitoring.\n     *\n     * Allows retrieving resource usage statistics (CPU, memory) and other performance metrics.\n     *\n     * @return Service for metrics retrieval\n     */\n    fun metrics() = sandbox.metrics()\n\n    /**\n     * Provides access to code execution operations.\n     *\n     * This service enables:\n     * - Multi-language code execution (Python, JavaScript, Bash, etc.)\n     * - Execution context management with persistent variables\n     * - Real-time output streaming and interruption capabilities\n     *\n     * @return Service for advanced code execution with session support\n     */\n    fun codes() = codeService\n\n    companion object {\n        private val logger = LoggerFactory.getLogger(CodeInterpreter::class.java)\n\n        /**\n         * Creates a new [Builder] for creating CodeInterpreter instances.\n         *\n         * CodeInterpreter instances must be created from existing Sandbox instances\n         * using the fromSandbox() method on the builder.\n         *\n         * @return A new Builder instance\n         */\n        @JvmStatic\n        fun builder(): Builder = Builder()\n\n        /**\n         * Creates a CodeInterpreter from an existing Sandbox instance.\n         *\n         * This internal method handles the creation and initialization of CodeInterpreter\n         * services, including the code execution service and language configuration.\n         *\n         * @param sandbox Existing sandbox instance to wrap with code execution capabilities\n         * @return CodeInterpreter instance wrapping the sandbox\n         * @throws SandboxException if creation fails\n         * @throws SandboxInternalException if internal service initialization fails\n         */\n        internal fun create(sandbox: Sandbox): CodeInterpreter {\n            logger.info(\"Creating code interpreter from existing sandbox: {}\", sandbox.id)\n\n            val factory = AdapterFactory(sandbox.httpClientProvider())\n\n            try {\n                // Connect to the execd daemon endpoint for code execution services\n                val codeInterpreterEndpoint = sandbox.getEndpoint(DEFAULT_EXECD_PORT)\n                val codeExecutionService = factory.createCodes(codeInterpreterEndpoint)\n\n                logger.info(\"Code interpreter {} created from sandbox successfully\", sandbox.id)\n\n                return CodeInterpreter(sandbox, codeExecutionService)\n            } catch (e: Exception) {\n                throw when (e) {\n                    is SandboxException -> e\n                    else -> SandboxInternalException(\"Failed to create code interpreter from sandbox: ${e.message}\", e)\n                }\n            }\n        }\n    }\n\n    /**\n     * Builder for creating CodeInterpreter instances from existing Sandbox instances.\n     *\n     * CodeInterpreter must be created by wrapping an existing Sandbox instance with\n     * code execution capabilities. This design ensures clear separation of concerns:\n     * - Sandbox handles infrastructure (containers, resources, networking)\n     * - CodeInterpreter adds code execution capabilities on top\n     *\n     * ## Usage Example\n     *\n     * ```kotlin\n     * // First create a sandbox with desired configuration\n     * val sandbox = Sandbox.builder()\n     *     .image(\"python:3.11\")\n     *     .resource { put(\"memory\", \"4Gi\") }\n     *     .env { put(\"PYTHONPATH\", \"/custom/path\") }\n     *     .build()\n     *\n     * // Then wrap it with code interpreter capabilities\n     * val interpreter = CodeInterpreter.builder()\n     *     .fromSandbox(sandbox)\n     *     .connectionConfig(customConfig)  // Optional\n     *     .build()\n     *\n     * // Use the interpreter\n     * val result = interpreter.codes().run(RunCodeRequest.builder().code(\"print('Hello World!')\").build())\n     * ```\n     */\n\n    class Builder internal constructor() {\n        private var sandbox: Sandbox? = null\n\n        /**\n         * Specifies the Sandbox instance to wrap with code interpreter capabilities.\n         *\n         * This is the only way to create a CodeInterpreter - by extending an existing\n         * Sandbox instance with code execution functionality.\n         *\n         * @param sandbox Existing sandbox instance to wrap\n         * @return This builder for method chaining\n         * @throws InvalidArgumentException if sandbox is null\n         */\n        fun fromSandbox(sandbox: Sandbox): Builder {\n            this.sandbox = sandbox\n            return this\n        }\n\n        /**\n         * Creates the CodeInterpreter instance from the configured sandbox.\n         *\n         * @return CodeInterpreter instance wrapping the specified sandbox\n         * @throws InvalidArgumentException if no sandbox was specified via fromSandbox()\n         */\n        fun build(): CodeInterpreter {\n            val sandboxInstance =\n                sandbox ?: throw InvalidArgumentException(\n                    \"Sandbox instance must be specified via fromSandbox(). \" +\n                        \"Create a Sandbox first, then wrap it with CodeInterpreter.\",\n                )\n            return create(sandboxInstance)\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/models/execd/executions/CodeModels.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions\n\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers\n\n/**\n * Supported programming languages for code execution.\n *\n * This object defines the languages that are officially supported by the code interpreter.\n * When adding new languages, ensure corresponding execution environments are available.\n */\nobject SupportedLanguage {\n    const val PYTHON = \"python\"\n    const val JAVA = \"java\"\n    const val GO = \"go\"\n    const val TYPESCRIPT = \"typescript\"\n    const val BASH = \"bash\"\n    const val JAVASCRIPT = \"javascript\"\n}\n\n/**\n * Represents an execution context for code interpretation.\n *\n * A CodeContext maintains the execution environment for a specific programming\n * language, including the working directory, language configuration, and\n * persistent state across multiple code executions.\n *\n * ## Context Lifecycle\n *\n * 1. **Creation**: Context is created with language and working directory\n * 2. **Execution**: Code runs within this context, building up state\n * 3. **Persistence**: Variables, imports, and functions persist between executions\n * 4. **Cleanup**: Context can be explicitly destroyed or garbage collected\n *\n * @property id Unique identifier for this execution context\n * @property language Programming language for this context (e.g., \"python\", \"javascript\")\n * @property cwd Current working directory for code execution\n */\nclass CodeContext private constructor(\n    val id: String?,\n    val language: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var language: String = SupportedLanguage.PYTHON\n\n        private var id: String? = null\n\n        fun id(id: String?): Builder {\n            this.id = id\n            return this\n        }\n\n        fun language(language: String): Builder {\n            this.language = language\n            return this\n        }\n\n        fun build(): CodeContext {\n            return CodeContext(\n                id = id,\n                language = language,\n            )\n        }\n    }\n}\n\n/**\n * Request model for executing code within a specific context.\n *\n * This model encapsulates all the information needed to execute a piece of\n * code, including the code itself and the execution context. The context\n * determines the language interpreter, working directory, and persistent state.\n *\n * ## Usage Patterns\n *\n * ### Simple Execution\n * ```kotlin\n * val request = RunCodeRequest.builder()\n *     .code(\"print('Hello World')\")\n *     .build()\n * ```\n *\n * ### Context-Aware Execution\n * ```kotlin\n * val context = CodeContext.builder()\n *     .id(\"session-123\")\n *     .language(\"python\")\n *     .cwd(\"/workspace\")\n *     .build()\n * val request = RunCodeRequest.builder()\n *     .code(\"import pandas as pd; df = pd.read_csv('data.csv')\")\n *     .context(context)\n *     .build()\n * ```\n *\n * @property code The source code to execute\n * @property context Optional execution context. If null, a temporary context will be created\n */\nclass RunCodeRequest private constructor(\n    val code: String,\n    val context: CodeContext,\n    val handlers: ExecutionHandlers?,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var code: String? = null\n        private var context: CodeContext = CodeContext.builder().build()\n        private var handlers: ExecutionHandlers? = null\n\n        fun code(code: String): Builder {\n            require(code.isNotBlank()) { \"Code cannot be blank\" }\n            this.code = code\n            return this\n        }\n\n        fun context(context: CodeContext): Builder {\n            this.context = context\n            return this\n        }\n\n        fun handlers(handlers: ExecutionHandlers?): Builder {\n            this.handlers = handlers\n            return this\n        }\n\n        fun build(): RunCodeRequest {\n            val codeValue = code ?: throw IllegalArgumentException(\"Code must be specified\")\n            return RunCodeRequest(\n                code = codeValue,\n                context = context,\n                handlers = handlers,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/domain/services/Codes.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.domain.services\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers\n\n/**\n * Code execution operations for multi-language code interpretation.\n *\n * This service provides advanced code execution capabilities with context management,\n * session persistence, and multi-language support.\n */\ninterface Codes {\n    /**\n     * Gets an existing execution context by id.\n     *\n     * A [CodeContext] represents a persistent execution session (kernel/runtime) that can be reused\n     * across multiple executions to preserve state (variables, imports, working directory, etc.).\n     *\n     * @param id Execution context id\n     * @return The existing [CodeContext]\n     */\n    fun getContext(id: String): CodeContext\n\n    /**\n     * Lists active execution contexts for a given language/runtime.\n     *\n     * This is useful for debugging, monitoring, or cleaning up leaked contexts.\n     *\n     * @param language Execution runtime (e.g., \"python\", \"bash\", \"java\")\n     * @return List of [CodeContext] currently available for the given language\n     */\n    fun listContexts(language: String): List<CodeContext>\n\n    /**\n     * Creates a new execution context for code interpretation.\n     *\n     * @param language The programming language for this context (e.g., \"python\", \"javascript\")\n     * @return A new [CodeContext] with the specified configuration\n     */\n    fun createContext(language: String): CodeContext\n\n    /**\n     * Deletes an execution context (session) by id.\n     *\n     * This should terminate the underlying context thread/process and release resources.\n     *\n     * @param id Execution context id to delete\n     */\n    fun deleteContext(id: String)\n\n    /**\n     * Deletes all execution contexts under a specific language/runtime.\n     *\n     * This is a bulk cleanup operation intended for context management.\n     *\n     * @param language Target execution runtime whose contexts should be deleted\n     */\n    fun deleteContexts(language: String)\n\n    /**\n     * Executes code within the specified context.\n     *\n     * @param request The code execution request containing code and context\n     * @return Execution with stdout, stderr, exit code, and execution metadata\n     */\n    fun run(request: RunCodeRequest): Execution\n\n    /**\n     * Executes code within the specified context.\n     *\n     * @param code The code to run\n     * @param context The context to run code\n     * @param handlers execution events handlers\n     * @return Execution with stdout, stderr, exit code, and execution metadata\n     */\n    fun run(\n        code: String,\n        context: CodeContext,\n        handlers: ExecutionHandlers,\n    ): Execution {\n        return run(RunCodeRequest.builder().code(code).context(context).handlers(handlers).build())\n    }\n\n    /**\n     * Executes code within the specified context.\n     *\n     * @param code The code to run\n     * @param context The context to run code\n     * @return Execution with stdout, stderr, exit code, and execution metadata\n     */\n    fun run(\n        code: String,\n        context: CodeContext,\n    ): Execution {\n        return run(RunCodeRequest.builder().code(code).context(context).build())\n    }\n\n    /**\n     * Run code with specified language within the default context\n     *\n     * @param code The code to run\n     * @param language The language of code\n     * @param handlers execution events handlers\n     * @return Execution with stdout, stderr, exit code, and execution metadata\n     */\n    fun run(\n        code: String,\n        language: String,\n        handlers: ExecutionHandlers,\n    ): Execution {\n        return run(\n            RunCodeRequest\n                .builder()\n                .code(code)\n                .context(CodeContext.builder().language(language).build()).handlers(handlers).build(),\n        )\n    }\n\n    /**\n     * Run code with specified language within the default context\n     *\n     * @param code The code to run\n     * @param language The language of code\n     * @return Execution with stdout, stderr, exit code, and execution metadata\n     */\n    fun run(\n        code: String,\n        language: String,\n    ): Execution {\n        return run(\n            RunCodeRequest\n                .builder()\n                .code(code)\n                .context(CodeContext.builder().language(language).build()).build(),\n        )\n    }\n\n    /**\n     * Interrupts a currently running code execution.\n     *\n     * @param executionId The unique identifier of the execution to interrupt\n     */\n    fun interrupt(executionId: String)\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/converter/CodeExecutionConverter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.converter\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest\nimport com.alibaba.opensandbox.sandbox.api.models.execd.CodeContext as ApiCodeContext\nimport com.alibaba.opensandbox.sandbox.api.models.execd.RunCodeRequest as ApiRunCodeRequest\n\nobject CodeExecutionConverter {\n    fun RunCodeRequest.toApiRunCodeRequest(): ApiRunCodeRequest {\n        return ApiRunCodeRequest(\n            code = this.code,\n            context = this.context?.toApiCodeContext(),\n        )\n    }\n\n    fun CodeContext.toApiCodeContext(): ApiCodeContext {\n        return ApiCodeContext(\n            id = this.id,\n            language = this.language,\n        )\n    }\n\n    fun ApiCodeContext.toCodeContext(): CodeContext {\n        return CodeContext.builder()\n            .id(this.id)\n            .language(this.language)\n            .build()\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest\nimport com.alibaba.opensandbox.codeinterpreter.domain.services.Codes\nimport com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.converter.CodeExecutionConverter.toApiRunCodeRequest\nimport com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.converter.CodeExecutionConverter.toCodeContext\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.execd.CodeInterpretingApi\nimport com.alibaba.opensandbox.sandbox.api.models.execd.EventNode\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.ExecutionEventDispatcher\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.jsonParser\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport okhttp3.Headers.Companion.toHeaders\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.slf4j.LoggerFactory\nimport com.alibaba.opensandbox.sandbox.api.models.execd.CodeContextRequest as ApiCodeContextRequest\n\nclass CodesAdapter(\n    private val execdEndpoint: SandboxEndpoint,\n    private val httpClientProvider: HttpClientProvider,\n) : Codes {\n    companion object {\n        private const val RUN_CODE_PATH = \"/code\"\n    }\n\n    private val logger = LoggerFactory.getLogger(CodesAdapter::class.java)\n    private val baseUrl = \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}\"\n    private val apiClient =\n        httpClientProvider.httpClient.newBuilder()\n            .addInterceptor { chain ->\n                val requestBuilder = chain.request().newBuilder()\n                execdEndpoint.headers.forEach { (key, value) ->\n                    requestBuilder.header(key, value)\n                }\n                chain.proceed(requestBuilder.build())\n            }\n            .build()\n    private val api =\n        CodeInterpretingApi(baseUrl, apiClient)\n\n    override fun getContext(id: String): CodeContext {\n        try {\n            val result = api.getContext(id)\n            return result.toCodeContext()\n        } catch (e: Exception) {\n            logger.error(\"Failed to get context\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun listContexts(language: String): List<CodeContext> {\n        try {\n            val list = api.listContexts(language)\n            return list.map { it.toCodeContext() }\n        } catch (e: Exception) {\n            logger.error(\"Failed to list contexts\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun createContext(language: String): CodeContext {\n        try {\n            val request = ApiCodeContextRequest(language = language)\n            val result = api.createCodeContext(request)\n            return result.toCodeContext()\n        } catch (e: Exception) {\n            logger.error(\"Failed to create context\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun deleteContext(id: String) {\n        try {\n            api.deleteContext(id)\n        } catch (e: Exception) {\n            logger.error(\"Failed to delete context\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun deleteContexts(language: String) {\n        try {\n            deleteContexts(language)\n        } catch (e: Exception) {\n            logger.error(\"Failed to delete contexts\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun run(request: RunCodeRequest): Execution {\n        if (request.code.isEmpty()) {\n            throw InvalidArgumentException(\"Code cannot be empty\")\n        }\n        try {\n            val apiRequest = request.toApiRunCodeRequest()\n            val httpRequest =\n                Request.Builder()\n                    .url(\"$baseUrl$RUN_CODE_PATH\")\n                    .post(\n                        jsonParser.encodeToString(apiRequest).toRequestBody(\"application/json\".toMediaType()),\n                    )\n                    .headers(execdEndpoint.headers.toHeaders())\n                    .build()\n\n            val execution = Execution()\n\n            httpClientProvider.sseClient.newCall(httpRequest).execute().use { response ->\n                if (!response.isSuccessful) {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to run code. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                }\n\n                response.body?.byteStream()?.bufferedReader(Charsets.UTF_8)?.use { reader ->\n                    val dispatcher = ExecutionEventDispatcher(execution, request.handlers)\n                    reader.lineSequence()\n                        .filter(String::isNotBlank)\n                        .forEach { line ->\n                            try {\n                                val eventNode = jsonParser.decodeFromString<EventNode>(line)\n                                dispatcher.dispatch(eventNode)\n                            } catch (e: Exception) {\n                                logger.error(\"Failed to parse SSE line: {}\", line, e)\n                            }\n                        }\n                }\n            }\n\n            return execution\n        } catch (e: Exception) {\n            logger.error(\"Failed to run code (length: {})\", request.code.length, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun interrupt(executionId: String) {\n        try {\n            api.interruptCode(executionId)\n        } catch (e: Exception) {\n            logger.error(\"Failed to interrupt code execution\", e)\n            throw e.toSandboxException()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/main/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/factory/AdapterFactory.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.infrastructure.factory\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.services.Codes\nimport com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.service.CodesAdapter\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\n\nclass AdapterFactory(\n    private val httpClientProvider: HttpClientProvider,\n) {\n    fun createCodes(endpoint: SandboxEndpoint): Codes {\n        return CodesAdapter(endpoint, httpClientProvider)\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/CodeInterpreterTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.services.Codes\nimport com.alibaba.opensandbox.sandbox.Sandbox\nimport com.alibaba.opensandbox.sandbox.domain.services.Commands\nimport com.alibaba.opensandbox.sandbox.domain.services.Filesystem\nimport com.alibaba.opensandbox.sandbox.domain.services.Metrics\nimport io.mockk.every\nimport io.mockk.impl.annotations.MockK\nimport io.mockk.junit5.MockKExtension\nimport io.mockk.mockk\nimport io.mockk.verify\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertSame\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.extension.ExtendWith\n\n@ExtendWith(MockKExtension::class)\nclass CodeInterpreterTest {\n    @MockK\n    lateinit var sandbox: Sandbox\n\n    @MockK\n    lateinit var codeService: Codes\n\n    private lateinit var codeInterpreter: CodeInterpreter\n    private val sandboxId = \"sandbox-id\"\n\n    @BeforeEach\n    fun setUp() {\n        every { sandbox.id } returns sandboxId\n        codeInterpreter = CodeInterpreter(sandbox, codeService)\n    }\n\n    @Test\n    fun `id should return sandbox id`() {\n        assertEquals(sandboxId, codeInterpreter.id)\n    }\n\n    @Test\n    fun `sandbox should return underlying sandbox`() {\n        assertSame(sandbox, codeInterpreter.sandbox())\n    }\n\n    @Test\n    fun `files should delegate to sandbox files`() {\n        val filesService = mockk<Filesystem>()\n        every { sandbox.files() } returns filesService\n\n        assertSame(filesService, codeInterpreter.files())\n        verify { sandbox.files() }\n    }\n\n    @Test\n    fun `commands should delegate to sandbox commands`() {\n        val commandService = mockk<Commands>()\n        every { sandbox.commands() } returns commandService\n\n        assertSame(commandService, codeInterpreter.commands())\n        verify { sandbox.commands() }\n    }\n\n    @Test\n    fun `metrics should delegate to sandbox metrics`() {\n        val metricsService = mockk<Metrics>()\n        every { sandbox.metrics() } returns metricsService\n\n        assertSame(metricsService, codeInterpreter.metrics())\n        verify { sandbox.metrics() }\n    }\n\n    @Test\n    fun `codes should return code service`() {\n        assertSame(codeService, codeInterpreter.codes())\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter/src/test/kotlin/com/alibaba/opensandbox/codeinterpreter/infrastructure/adapters/service/CodesAdapterTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.codeinterpreter.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport okhttp3.mockwebserver.MockResponse\nimport okhttp3.mockwebserver.MockWebServer\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertThrows\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport java.util.concurrent.CountDownLatch\nimport java.util.concurrent.TimeUnit\n\nclass CodesAdapterTest {\n    private lateinit var mockWebServer: MockWebServer\n    private lateinit var codesAdapter: CodesAdapter\n    private lateinit var httpClientProvider: HttpClientProvider\n\n    @BeforeEach\n    fun setUp() {\n        mockWebServer = MockWebServer()\n        mockWebServer.start()\n\n        val host = mockWebServer.hostName\n        val port = mockWebServer.port\n        val config =\n            ConnectionConfig.builder()\n                .domain(\"$host:$port\")\n                .protocol(\"http\")\n                .build()\n\n        val endpoint = SandboxEndpoint(\"$host:$port\")\n        httpClientProvider = HttpClientProvider(config)\n        codesAdapter = CodesAdapter(endpoint, httpClientProvider)\n    }\n\n    @AfterEach\n    fun tearDown() {\n        mockWebServer.shutdown()\n        httpClientProvider.close()\n    }\n\n    @Test\n    fun `createContext should send correct request`() {\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(200)\n                .setBody(\"\"\"{\"id\":\"ctx-123\", \"language\":\"python\"}\"\"\"),\n        )\n\n        val context = codesAdapter.createContext(\"python\")\n\n        assertEquals(\"ctx-123\", context.id)\n        assertEquals(\"python\", context.language)\n\n        val request = mockWebServer.takeRequest()\n        assertEquals(\"POST\", request.method)\n        assertEquals(\"/code/context\", request.path)\n    }\n\n    @Test\n    fun `createContext should include endpoint headers`() {\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(200)\n                .setBody(\"\"\"{\"id\":\"ctx-123\", \"language\":\"python\"}\"\"\"),\n        )\n\n        val host = mockWebServer.hostName\n        val port = mockWebServer.port\n        val config =\n            ConnectionConfig.builder()\n                .domain(\"$host:$port\")\n                .protocol(\"http\")\n                .build()\n        val endpoint = SandboxEndpoint(\"$host:$port\", mapOf(\"X-Endpoint\" to \"endpoint\"))\n\n        HttpClientProvider(config).use { provider ->\n            val adapter = CodesAdapter(endpoint, provider)\n            adapter.createContext(\"python\")\n        }\n\n        val request = mockWebServer.takeRequest()\n        assertEquals(\"endpoint\", request.getHeader(\"X-Endpoint\"))\n    }\n\n    @Test\n    fun `run should stream events correctly`() {\n        // SSE format\n        val event1 = \"\"\"{\"type\":\"stdout\",\"text\":\"Hello World\",\"timestamp\":1672531200000}\"\"\"\n        val event2 = \"\"\"{\"type\":\"execution_complete\",\"execution_time\":100,\"timestamp\":1672531201000}\"\"\"\n\n        val responseBody = \"$event1\\n$event2\\n\"\n\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(200)\n                .setBody(responseBody),\n        )\n\n        val receivedOutput = StringBuilder()\n        val latch = CountDownLatch(1)\n        var executionTime = -1L\n\n        val handlers =\n            ExecutionHandlers.builder()\n                .onStdout { msg -> receivedOutput.append(msg.text) }\n                .onExecutionComplete { complete ->\n                    executionTime = complete.executionTimeInMillis\n                    latch.countDown()\n                }\n                .build()\n\n        val request =\n            RunCodeRequest.builder()\n                .code(\"print('Hello World')\")\n                .handlers(handlers)\n                .build()\n\n        codesAdapter.run(request)\n\n        assertTrue(latch.await(2, TimeUnit.SECONDS), \"Timed out waiting for completion\")\n        assertEquals(\"Hello World\", receivedOutput.toString())\n        assertEquals(100L, executionTime)\n\n        val recordedRequest = mockWebServer.takeRequest()\n        assertEquals(\"/code\", recordedRequest.path)\n        assertEquals(\"POST\", recordedRequest.method)\n    }\n\n    @Test\n    fun `run should include endpoint headers`() {\n        val event1 = \"\"\"{\"type\":\"stdout\",\"text\":\"Hello World\",\"timestamp\":1672531200000}\"\"\"\n        val event2 = \"\"\"{\"type\":\"execution_complete\",\"execution_time\":100,\"timestamp\":1672531201000}\"\"\"\n\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(200)\n                .setBody(\"$event1\\n$event2\\n\"),\n        )\n\n        val host = mockWebServer.hostName\n        val port = mockWebServer.port\n        val config =\n            ConnectionConfig.builder()\n                .domain(\"$host:$port\")\n                .protocol(\"http\")\n                .build()\n        val endpoint = SandboxEndpoint(\"$host:$port\", mapOf(\"X-Endpoint\" to \"endpoint\"))\n\n        HttpClientProvider(config).use { provider ->\n            val adapter = CodesAdapter(endpoint, provider)\n            val request =\n                RunCodeRequest.builder()\n                    .code(\"print('Hello World')\")\n                    .handlers(ExecutionHandlers.builder().build())\n                    .build()\n\n            adapter.run(request)\n        }\n\n        val recordedRequest = mockWebServer.takeRequest()\n        assertEquals(\"endpoint\", recordedRequest.getHeader(\"X-Endpoint\"))\n    }\n\n    @Test\n    fun `interrupt should send correct request`() {\n        mockWebServer.enqueue(MockResponse().setResponseCode(204))\n\n        codesAdapter.interrupt(\"exec-123\")\n\n        val request = mockWebServer.takeRequest()\n        assertEquals(\"DELETE\", request.method)\n        assertEquals(\"/code\", request.requestUrl?.encodedPath)\n        assertEquals(\"exec-123\", request.requestUrl?.queryParameter(\"id\"))\n    }\n\n    @Test\n    fun `run should expose request id on api exception`() {\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(500)\n                .addHeader(\"X-Request-ID\", \"req-kotlin-code-123\")\n                .setBody(\"\"\"{\"code\":\"INTERNAL_ERROR\",\"message\":\"boom\"}\"\"\"),\n        )\n\n        val request = RunCodeRequest.builder().code(\"print('boom')\").build()\n        val ex = assertThrows(SandboxApiException::class.java) { codesAdapter.run(request) }\n\n        assertEquals(500, ex.statusCode)\n        assertEquals(\"req-kotlin-code-123\", ex.requestId)\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/code-interpreter-bom/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    `java-platform`\n}\n\ndependencies {\n    constraints {\n        api(project(\":code-interpreter\"))\n\n        api(libs.kotlin.stdlib)\n        api(libs.okhttp)\n        api(libs.okhttp.logging)\n        api(libs.kotlinx.serialization.json)\n        api(libs.slf4j.api)\n    }\n}\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/gradle/libs.versions.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[versions]\nkotlin = \"2.2.21\"\nkotlinx-serialization = \"1.9.0\"\nokhttp = \"4.12.0\"\nslf4j = \"2.0.9\"\njunit = \"5.10.1\"\nmockk = \"1.13.8\"\nspotless = \"6.23.3\"\nmaven-publish = \"0.35.0\"\ndokka = \"1.9.10\"\njackson = \"2.18.2\"\nsandbox = \"1.0.5\"\njunit-platform = \"1.13.4\"\n\n[libraries]\n# Kotlin\nkotlin-stdlib = { module = \"org.jetbrains.kotlin:kotlin-stdlib\", version.ref = \"kotlin\" }\n\n# HTTP\nokhttp = { module = \"com.squareup.okhttp3:okhttp\", version.ref = \"okhttp\" }\nokhttp-logging = { module = \"com.squareup.okhttp3:logging-interceptor\", version.ref = \"okhttp\" }\nokhttp-mockwebserver = { module = \"com.squareup.okhttp3:mockwebserver\", version.ref = \"okhttp\" }\n\n# Serialization\nkotlinx-serialization-json = { module = \"org.jetbrains.kotlinx:kotlinx-serialization-json\", version.ref = \"kotlinx-serialization\" }\n\n# Logging\nslf4j-api = { module = \"org.slf4j:slf4j-api\", version.ref = \"slf4j\" }\n\n# Testing\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\nmockk = { module = \"io.mockk:mockk\", version.ref = \"mockk\" }\njunit-platform-launcher = { module = \"org.junit.platform:junit-platform-launcher\", version = \"junit-platform\" }\n\n# Jackson(build-time)\njackson-core = { module = \"com.fasterxml.jackson.core:jackson-core\", version.ref = \"jackson\" }\njackson-databind = { module = \"com.fasterxml.jackson.core:jackson-databind\", version.ref = \"jackson\" }\njackson-yaml = { module = \"com.fasterxml.jackson.dataformat:jackson-dataformat-yaml\", version.ref = \"jackson\" }\njackson-kotlin = { module = \"com.fasterxml.jackson.module:jackson-module-kotlin\", version.ref = \"jackson\" }\n\n# sandbox\nsandbox = { module = \"com.alibaba.opensandbox:sandbox\", version.ref = \"sandbox\" }\nsandbox-api = { module = \"com.alibaba.opensandbox:sandbox-api\", version.ref = \"sandbox\" }\n\n[plugins]\nkotlin-jvm = { id = \"org.jetbrains.kotlin.jvm\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"kotlin\" }\nspotless = { id = \"com.diffplug.spotless\", version.ref = \"spotless\" }\nmavenPublish = { id = \"com.vanniktech.maven.publish\", version.ref = \"maven-publish\" }\ndokka = { id = \"org.jetbrains.dokka\", version.ref = \"dokka\" }\n\n[bundles]\nserialization = [\"kotlinx-serialization-json\"]\ntesting = [\"junit-jupiter\", \"mockk\", \"okhttp-mockwebserver\"]\njackson-build = [\"jackson-core\", \"jackson-databind\", \"jackson-yaml\", \"jackson-kotlin\"]\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.2.1-all.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/gradle.properties",
    "content": "# Build optimization\norg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\norg.gradle.caching=true\norg.gradle.parallel=true\n\n# Project metadata\nproject.group=com.alibaba.opensandbox\nproject.version=1.0.5\nproject.description=A Kotlin SDK for Code Interpreter\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "sdks/code-interpreter/kotlin/settings.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nrootProject.name = \"code-interpreter-parent\"\n\nplugins {\n    id(\"org.gradle.toolchains.foojay-resolver-convention\") version(\"1.0.0\")\n}\n\ninclude(\":code-interpreter\")\ninclude(\":code-interpreter-bom\")\n"
  },
  {
    "path": "sdks/code-interpreter/python/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "sdks/code-interpreter/python/Makefile",
    "content": ".PHONY: help install dev-install format lint type-check test test-cov clean docs build publish\n\n# Default target\nhelp: ## Show this help message\n\t@echo \"Available commands:\"\n\t@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = \":.*?## \"}; {printf \"\\033[36m%-15s\\033[0m %s\\n\", $$1, $$2}'\n\ninstall: ## Install package dependencies\n\tuv sync\n\ndev-install: ## Install package with development dependencies\n\tuv sync --group dev\n\nformat: ## Format code with ruff\n\tuv run ruff format .\n\nlint: ## Run linting with ruff\n\tuv run ruff check .\n\ntype-check: ## Run type checking with pyright\n\tuv run pyright\n\ntest: ## Run tests\n\tuv run pytest\n\ntest-cov: ## Run tests with coverage\n\tuv run pytest --cov=src/opensandbox --cov-report=html --cov-report=term\n\nclean: ## Clean build artifacts\n\trm -rf build/\n\trm -rf dist/\n\trm -rf *.egg-info/\n\trm -rf .pytest_cache/\n\trm -rf .coverage\n\trm -rf htmlcov/\n\tfind . -type d -name __pycache__ -exec rm -rf {} +\n\tfind . -name \"*.pyc\" -delete\n\ndocs: ## Generate documentation\n\tcd docs && uv run sphinx-build -b html . _build/html\n\nbuild: ## Build package\n\tuv build\n\npublish: ## Publish to PyPI (requires authentication)\n\tuv publish\n\n# Development workflow targets\ncheck: format lint type-check ## Run all code quality checks\n\nci: dev-install check test ## Run CI pipeline locally\n\n# Docker targets\ndocker-build: ## Build Docker image for development\n\tdocker build -t opensandbox-code-interpreter-dev .\n\ndocker-test: ## Run tests in Docker container\n\tdocker run --rm -v $(PWD):/app opensandbox-code-interpreter-dev make test\n"
  },
  {
    "path": "sdks/code-interpreter/python/README.md",
    "content": "# OpenSandbox Code Interpreter SDK for Python\n\nEnglish | [中文](README_zh.md)\n\nA Python SDK for executing code in secure, isolated sandboxes. It provides a high-level API for running Python, Java,\nGo, TypeScript, and other languages safely, with support for code execution contexts.\n\n## Prerequisites\n\nThis SDK requires a Docker image containing the Code Interpreter runtime environment. You must use the\n`opensandbox/code-interpreter` image (or a derivative) which includes pre-installed runtimes for Python, Java, Go,\nNode.js, etc.\n\nFor detailed information about supported languages and versions, refer to the\n[Environment Documentation](../../../sandboxes/code-interpreter/README.md).\n\n## Installation\n\n### pip\n\n```bash\npip install opensandbox-code-interpreter\n```\n\n### uv\n\n```bash\nuv add opensandbox-code-interpreter\n```\n\n## Quick Start\n\nThe following example demonstrates how to create a sandbox with a specific runtime configuration and execute a simple\nscript.\n\n```python\nimport asyncio\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nasync def main() -> None:\n    # 1. Configure connection\n    config = ConnectionConfig(\n        domain=\"api.opensandbox.io\",\n        api_key=\"your-api-key\",\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # 2. Create a Sandbox with the code-interpreter image + runtime versions\n    sandbox = await Sandbox.create(\n        \"opensandbox/code-interpreter:v1.0.2\",\n        connection_config=config,\n        entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n        env={\n            \"PYTHON_VERSION\": \"3.11\",\n            \"JAVA_VERSION\": \"17\",\n            \"NODE_VERSION\": \"20\",\n            \"GO_VERSION\": \"1.24\",\n        },\n    )\n\n    # 3. Use async context manager to ensure local resources are cleaned up\n    async with sandbox:\n        # 4. Create CodeInterpreter wrapper\n        interpreter = await CodeInterpreter.create(sandbox=sandbox)\n\n        # 5. Create an execution context (Python)\n        context = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\n\n        # 6. Run code\n        result = await interpreter.codes.run(\n            \"import sys\\nprint(sys.version)\\nresult = 2 + 2\\nresult\",\n            context=context,\n        )\n\n        # Alternatively, you can pass a language directly (recommended: SupportedLanguage.*).\n        # This uses the default context for that language (state can persist across runs).\n        # result = await interpreter.codes.run(\"print('hi')\", language=SupportedLanguage.PYTHON)\n\n        # 7. Print output\n        if result.result:\n            print(result.result[0].text)\n\n        # 8. Cleanup remote instance (optional but recommended)\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### Synchronous Quick Start\n\nIf you prefer a synchronous API, use `SandboxSync` + `CodeInterpreterSync`:\n\n```python\nfrom datetime import timedelta\n\nimport httpx\nfrom code_interpreter import CodeInterpreterSync\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\n\nconfig = ConnectionConfigSync(\n    domain=\"api.opensandbox.io\",\n    api_key=\"your-api-key\",\n    request_timeout=timedelta(seconds=60),\n    transport=httpx.HTTPTransport(limits=httpx.Limits(max_connections=20)),\n)\n\nsandbox = SandboxSync.create(\n    \"opensandbox/code-interpreter:v1.0.2\",\n    connection_config=config,\n    entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n    env={\"PYTHON_VERSION\": \"3.11\"},\n)\nwith sandbox:\n    interpreter = CodeInterpreterSync.create(sandbox=sandbox)\n    result = interpreter.codes.run(\"result = 2 + 2\\nresult\")\n    if result.result:\n        print(result.result[0].text)\n    sandbox.kill()\n```\n\n### Installing Python packages at runtime\n\nYou can install packages directly via `sandbox.commands.run(...)`:\n\n```python\nexecution = await sandbox.commands.run(\"pip install pandas numpy\")\n```\n\n## Runtime Configuration\n\n### Docker Image\n\nThe Code Interpreter SDK relies on a specialized environment. Ensure your sandbox provider has the\n`opensandbox/code-interpreter` image available.\n\n### Language Version Selection\n\nYou can specify the desired version of a programming language by setting the corresponding environment variable when\ncreating the `Sandbox`.\n\n| Language | Environment Variable | Example Value | Default (if unset) |\n| -------- | -------------------- | ------------- | ------------------ |\n| Python   | `PYTHON_VERSION`     | `3.11`        | Image default      |\n| Java     | `JAVA_VERSION`       | `17`          | Image default      |\n| Node.js  | `NODE_VERSION`       | `20`          | Image default      |\n| Go       | `GO_VERSION`         | `1.24`        | Image default      |\n\n## Usage Examples\n\n### 0. Run with `language` (default language context)\n\nYou can pass `language` directly (recommended: `SupportedLanguage.*`) and skip `create_context`.\nWhen `context.id` is omitted, **execd will create/reuse a default session for that language**, so\nstate can persist across runs:\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nexecution = await interpreter.codes.run(\n    \"result = 2 + 2\\nresult\",\n    language=SupportedLanguage.PYTHON,\n)\nassert execution.result and execution.result[0].text == \"4\"\n```\n\nState persistence example (default Python context):\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nawait interpreter.codes.run(\"x = 42\", language=SupportedLanguage.PYTHON)\nexecution = await interpreter.codes.run(\"result = x\\nresult\", language=SupportedLanguage.PYTHON)\nassert execution.result and execution.result[0].text == \"42\"\n```\n\n### 1. Java Code Execution\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nctx = await interpreter.codes.create_context(SupportedLanguage.JAVA)\nexecution = await interpreter.codes.run(\n    (\n        'System.out.println(\"Calculating sum...\");\\n'\n        + \"int a = 10;\\n\"\n        + \"int b = 20;\\n\"\n        + \"int sum = a + b;\\n\"\n        + 'System.out.println(\"Sum: \" + sum);\\n'\n        + \"sum\"\n    ),\n    context=ctx,\n)\n\nprint(execution.id)\nfor msg in execution.logs.stdout:\n    print(msg.text)\n```\n\n### 2. Python with State Persistence\n\nVariables defined in one execution are available in subsequent executions within the same context.\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\n\nawait interpreter.codes.run(\n    \"users = ['Alice', 'Bob', 'Charlie']\\nprint(len(users))\",\n    context=ctx,\n)\n\nresult = await interpreter.codes.run(\n    \"users.append('Dave')\\nprint(users)\\nresult = users\\nresult\",\n    context=ctx,\n)\n```\n\n### 3. Streaming Output Handling\n\nHandle stdout/stderr and execution events in real-time.\n\n```python\nfrom opensandbox.models.execd import ExecutionHandlers\nfrom code_interpreter import SupportedLanguage\n\nasync def on_stdout(msg):\n    print(\"STDOUT:\", msg.text)\n\nasync def on_stderr(msg):\n    print(\"STDERR:\", msg.text)\n\nhandlers = ExecutionHandlers(on_stdout=on_stdout, on_stderr=on_stderr)\n\nctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\nawait interpreter.codes.run(\n    \"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.5)\",\n    context=ctx,\n    handlers=handlers,\n)\n```\n\n### 4. Multi-Language Context Isolation\n\nDifferent languages run in isolated environments.\n\n```python\nfrom code_interpreter import SupportedLanguage\n\npy_ctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\ngo_ctx = await interpreter.codes.create_context(SupportedLanguage.GO)\n\nawait interpreter.codes.run(\"print('Running in Python')\", context=py_ctx)\nawait interpreter.codes.run(\n    \"package main\\nfunc main() { println(\\\"Running in Go\\\") }\",\n    context=go_ctx,\n)\n```\n\n## Notes\n\n- **Lifecycle**: `CodeInterpreter` wraps an existing `Sandbox` instance and reuses its connection configuration.\n- **Asyncio/event loop**: avoid sharing long-lived clients across multiple event loops (e.g. pytest-asyncio defaults).\n"
  },
  {
    "path": "sdks/code-interpreter/python/README_zh.md",
    "content": "# OpenSandbox Code Interpreter SDK for Python\n\n中文 | [English](README.md)\n\n一个用于在安全、隔离的沙箱环境中执行代码的 Python SDK。该 SDK 提供了高级 API，支持安全地运行 Python、Java、Go、TypeScript\n等语言，并具备“代码执行上下文（Context）”能力。\n\n## 前置要求\n\n本 SDK 需要配合包含 Code Interpreter 运行时环境的特定 Docker 镜像使用。请务必使用 `opensandbox/code-interpreter` 镜像（或其衍生镜像），其中预装了 Python、Java、Go、Node.js 等语言的运行环境。\n\n关于支持的语言与具体版本信息，请参考 [环境文档](../../../sandboxes/code-interpreter/README_zh.md)。\n\n## 安装指南\n\n### pip\n\n```bash\npip install opensandbox-code-interpreter\n```\n\n### uv\n\n```bash\nuv add opensandbox-code-interpreter\n```\n\n## 快速开始\n\n以下示例展示了如何创建带指定运行时配置的 Sandbox，并执行一段简单脚本。\n\n```python\nimport asyncio\nfrom datetime import timedelta\n\nfrom code_interpreter import CodeInterpreter, SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\n\n\nasync def main() -> None:\n    # 1. 配置连接信息\n    config = ConnectionConfig(\n        domain=\"api.opensandbox.io\",\n        api_key=\"your-api-key\",\n        request_timeout=timedelta(seconds=60),\n    )\n\n    # 2. 创建 Sandbox（必须使用 code-interpreter 镜像），并指定语言版本\n    sandbox = await Sandbox.create(\n        \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n        connection_config=config,\n        entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n        env={\n            \"PYTHON_VERSION\": \"3.11\",\n            \"JAVA_VERSION\": \"17\",\n            \"NODE_VERSION\": \"20\",\n            \"GO_VERSION\": \"1.24\",\n        },\n    )\n\n    # 3. 使用异步上下文管理器，确保本地资源正确清理\n    async with sandbox:\n        # 4. 创建 CodeInterpreter 包装器\n        interpreter = await CodeInterpreter.create(sandbox=sandbox)\n\n        # 5. 创建执行上下文（Python）\n        context = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\n\n        # 6. 运行代码\n        result = await interpreter.codes.run(\n            \"import sys\\nprint(sys.version)\\nresult = 2 + 2\\nresult\",\n            context=context,\n        )\n\n        # 或者：直接传入 language（推荐使用 SupportedLanguage.*），使用该语言默认上下文执行（可跨次保持状态）\n        # result = await interpreter.codes.run(\"print('hi')\", language=SupportedLanguage.PYTHON)\n\n        # 7. 打印输出\n        if result.result:\n            print(result.result[0].text)\n\n        # 8. 清理远程实例（可选，但推荐）\n        await sandbox.kill()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### 同步版本快速开始\n\n如果你更偏好同步 API，可以使用 `SandboxSync` + `CodeInterpreterSync`：\n\n```python\nfrom datetime import timedelta\n\nimport httpx\nfrom code_interpreter import CodeInterpreterSync\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\n\nconfig = ConnectionConfigSync(\n    domain=\"api.opensandbox.io\",\n    api_key=\"your-api-key\",\n    request_timeout=timedelta(seconds=60),\n    transport=httpx.HTTPTransport(limits=httpx.Limits(max_connections=20)),\n)\n\nsandbox = SandboxSync.create(\n    \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2\",\n    connection_config=config,\n    entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n    env={\"PYTHON_VERSION\": \"3.11\"},\n)\nwith sandbox:\n    interpreter = CodeInterpreterSync.create(sandbox=sandbox)\n    result = interpreter.codes.run(\"result = 2 + 2\\nresult\")\n    if result.result:\n        print(result.result[0].text)\n    sandbox.kill()\n```\n\n### 运行时安装 Python 依赖\n\n可以直接通过 `sandbox.commands.run(...)` 安装依赖：\n\n```python\nexecution = await sandbox.commands.run(\"pip install pandas numpy\")\n```\n\n## 运行时配置\n\n### Docker 镜像\n\nCode Interpreter SDK 依赖于特定的运行环境。请确保你的沙箱服务提供商支持 `opensandbox/code-interpreter` 镜像。\n\n### 语言版本选择\n\n你可以在创建 `Sandbox` 时通过环境变量指定所需的编程语言版本。\n\n| 语言    | 环境变量         | 示例值 | 默认值（若不设置） |\n| ------- | ---------------- | ------ | ------------------ |\n| Python  | `PYTHON_VERSION` | `3.11` | 镜像默认值         |\n| Java    | `JAVA_VERSION`   | `17`   | 镜像默认值         |\n| Node.js | `NODE_VERSION`   | `20`   | 镜像默认值         |\n| Go      | `GO_VERSION`     | `1.24` | 镜像默认值         |\n\n## 核心功能示例\n\n### 0. 直接传 `language`（使用该语言默认上下文）\n\n可以直接传入 `language`（推荐：`SupportedLanguage.*`），跳过 `create_context`。\n当 `context.id` 省略时，**execd 会为该语言创建/复用默认 session**，因此状态可以跨次执行保持：\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nexecution = await interpreter.codes.run(\n    \"result = 2 + 2\\nresult\",\n    language=SupportedLanguage.PYTHON,\n)\nassert execution.result and execution.result[0].text == \"4\"\n```\n\n状态持久化示例（Python 默认上下文）：\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nawait interpreter.codes.run(\"x = 42\", language=SupportedLanguage.PYTHON)\nexecution = await interpreter.codes.run(\"result = x\\nresult\", language=SupportedLanguage.PYTHON)\nassert execution.result and execution.result[0].text == \"42\"\n```\n\n### 1. Java 代码执行\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nctx = await interpreter.codes.create_context(SupportedLanguage.JAVA)\nexecution = await interpreter.codes.run(\n    (\n        'System.out.println(\"Calculating sum...\");\\n'\n        + \"int a = 10;\\n\"\n        + \"int b = 20;\\n\"\n        + \"int sum = a + b;\\n\"\n        + 'System.out.println(\"Sum: \" + sum);\\n'\n        + \"sum\"\n    ),\n    context=ctx,\n)\n\nprint(execution.id)\nfor msg in execution.logs.stdout:\n    print(msg.text)\n```\n\n### 2. Python 持久化状态\n\n在同一个上下文中，变量状态可以跨次执行保持。\n\n```python\nfrom code_interpreter import SupportedLanguage\n\nctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\n\nawait interpreter.codes.run(\n    \"users = ['Alice', 'Bob', 'Charlie']\\nprint(len(users))\",\n    context=ctx,\n)\n\nresult = await interpreter.codes.run(\n    \"users.append('Dave')\\nprint(users)\\nresult = users\\nresult\",\n    context=ctx,\n)\n```\n\n### 3. 流式输出处理\n\n实时处理 stdout/stderr 等事件。\n\n```python\nfrom opensandbox.models.execd import ExecutionHandlers\nfrom code_interpreter import SupportedLanguage\n\nasync def on_stdout(msg):\n    print(\"STDOUT:\", msg.text)\n\nasync def on_stderr(msg):\n    print(\"STDERR:\", msg.text)\n\nhandlers = ExecutionHandlers(on_stdout=on_stdout, on_stderr=on_stderr)\n\nctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\nawait interpreter.codes.run(\n    \"import time\\nfor i in range(5):\\n    print(i)\\n    time.sleep(0.5)\",\n    context=ctx,\n    handlers=handlers,\n)\n```\n\n### 4. 多语言上下文隔离\n\n不同语言在隔离的环境中运行。\n\n```python\nfrom code_interpreter import SupportedLanguage\n\npy_ctx = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\ngo_ctx = await interpreter.codes.create_context(SupportedLanguage.GO)\n\nawait interpreter.codes.run(\"print('Running in Python')\", context=py_ctx)\nawait interpreter.codes.run(\n    \"package main\\nfunc main() { println(\\\"Running in Go\\\") }\",\n    context=go_ctx,\n)\n```\n\n## 说明\n\n- **生命周期**：`CodeInterpreter` 基于既有的 `Sandbox` 实例进行包装，并复用其连接配置。\n- **Asyncio/event loop**：避免在多个 event loop 间共享长生命周期的 client/transport（例如 pytest-asyncio 默认行为）。\n"
  },
  {
    "path": "sdks/code-interpreter/python/pyproject.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"opensandbox-code-interpreter\"\ndynamic = [\"version\"]\ndescription = \"OpenSandbox Code Interpreter Python SDK - Advanced code execution with persistent contexts\"\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"ninan.nn@alibaba-inc.com\" }\n]\nlicense = { file = \"LICENSE\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nkeywords = [\"sandbox\", \"code-interpreter\", \"code-execution\", \"sdk\", \"opensandbox\"]\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Software Development :: Libraries\",\n    \"Typing :: Typed\",\n]\ndependencies = [\n    \"pydantic>=2.4.2,<3.0\",\n    \"opensandbox>=0.1.5,<0.2.0\",\n]\n\n[project.urls]\nHomepage = \"https://open-sandbox.ai\"\nRepository = \"https://github.com/alibaba/OpenSandbox\"\nDocumentation = \"https://open-sandbox.ai\"\nIssues = \"https://github.com/alibaba/OpenSandbox/issues\"\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.hatch.version.raw-options]\n# This package is in a subdirectory; explicitly point setuptools-scm at the git root.\nroot = \"../../..\"\ntag_regex = \"^python/code-interpreter/v(?P<version>\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[\\\\.\\\\w\\\\+\\\\-]*)?)$\"\ngit_describe_command = 'git describe --dirty --tags --long --match \"python/code-interpreter/v*\"'\nfallback_version = \"0.1.0\"\n\n[tool.hatch.build]\ninclude = [\n    \"LICENSE\",\n    \"src/**/py.typed\",\n    \"src/code_interpreter\"\n]\n\n[dependency-groups]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"pytest-cov>=4.0.0\",\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.407\",\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/code_interpreter\"]\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 88\n\n[tool.ruff.lint]\nselect = [\n    \"E\",  # pycodestyle errors\n    \"W\",  # pycodestyle warnings\n    \"F\",  # pyflakes\n    \"I\",  # isort\n    \"B\",  # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"UP\", # pyupgrade\n]\nignore = [\n    \"E501\", # line too long, handled by black\n    \"B008\", # do not perform function calls in argument defaults\n    \"C901\", # too complex\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.10\"\npythonPlatform = \"All\"\n\ninclude = [\"src\"]\n\nexclude = [\n    \"**/node_modules\",\n    \"**/__pycache__\",\n    \"src/opensandbox/api/**\",\n]\n\nvenvPath = \".\"\nvenv = \".venv\"\n\nreportMissingImports = true\nreportMissingTypeStubs = false\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = \"-ra -q --strict-markers --strict-config\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\", \"*_test.py\"]\nasyncio_mode = \"auto\"\n\n[tool.coverage.run]\nsource = [\"src\"]\nbranch = true\n\n\n[tool.uv.sources]\nopensandbox = { path = \"../../sandbox/python\", editable = true }\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nOpenSandbox Code Interpreter SDK.\n\nThis package provides secure, isolated code execution capabilities built on top\nof the OpenSandbox infrastructure. It supports multiple programming languages,\nsession management, and variable persistence across executions.\n\"\"\"\n\nfrom importlib.metadata import PackageNotFoundError\nfrom importlib.metadata import version as _pkg_version\n\nfrom code_interpreter.code_interpreter import CodeInterpreter\nfrom code_interpreter.models.code import (\n    CodeContext,\n    SupportedLanguage,\n)\nfrom code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n__all__ = [\n    \"CodeInterpreter\",\n    \"CodeInterpreterSync\",\n    \"CodeContext\",\n    \"SupportedLanguage\",\n]\n\ntry:\n    __version__ = _pkg_version(\"opensandbox-code-interpreter\")\nexcept PackageNotFoundError:  # pragma: no cover\n    # Fallback for editable/uninstalled source checkouts.\n    __version__ = \"0.0.0\"\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/adapters/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nAdapter implementations for code execution services.\n\"\"\"\n\nfrom code_interpreter.adapters.code_adapter import CodesAdapter\nfrom code_interpreter.adapters.converter.code_execution_converter import (\n    CodeExecutionConverter,\n)\nfrom code_interpreter.adapters.factory import AdapterFactory\n\n__all__ = [\n    \"CodesAdapter\",\n    \"CodeExecutionConverter\",\n    \"AdapterFactory\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/adapters/code_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nAdapter implementation for code execution service.\n\nProvides the concrete implementation of Codes by wrapping auto-generated\nAPI clients and handling SSE streaming for real-time code execution.\n\"\"\"\n\nimport json\nimport logging\nimport time\n\nimport httpx\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.execution_event_dispatcher import (\n    ExecutionEventDispatcher,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.execd import Execution, ExecutionHandlers\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.adapters.converter.code_execution_converter import (\n    CodeExecutionConverter,\n)\nfrom code_interpreter.models.code import CodeContext, SupportedLanguage\nfrom code_interpreter.services.code import Codes\n\nlogger = logging.getLogger(__name__)\n\n\ndef _normalize_sse_event(event_dict: dict) -> dict:\n    if \"type\" in event_dict and \"timestamp\" in event_dict:\n        return event_dict\n    if \"code\" in event_dict and \"message\" in event_dict:\n        return {\n            \"type\": \"error\",\n            \"timestamp\": int(time.time() * 1000),\n            \"error\": {\n                \"ename\": str(event_dict[\"code\"]),\n                \"evalue\": str(event_dict[\"message\"]),\n                \"traceback\": [],\n            },\n        }\n    return event_dict\n\n\nclass CodesAdapter(Codes):\n    \"\"\"\n    Adapter implementation for code execution service.\n\n    This adapter wraps auto-generated API clients and provides the concrete\n    implementation of the Codes interface. It handles both standard\n    API calls and SSE streaming for real-time code execution output.\n\n    Similar to CommandServiceAdapter, this adapter uses:\n    - Generated API clients for simple operations (create_context, interrupt)\n    - Direct httpx SSE streaming for run\n    - ExceptionConverter for unified exception handling\n    \"\"\"\n\n    RUN_CODE_PATH = \"/code\"\n    CREATE_CONTEXT_PATH = \"/code/context\"\n\n    def __init__(\n        self, execd_endpoint: SandboxEndpoint, connection_config: ConnectionConfig\n    ) -> None:\n        \"\"\"\n        Initialize the code service adapter.\n\n        Args:\n            execd_endpoint: Endpoint for execd daemon connection\n            connection_config: Shared connection configuration (transport, headers, timeouts)\n        \"\"\"\n        self.execd_endpoint = execd_endpoint\n        self.connection_config = connection_config\n        from opensandbox.api.execd import Client\n\n        protocol = self.connection_config.protocol\n        base_url = f\"{protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        # Execd API does not require authentication\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n\n        # Inject httpx client (adapter-owned)\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n        # SSE client (read timeout disabled)\n        sse_headers = {\n            **headers,\n            \"Accept\": \"text/event-stream\",\n            \"Cache-Control\": \"no-cache\",\n        }\n        self._sse_client = httpx.AsyncClient(\n            headers=sse_headers,\n            timeout=httpx.Timeout(\n                connect=timeout_seconds,\n                read=None,\n                write=timeout_seconds,\n                pool=None,\n            ),\n            transport=self.connection_config.transport,\n        )\n\n    async def _get_client(self):\n        \"\"\"Return the client for execd API (no auth required).\"\"\"\n        return self._client\n\n    def _get_execd_url(self, path: str) -> str:\n        \"\"\"Build URL for execd endpoint.\"\"\"\n        protocol = self.connection_config.protocol\n        return f\"{protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    async def _get_sse_client(self) -> httpx.AsyncClient:\n        \"\"\"Return SSE client (read timeout disabled) for execd streaming.\"\"\"\n        return self._sse_client\n\n    async def create_context(self, language: str) -> CodeContext:\n        \"\"\"\n        Creates a new execution context for code interpretation.\n\n        Uses the generated API client for this non-streaming operation.\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.code_interpreting import create_code_context\n            from opensandbox.api.execd.models.code_context_request import (\n                CodeContextRequest,\n            )\n\n            client = await self._get_client()\n            api_request = CodeContextRequest(language=language)\n\n            response_obj = await create_code_context.asyncio_detailed(\n                client=client,\n                body=api_request,\n            )\n\n            handle_api_error(response_obj, \"Create code context\")\n            from opensandbox.api.execd.models.code_context import (\n                CodeContext as ApiCodeContext,\n            )\n\n            parsed = require_parsed(response_obj, ApiCodeContext, \"Create code context\")\n            return CodeExecutionConverter.from_api_code_context(parsed)\n\n        except Exception as e:\n            logger.error(\"Failed to create context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_context(self, context_id: str) -> CodeContext:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import get_context\n            from opensandbox.api.execd.models.code_context import (\n                CodeContext as ApiCodeContext,\n            )\n\n            client = await self._get_client()\n            response_obj = await get_context.asyncio_detailed(\n                client=client,\n                context_id=context_id,\n            )\n            handle_api_error(response_obj, \"Get code context\")\n            parsed = require_parsed(response_obj, ApiCodeContext, \"Get code context\")\n            return CodeExecutionConverter.from_api_code_context(parsed)\n        except Exception as e:\n            logger.error(\"Failed to get context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def list_contexts(self, language: str) -> list[CodeContext]:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import list_contexts\n\n            client = await self._get_client()\n            response_obj = await list_contexts.asyncio_detailed(\n                client=client,\n                language=language,\n            )\n            handle_api_error(response_obj, \"List code contexts\")\n            parsed_list = require_parsed(response_obj, list, \"List code contexts\")\n            return [CodeExecutionConverter.from_api_code_context(c) for c in parsed_list]\n        except Exception as e:\n            logger.error(\"Failed to list contexts\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def delete_context(self, context_id: str) -> None:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import delete_context\n\n            client = await self._get_client()\n            response_obj = await delete_context.asyncio_detailed(\n                client=client,\n                context_id=context_id,\n            )\n            handle_api_error(response_obj, \"Delete code context\")\n        except Exception as e:\n            logger.error(\"Failed to delete context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def delete_contexts(self, language: str) -> None:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import (\n                delete_contexts_by_language,\n            )\n\n            client = await self._get_client()\n            response_obj = await delete_contexts_by_language.asyncio_detailed(\n                client=client,\n                language=language,\n            )\n            handle_api_error(response_obj, \"Delete code contexts by language\")\n        except Exception as e:\n            logger.error(\"Failed to delete contexts\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def run(\n        self,\n        code: str,\n        *,\n        language: str | None = None,\n        context: CodeContext | None = None,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution:\n        \"\"\"\n        Executes code within the specified context using SSE streaming.\n\n        Similar to CommandServiceAdapter.run, this uses direct httpx\n        streaming to handle SSE responses from the execd service.\n        \"\"\"\n        if not code.strip():\n            raise InvalidArgumentException(\"Code cannot be empty\")\n\n        try:\n            if context is not None and language is not None and context.language != language:\n                raise InvalidArgumentException(\n                    f\"language '{language}' must match context.language '{context.language}'\"\n                )\n\n            # Default context: language default context (server-side behavior).\n            # When context.id is omitted, execd will create/reuse a default session per language.\n            if context is None:\n                context = CodeContext(language=language or SupportedLanguage.PYTHON)\n            api_request = CodeExecutionConverter.to_api_run_code_request(code, context)\n\n            # Prepare URL\n            url = self._get_execd_url(self.RUN_CODE_PATH)\n\n            execution = Execution(\n                id=None,\n                execution_count=None,\n                result=[],\n                error=None,\n            )\n\n            # Use SSE client for streaming responses (read timeout disabled)\n            client = await self._get_sse_client()\n\n            # Use streaming request for SSE\n            async with client.stream(\"POST\", url, json=api_request) as response:\n                if response.status_code != 200:\n                    await response.aread()\n                    error_body = response.text\n                    logger.error(\n                        \"Failed to run code. Status: %s, Body: %s\",\n                        response.status_code,\n                        error_body,\n                    )\n                    raise SandboxApiException(\n                        message=f\"Failed to run code. Status code: {response.status_code}\",\n                        status_code=response.status_code,\n                        request_id=extract_request_id(response.headers),\n                    )\n\n                dispatcher = ExecutionEventDispatcher(execution, handlers)\n\n                async for line in response.aiter_lines():\n                    if not line.strip():\n                        continue\n\n                    # Handle potential SSE format \"data: ...\"\n                    data = line\n                    if data.startswith(\"data:\"):\n                        data = data[5:].strip()\n\n                    try:\n                        event_dict = _normalize_sse_event(json.loads(data))\n                        event_node = EventNode(**event_dict)\n                        await dispatcher.dispatch(event_node)\n                    except json.JSONDecodeError:\n                        logger.debug(\"Failed to parse SSE line: %s\", line)\n                        continue\n                    except Exception as e:\n                        logger.error(\"Error processing event: %s\", data, exc_info=e)\n                        continue\n\n            return execution\n\n        except Exception as e:\n            logger.error(\n                \"Failed to run code (length: %s)\", len(code), exc_info=e\n            )\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupts a currently running code execution.\n\n        Uses the generated API client for this operation.\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.code_interpreting import interrupt_code\n\n            client = await self._get_client()\n            response_obj = await interrupt_code.asyncio_detailed(\n                client=client,\n                id=execution_id,\n            )\n\n            handle_api_error(response_obj, \"Interrupt code execution\")\n\n        except Exception as e:\n            logger.error(\"Failed to interrupt code execution\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/adapters/converter/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nModel converters for code execution adapters.\n\"\"\"\n\nfrom code_interpreter.adapters.converter.code_execution_converter import (\n    CodeExecutionConverter,\n)\n\n__all__ = [\n    \"CodeExecutionConverter\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/adapters/converter/code_execution_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nConverter for code execution models between domain and API layers.\n\nHandles the transformation of code execution requests and contexts\nbetween the domain model and auto-generated API client models.\n\"\"\"\n\nfrom typing import Any\n\nfrom opensandbox.api.execd.models import CodeContext as ApiCodeContext\n\nfrom code_interpreter.models.code import CodeContext\n\n\nclass CodeExecutionConverter:\n    \"\"\"\n    Converts code execution models between domain and API representations.\n    \"\"\"\n\n    @staticmethod\n    def to_api_run_code_request(code: str, context: CodeContext | None) -> dict[str, Any]:\n        \"\"\"\n        Converts domain code + context to API request dictionary.\n\n        Args:\n            code: Source code to execute\n            context: Optional execution context (language + optional id)\n\n        Returns:\n            Dictionary representation for API call\n        \"\"\"\n        result: dict[str, Any] = {\"code\": code}\n\n        if context is not None:\n            result[\"context\"] = CodeExecutionConverter.to_api_code_context(context)\n\n        return result\n\n    @staticmethod\n    def to_api_code_context(context: CodeContext) -> dict[str, Any]:\n        \"\"\"\n        Converts domain CodeContext to API context dictionary.\n\n        Args:\n            context: Domain model code context\n\n        Returns:\n            Dictionary representation for API call\n        \"\"\"\n        result: dict[str, Any] = {\n            \"language\": context.language,\n        }\n\n        if context.id:\n            result[\"id\"] = context.id\n\n        return result\n\n    @staticmethod\n    def from_api_code_context(api_context: ApiCodeContext) -> CodeContext:\n        \"\"\"\n        Converts API CodeContextResponse to domain CodeContext.\n\n        Args:\n            api_context: API response from create_code_context\n\n        Returns:\n            Domain model code context\n        \"\"\"\n        from opensandbox.api.execd.types import Unset\n\n        context_id = None if isinstance(api_context.id, Unset) else api_context.id\n\n        return CodeContext(\n            id=context_id,\n            language=api_context.language\n        )\n\n    @staticmethod\n    def from_api_code_context_dict(api_context: dict[str, Any]) -> CodeContext:\n        \"\"\"\n        Converts API code context dictionary to domain CodeContext.\n\n        Args:\n            api_context: API response dictionary containing context data\n\n        Returns:\n            Domain model code context\n        \"\"\"\n        return CodeContext(\n            id=api_context.get(\"id\"),\n            language=api_context.get(\"language\", \"python\")\n        )\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/adapters/factory.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFactory for creating code interpreter services.\n\nProvides a centralized way to create and configure code execution services\nwith proper dependency injection and configuration management.\n\"\"\"\n\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.adapters.code_adapter import CodesAdapter\nfrom code_interpreter.services.code import Codes\n\n\nclass AdapterFactory:\n    \"\"\"\n    Factory for creating code interpreter service instances.\n\n    This factory handles the creation of code execution services with proper\n    configuration and dependency injection, ensuring all services have access\n    to the required HTTP client and endpoint configuration.\n    \"\"\"\n\n    def __init__(self, connection_config: ConnectionConfig) -> None:\n        \"\"\"\n        Initialize the factory with shared connection configuration.\n\n        Args:\n            connection_config: Shared connection configuration (transport, headers, timeouts)\n        \"\"\"\n        self.connection_config = connection_config\n\n    def create_code_execution_service(self, endpoint: SandboxEndpoint) -> Codes:\n        \"\"\"\n        Create a code execution service for the specified endpoint.\n\n        Args:\n            endpoint: Sandbox endpoint for code execution services.\n\n        Returns:\n            Configured code service instance.\n        \"\"\"\n        return CodesAdapter(endpoint, self.connection_config)\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nCode Interpreter SDK providing secure, isolated code execution capabilities.\n\nThis module provides the main CodeInterpreter class that extends basic Sandbox\nfunctionality with specialized code execution features, including multi-language\nsupport, session management, and variable persistence.\n\"\"\"\n\nimport logging\n\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxException,\n    SandboxInternalException,\n)\nfrom opensandbox.sandbox import Sandbox\n\nfrom code_interpreter.adapters.factory import AdapterFactory\nfrom code_interpreter.services.code import Codes\n\nlogger = logging.getLogger(__name__)\n\n\nclass CodeInterpreter:\n    \"\"\"\n    Code Interpreter SDK providing secure, isolated code execution capabilities.\n\n    This class extends the basic Sandbox functionality with specialized code execution features,\n    including multi-language support, session management, and variable persistence.\n\n    Key Features:\n\n    - Multi-language Code Execution: Support for Python, JavaScript, Bash, Java, Kotlin\n    - Session Management: Persistent execution contexts with variable state\n    - Sandbox Integration: Full access to underlying sandbox file system and command execution\n    - Streaming Execution: Real-time code execution with output streaming\n    - Variable Inspection: Access to execution variables and state\n\n    Usage Example:\n\n    ```python\n    # First create a sandbox instance\n\n    sandbox = await Sandbox.create(\n        \"python:3.11\",\n        resource={\"cpu\": \"1\", \"memory\": \"2Gi\"}\n    )\n\n    # Then create a code interpreter wrapping the sandbox\n    interpreter = await CodeInterpreter.create(sandbox=sandbox)\n\n    # Execute code with context\n    from code_interpreter.models.code import SupportedLanguage\n    context = await interpreter.codes.create_context(SupportedLanguage.PYTHON)\n    result = await interpreter.codes.run(\"print('Hello World')\", context=context)\n    print(result.logs.stdout)  # Output: Hello World\n\n    # Access underlying sandbox for file operations\n    await interpreter.sandbox.files.write_files([\n        WriteEntry(path=\"data.txt\", data=\"Hello\")\n    ])\n    file_result = await interpreter.codes.run(\n        \"with open('data.txt') as f: print(f.read())\",\n        context=context,\n    )\n\n    # Always clean up resources\n    await sandbox.kill()\n    await sandbox.close()\n    ```\n    \"\"\"\n\n    def __init__(self, sandbox: Sandbox, code_service: Codes) -> None:\n        \"\"\"\n        Initialize CodeInterpreter with sandbox and code service.\n\n        Note: This constructor is for internal use. Use CodeInterpreter.create() instead.\n\n        Args:\n            sandbox: Underlying sandbox instance\n            code_service: Code execution implementation\n        \"\"\"\n        self._sandbox = sandbox\n        self._code_service = code_service\n\n    @property\n    def sandbox(self) -> Sandbox:\n        \"\"\"\n        Provides access to the underlying sandbox instance.\n\n        Returns:\n            The underlying sandbox instance\n        \"\"\"\n        return self._sandbox\n\n    @property\n    def id(self) -> str:\n        \"\"\"\n        Gets the unique identifier of this code interpreter (same as underlying sandbox ID).\n\n        Returns:\n            ID of the code interpreter/sandbox\n        \"\"\"\n        return self._sandbox.id\n\n    @property\n    def files(self):\n        \"\"\"\n        Provides access to file system operations within the sandbox.\n\n        Allows writing, reading, listing, and deleting files and directories.\n\n        Returns:\n            Service for filesystem manipulation\n        \"\"\"\n        return self._sandbox.files\n\n    @property\n    def commands(self):\n        \"\"\"\n        Provides access to command execution operations.\n\n        Allows running shell commands, capturing output, and managing processes.\n\n        Returns:\n            Service for command execution\n        \"\"\"\n        return self._sandbox.commands\n\n    @property\n    def metrics(self):\n        \"\"\"\n        Provides access to sandbox metrics and monitoring.\n\n        Allows retrieving resource usage statistics (CPU, memory) and other performance metrics.\n\n        Returns:\n            Service for metrics retrieval\n        \"\"\"\n        return self._sandbox.metrics\n\n    @property\n    def codes(self) -> Codes:\n        \"\"\"\n        Provides access to code execution operations.\n\n        This service enables:\n        - Multi-language code execution (Python, JavaScript, Bash, etc.)\n        - Execution context management with persistent variables\n        - Real-time output streaming and interruption capabilities\n\n        Returns:\n            Service for advanced code execution with session support\n        \"\"\"\n        return self._code_service\n\n    @classmethod\n    async def create(cls, sandbox: Sandbox) -> \"CodeInterpreter\":\n        \"\"\"\n        Creates a CodeInterpreter from an existing Sandbox instance.\n\n        This factory method handles the creation and initialization of CodeInterpreter\n        services, including the code execution service and language configuration.\n\n        CodeInterpreter must be created by wrapping an existing Sandbox instance with\n        code execution capabilities. This design ensures clear separation of concerns:\n        - Sandbox handles infrastructure (containers, resources, networking)\n        - CodeInterpreter adds code execution capabilities on top\n\n        Args:\n            sandbox: Existing sandbox instance to wrap with code execution capabilities\n\n        Returns:\n            CodeInterpreter instance wrapping the sandbox\n\n        Raises:\n            InvalidArgumentException: If sandbox is not provided\n            SandboxException: If creation fails\n            SandboxInternalException: If internal service initialization fails\n        \"\"\"\n        if sandbox is None:\n            raise InvalidArgumentException(\"Sandbox instance must be provided\")\n\n        logger.info(\"Creating code interpreter from sandbox: %s\", sandbox.id)\n\n        factory = AdapterFactory(sandbox.connection_config)\n\n        try:\n            # Connect to the execd daemon endpoint for code execution services\n            from opensandbox.constants import DEFAULT_EXECD_PORT\n            code_interpreter_endpoint = await sandbox.get_endpoint(DEFAULT_EXECD_PORT)\n            code_execution_service = factory.create_code_execution_service(code_interpreter_endpoint)\n\n            logger.info(\"Code interpreter %s created successfully\", sandbox.id)\n\n            return cls(sandbox, code_execution_service)\n        except Exception as e:\n            if isinstance(e, SandboxException):\n                raise\n            raise SandboxInternalException(\n                f\"Failed to create code interpreter: {e}\", cause=e\n            ) from e\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/models/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nData models for code execution and interpretation.\n\"\"\"\n\nfrom code_interpreter.models.code import (\n    CodeContext,\n    SupportedLanguage,\n)\n\n__all__ = [\n    \"CodeContext\",\n    \"SupportedLanguage\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/models/code.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nCode execution models.\n\nModels for code contexts, execution requests, and language support.\n\"\"\"\n\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\n\n\nclass SupportedLanguage:\n    \"\"\"\n    Supported programming languages for code execution.\n\n    This class defines the languages that are officially supported by the code interpreter.\n    When adding new languages, ensure corresponding execution environments are available.\n    \"\"\"\n    PYTHON = \"python\"\n    JAVA = \"java\"\n    GO = \"go\"\n    TYPESCRIPT = \"typescript\"\n    BASH = \"bash\"\n    JAVASCRIPT = \"javascript\"\n\n\nclass CodeContext(BaseModel):\n    \"\"\"\n    Represents an execution context for code interpretation.\n\n    A CodeContext maintains the execution environment for a specific programming\n    language, including the working directory, language configuration, and\n    persistent state across multiple code executions.\n\n    Context Lifecycle:\n\n    1. Creation: Context is created with language and working directory\n    2. Execution: Code runs within this context, building up state\n    3. Persistence: Variables, imports, and functions persist between executions\n    4. Cleanup: Context can be explicitly destroyed or garbage collected\n    \"\"\"\n\n    id: str | None = Field(default=None, description=\"Unique identifier for this execution context\")\n    language: str = Field(description=\"Programming language for this context (e.g., 'python', 'javascript')\")\n\n    @field_validator('language')\n    @classmethod\n    def language_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Language cannot be blank\")\n        return v\n\n\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/models/code_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous code execution models for Code Interpreter SDK.\n\"\"\"\n\nfrom pydantic import BaseModel, Field, field_validator\n\n\nclass SupportedLanguageSync:\n    # kept for symmetry; values match SupportedLanguage\n    PYTHON = \"python\"\n    JAVA = \"java\"\n    GO = \"go\"\n    TYPESCRIPT = \"typescript\"\n    BASH = \"bash\"\n\n\nclass CodeContextSync(BaseModel):\n    id: str | None = Field(default=None)\n    language: str = Field(description=\"Programming language for this context\")\n\n    @field_validator(\"language\")\n    @classmethod\n    def language_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Language cannot be blank\")\n        return v\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/py.typed",
    "content": ""
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/services/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nServices for code execution and interpretation.\n\"\"\"\n\nfrom code_interpreter.services.code import Codes\n\n__all__ = [\n    \"Codes\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/services/code.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nCode execution service interface.\n\nDefines the contract for multi-language code interpretation with context management,\nsession persistence, and real-time execution capabilities.\n\"\"\"\n\nfrom typing import Protocol, overload\n\nfrom opensandbox.models.execd import Execution, ExecutionHandlers\n\nfrom code_interpreter.models.code import CodeContext\n\n\nclass Codes(Protocol):\n    \"\"\"\n    Code execution service for multi-language code interpretation.\n\n    This service provides advanced code execution capabilities with context management,\n    session persistence, and multi-language support. It extends basic command execution\n    with interpreter-specific features like variable inspection and execution history.\n\n    Supported Languages:\n\n    - Python: Full Python 3.x support with package management\n    - JavaScript/Node.js: ES6+ with npm package support\n    - Bash: Shell scripting with full system access\n    - Java: Compilation and execution with classpath management\n    - Kotlin: Script and compiled Kotlin execution\n\n    Key Features:\n\n    - Execution Contexts: Isolated environments with persistent state\n    - Variable Persistence: Variables and imports persist across executions\n    - Real-time Interruption: Stop long-running code execution safely\n    - Output Streaming: Real-time stdout/stderr with proper buffering\n    - Error Handling: Language-specific error parsing and reporting\n\n    Usage Example:\n\n    ```python\n    # Create execution context\n    context = await code_service.create_context(SupportedLanguage.PYTHON)\n\n    # Execute code with persistent state\n    result1 = await code_service.run(\n        \"import numpy as np; x = 42\",\n        context=context,\n    )\n\n    result2 = await code_service.run(\n        \"print(f'Value: {x}, NumPy version: {np.__version__}')\",\n        context=context,\n    )\n    # Variables 'x' and 'np' persist between executions\n    ```\n    \"\"\"\n\n    async def create_context(self, language: str) -> CodeContext:\n        \"\"\"\n        Creates a new execution context for code interpretation.\n\n        An execution context maintains the state of variables, imports, and working\n        directory across multiple code executions. This allows for interactive\n        programming sessions where subsequent code can reference previously\n        defined variables and functions.\n\n        Args:\n            language: The programming language for this context (e.g., \"python\", \"javascript\")\n\n        Returns:\n            A new CodeContext with the specified configuration\n\n        Raises:\n            SandboxException: If the language is not supported or context creation fails\n        \"\"\"\n        ...\n\n    async def get_context(self, context_id: str) -> CodeContext:\n        \"\"\"\n        Get an existing execution context by id.\n\n        Args:\n            context_id: Context/session id\n\n        Returns:\n            The existing CodeContext\n        \"\"\"\n        ...\n\n    async def list_contexts(self, language: str) -> list[CodeContext]:\n        \"\"\"\n        List active contexts under a given language/runtime.\n\n        Args:\n            language: Execution runtime (e.g. \"python\", \"bash\")\n\n        Returns:\n            List of contexts\n        \"\"\"\n        ...\n\n    async def delete_context(self, context_id: str) -> None:\n        \"\"\"\n        Delete an execution context by id.\n\n        Args:\n            context_id: Context/session id to delete\n        \"\"\"\n        ...\n\n    async def delete_contexts(self, language: str) -> None:\n        \"\"\"\n        Delete all execution contexts under a given language/runtime.\n\n        Args:\n            language: Execution runtime (e.g. \"python\", \"bash\")\n        \"\"\"\n        ...\n\n    @overload\n    async def run(\n        self,\n        code: str,\n        *,\n        context: CodeContext,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution: ...\n\n    @overload\n    async def run(\n        self,\n        code: str,\n        *,\n        language: str,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution: ...\n\n    async def run(\n        self,\n        code: str,\n        *,\n        language: str | None = None,\n        context: CodeContext | None = None,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution:\n        \"\"\"\n        Executes code within the specified context.\n\n        This method runs the provided code string in the language interpreter,\n        capturing all output, errors, and execution metadata. The execution\n        happens within the context's environment, preserving variable state\n        and working directory.\n\n        Execution Behavior:\n\n        - Asynchronous: Non-blocking execution with proper async handling\n        - Stateful: Variables and imports persist in the context\n        - Streaming: Output is captured in real-time as it's produced\n        - Interruptible: Can be stopped using interrupt() method\n\n        Args:\n            code: Source code to execute.\n            language: Convenience language selector for this run. If provided and ``context`` is None,\n                a **default context for this language** is used (execd will create/reuse a default\n                session when ``context.id`` is omitted). If both ``language`` and ``context`` are\n                provided, they must match.\n            context: Execution context (language + optional id). If None, the default Python context is used.\n            handlers: Optional streaming handlers for stdout/stderr/events.\n\n        Returns:\n            Execution with stdout, stderr, exit code, and execution metadata\n\n        Raises:\n            SandboxException: If execution fails or times out\n        \"\"\"\n        ...\n\n    async def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupts a currently running code execution.\n\n        This method safely terminates a running code execution, cleaning up\n        resources and ensuring the interpreter remains in a consistent state.\n        The interruption is cooperative and may take some time to complete.\n\n        Interruption Behavior:\n\n        - Safe: Preserves interpreter state and doesn't corrupt the context\n        - Cooperative: Respects language-specific interruption mechanisms\n        - Timeout: Will force-kill after a reasonable timeout if needed\n\n        Args:\n            execution_id: The unique identifier of the execution to interrupt\n\n        Raises:\n            SandboxException: If interruption fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom code_interpreter.sync.code_interpreter import CodeInterpreterSync\n\n__all__ = [\"CodeInterpreterSync\"]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/adapters/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nAdapter implementations for Code Interpreter sync services.\n\"\"\"\n\nfrom code_interpreter.sync.adapters.code_adapter import CodesAdapterSync\nfrom code_interpreter.sync.adapters.factory import AdapterFactorySync\n\n__all__ = [\n    \"AdapterFactorySync\",\n    \"CodesAdapterSync\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/adapters/code_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous adapter for code execution service (including SSE streaming).\n\"\"\"\n\nimport json\nimport logging\nimport time\n\nimport httpx\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.execd import Execution\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.adapters.converter.execution_event_dispatcher import (\n    ExecutionEventDispatcherSync,\n)\n\nfrom code_interpreter.models.code_sync import CodeContextSync, SupportedLanguageSync\nfrom code_interpreter.sync.services.code import CodesSync\n\nlogger = logging.getLogger(__name__)\n\n\ndef _normalize_sse_event(event_dict: dict) -> dict:\n    if \"type\" in event_dict and \"timestamp\" in event_dict:\n        return event_dict\n    if \"code\" in event_dict and \"message\" in event_dict:\n        return {\n            \"type\": \"error\",\n            \"timestamp\": int(time.time() * 1000),\n            \"error\": {\n                \"ename\": str(event_dict[\"code\"]),\n                \"evalue\": str(event_dict[\"message\"]),\n                \"traceback\": [],\n            },\n        }\n    return event_dict\n\n\nclass CodesAdapterSync(CodesSync):\n    \"\"\"\n    Synchronous adapter for code execution service.\n\n    This adapter is the sync counterpart of :class:`code_interpreter.adapters.code_adapter.CodesAdapter`.\n    It wraps the generated execd API client for non-streaming operations and uses direct ``httpx``\n    streaming for SSE output while running code.\n\n    Notes:\n\n    - ``run`` performs blocking SSE streaming via ``httpx.Client.stream``.\n    - Each SSE line is parsed into an :class:`EventNode` and dispatched via\n      :class:`ExecutionEventDispatcherSync` to update the shared :class:`Execution` object\n      and invoke any user-provided handlers.\n    \"\"\"\n\n    RUN_CODE_PATH = \"/code\"\n    CREATE_CONTEXT_PATH = \"/code/context\"\n\n    def __init__(self, execd_endpoint: SandboxEndpoint, connection_config: ConnectionConfigSync) -> None:\n        \"\"\"\n        Initialize the code service adapter (sync).\n\n        Args:\n            execd_endpoint: Endpoint for execd daemon connection\n            connection_config: Shared connection configuration (transport, headers, timeouts)\n        \"\"\"\n        self.execd_endpoint = execd_endpoint\n        self.connection_config = connection_config\n        from opensandbox.api.execd import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n        headers = {\"User-Agent\": self.connection_config.user_agent, **self.connection_config.headers}\n\n        self._client = Client(base_url=base_url, timeout=timeout)\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n        sse_headers = {**headers, \"Accept\": \"text/event-stream\", \"Cache-Control\": \"no-cache\"}\n        self._sse_client = httpx.Client(\n            headers=sse_headers,\n            timeout=httpx.Timeout(connect=timeout_seconds, read=None, write=timeout_seconds, pool=None),\n            transport=self.connection_config.transport,\n        )\n\n    def _get_execd_url(self, path: str) -> str:\n        \"\"\"Build URL for execd endpoint.\"\"\"\n        return f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    def create_context(self, language: str) -> CodeContextSync:\n        \"\"\"\n        Create a new execution context for code interpretation (sync).\n\n        Uses the generated API client for this non-streaming operation.\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.code_interpreting import create_code_context\n            from opensandbox.api.execd.models.code_context import (\n                CodeContext as ApiCodeContext,\n            )\n            from opensandbox.api.execd.models.code_context_request import (\n                CodeContextRequest,\n            )\n            from opensandbox.api.execd.types import Unset\n\n            response_obj = create_code_context.sync_detailed(\n                client=self._client,\n                body=CodeContextRequest(language=language),\n            )\n            handle_api_error(response_obj, \"Create code context\")\n            parsed = require_parsed(response_obj, ApiCodeContext, \"Create code context\")\n            context_id = None if isinstance(parsed.id, Unset) else parsed.id\n            return CodeContextSync(id=context_id, language=parsed.language)\n        except Exception as e:\n            logger.error(\"Failed to create context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_context(self, context_id: str) -> CodeContextSync:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import get_context\n            from opensandbox.api.execd.models.code_context import (\n                CodeContext as ApiCodeContext,\n            )\n            from opensandbox.api.execd.types import Unset\n\n            response_obj = get_context.sync_detailed(\n                client=self._client,\n                context_id=context_id,\n            )\n            handle_api_error(response_obj, \"Get code context\")\n            parsed = require_parsed(response_obj, ApiCodeContext, \"Get code context\")\n            context_id_val = None if isinstance(parsed.id, Unset) else parsed.id\n            return CodeContextSync(id=context_id_val, language=parsed.language)\n        except Exception as e:\n            logger.error(\"Failed to get context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def list_contexts(self, language: str) -> list[CodeContextSync]:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import list_contexts\n            from opensandbox.api.execd.types import UNSET\n\n            response_obj = list_contexts.sync_detailed(\n                client=self._client,\n                language=language,\n            )\n            handle_api_error(response_obj, \"List code contexts\")\n            parsed_list = require_parsed(response_obj, list, \"List code contexts\")\n            result: list[CodeContextSync] = []\n            for c in parsed_list:\n                # c is an API CodeContext model\n                context_id_val = c.id if c.id is not UNSET else None\n                result.append(CodeContextSync(id=context_id_val, language=c.language))\n            return result\n        except Exception as e:\n            logger.error(\"Failed to list contexts\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def delete_context(self, context_id: str) -> None:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import delete_context\n\n            response_obj = delete_context.sync_detailed(\n                client=self._client,\n                context_id=context_id,\n            )\n            handle_api_error(response_obj, \"Delete code context\")\n        except Exception as e:\n            logger.error(\"Failed to delete context\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def delete_contexts(self, language: str) -> None:\n        try:\n            from opensandbox.api.execd.api.code_interpreting import (\n                delete_contexts_by_language,\n            )\n\n            response_obj = delete_contexts_by_language.sync_detailed(\n                client=self._client,\n                language=language,\n            )\n            handle_api_error(response_obj, \"Delete code contexts by language\")\n        except Exception as e:\n            logger.error(\"Failed to delete contexts\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def run(\n        self,\n        code: str,\n        *,\n        language: str | None = None,\n        context: CodeContextSync | None = None,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution:\n        \"\"\"\n        Execute code within the specified context using SSE streaming (sync).\n\n        Args:\n            code: Source code to execute.\n            context: Execution context (language + optional id). If None, a temporary Python context is used.\n            handlers: Optional streaming handlers for stdout/stderr/events.\n\n        Returns:\n            Execution result populated incrementally while streaming events\n\n        Raises:\n            InvalidArgumentException: if code is empty\n            SandboxApiException: if execd returns a non-200 response\n            SandboxException: for other errors converted by :class:`ExceptionConverter`\n        \"\"\"\n        if not code.strip():\n            raise InvalidArgumentException(\"Code cannot be empty\")\n\n        try:\n            if context is not None and language is not None and context.language != language:\n                raise InvalidArgumentException(\n                    f\"language '{language}' must match context.language '{context.language}'\"\n                )\n\n            if context is None:\n                # Default context: language default context (server-side behavior).\n                # When context.id is omitted, execd will create/reuse a default session per language.\n                context = CodeContextSync(language=language or SupportedLanguageSync.PYTHON)\n            api_request = {\n                \"code\": code,\n                \"context\": {\n                    \"language\": context.language,\n                    **({\"id\": context.id} if context.id else {}),\n                },\n            }\n\n            url = self._get_execd_url(self.RUN_CODE_PATH)\n            execution = Execution(id=None, execution_count=None, result=[], error=None)\n            dispatcher = ExecutionEventDispatcherSync(execution, handlers)\n\n            with self._sse_client.stream(\"POST\", url, json=api_request) as response:\n                if response.status_code != 200:\n                    response.read()\n                    raise SandboxApiException(\n                        message=f\"Failed to run code. Status code: {response.status_code}\",\n                        status_code=response.status_code,\n                        request_id=extract_request_id(response.headers),\n                    )\n\n                for line in response.iter_lines():\n                    if not line or not line.strip():\n                        continue\n                    data = line\n                    if data.startswith(\"data:\"):\n                        data = data[5:].strip()\n                    try:\n                        event_dict = _normalize_sse_event(json.loads(data))\n                        event_node = EventNode(**event_dict)\n                        dispatcher.dispatch(event_node)\n                    except json.JSONDecodeError:\n                        logger.debug(\"Failed to parse SSE line: %s\", line)\n                        continue\n                    except Exception as e:\n                        logger.error(\"Error processing event: %s\", data, exc_info=e)\n                        continue\n\n            return execution\n        except Exception as e:\n            logger.error(\"Failed to run code (length: %s)\", len(code), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupt a currently running code execution.\n\n        Args:\n            execution_id: Execution id returned by execd for the running code execution\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.code_interpreting import interrupt_code\n\n            response_obj = interrupt_code.sync_detailed(client=self._client, id=execution_id)\n            handle_api_error(response_obj, \"Interrupt code execution\")\n        except Exception as e:\n            logger.error(\"Failed to interrupt code execution\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/adapters/factory.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFactory for creating Code Interpreter sync services.\n\"\"\"\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.sync.adapters.code_adapter import CodesAdapterSync\nfrom code_interpreter.sync.services.code import CodesSync\n\n\nclass AdapterFactorySync:\n    \"\"\"\n    Factory for creating Code Interpreter sync service instances.\n\n    This factory centralizes construction of sync services so they all share the same\n    connection configuration (transport, headers, timeouts).\n    \"\"\"\n\n    def __init__(self, connection_config: ConnectionConfigSync) -> None:\n        \"\"\"\n        Initialize the factory with shared connection configuration (sync).\n\n        Args:\n            connection_config: Shared connection configuration (transport, headers, timeouts).\n        \"\"\"\n        self.connection_config = connection_config\n\n    def create_code_execution_service(self, endpoint: SandboxEndpoint) -> CodesSync:\n        \"\"\"\n        Create a code execution service for the specified endpoint (sync).\n\n        Args:\n            endpoint: Sandbox endpoint for code execution services.\n\n        Returns:\n            Configured sync code service instance.\n        \"\"\"\n        return CodesAdapterSync(endpoint, self.connection_config)\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/code_interpreter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous Code Interpreter SDK.\n\"\"\"\n\nimport logging\n\nfrom opensandbox.constants import DEFAULT_EXECD_PORT\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxException,\n    SandboxInternalException,\n)\nfrom opensandbox.sync.sandbox import SandboxSync\n\nfrom code_interpreter.sync.adapters.factory import AdapterFactorySync\nfrom code_interpreter.sync.services.code import CodesSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass CodeInterpreterSync:\n    \"\"\"\n    Synchronous Code Interpreter SDK providing secure, isolated code execution capabilities.\n\n    This class mirrors the async :class:`code_interpreter.code_interpreter.CodeInterpreter`, but all\n    operations are **blocking** and executed in the current thread.\n\n    It wraps an existing :class:`opensandbox.sync.sandbox.SandboxSync` instance and adds\n    code-execution APIs (contexts, run with SSE streaming, interrupts) on top.\n\n    Notes:\n\n    - **Blocking**: Do not call these methods directly from an asyncio event loop thread.\n      If you need non-blocking behavior, prefer the async :class:`~code_interpreter.code_interpreter.CodeInterpreter`.\n    - **Lifecycle**: Remote lifecycle is owned by the underlying sandbox; call methods on\n      ``interpreter.sandbox`` for pause/resume/kill/renew/metrics/info/endpoints.\n\n    Usage Example:\n\n    ```python\n    from opensandbox.sync.sandbox import SandboxSync\n    from code_interpreter.sync.code_interpreter import CodeInterpreterSync\n    from code_interpreter.models.code import SupportedLanguage\n\n    sandbox = SandboxSync.create(\"python:3.11\")\n    interpreter = CodeInterpreterSync.create(sandbox=sandbox)\n\n    ctx = interpreter.codes.create_context(SupportedLanguage.PYTHON)\n    result = interpreter.codes.run(\"print('hi')\", context=ctx)\n\n    sandbox.kill()\n    sandbox.close()\n    ```\n    \"\"\"\n\n    def __init__(self, sandbox: SandboxSync, code_service: CodesSync) -> None:\n        \"\"\"\n        Initialize CodeInterpreterSync with sandbox and code service.\n\n        Note: This constructor is for internal use. Use :meth:`create` instead.\n\n        Args:\n            sandbox: Underlying sandbox instance\n            code_service: Code execution service implementation (sync)\n        \"\"\"\n        self._sandbox = sandbox\n        self._code_service = code_service\n\n    @property\n    def sandbox(self) -> SandboxSync:\n        \"\"\"\n        Provides access to the underlying sandbox instance.\n\n        Returns:\n            The underlying sandbox instance\n        \"\"\"\n        return self._sandbox\n\n    @property\n    def id(self) -> str:\n        \"\"\"\n        Gets the unique identifier of this code interpreter (same as underlying sandbox ID).\n\n        Returns:\n            ID of the code interpreter/sandbox\n        \"\"\"\n        return self._sandbox.id\n\n    @property\n    def files(self):\n        \"\"\"\n        Provides access to file system operations within the sandbox.\n\n        Returns:\n            Service for filesystem manipulation\n        \"\"\"\n        return self._sandbox.files\n\n    @property\n    def commands(self):\n        \"\"\"\n        Provides access to command execution operations.\n\n        Returns:\n            Service for command execution\n        \"\"\"\n        return self._sandbox.commands\n\n    @property\n    def metrics(self):\n        \"\"\"\n        Provides access to sandbox metrics and monitoring.\n\n        Returns:\n            Service for metrics retrieval\n        \"\"\"\n        return self._sandbox.metrics\n\n    @property\n    def codes(self) -> CodesSync:\n        \"\"\"\n        Provides access to code execution operations (sync).\n\n        This service enables:\n        - Multi-language code execution (Python, JavaScript, Bash, etc.)\n        - Execution context management with persistent variables\n        - Real-time output streaming and interruption capabilities\n\n        Returns:\n            Service for advanced code execution with session support\n        \"\"\"\n        return self._code_service\n\n    @classmethod\n    def create(cls, sandbox: SandboxSync) -> \"CodeInterpreterSync\":\n        \"\"\"\n        Create a CodeInterpreterSync from an existing SandboxSync instance (blocking).\n\n        Args:\n            sandbox: Existing sandbox instance to wrap with code execution capabilities\n\n        Returns:\n            CodeInterpreterSync instance wrapping the sandbox\n\n        Raises:\n            InvalidArgumentException: If sandbox is not provided\n            SandboxException: If creation fails\n            SandboxInternalException: If internal service initialization fails\n        \"\"\"\n        if sandbox is None:\n            raise InvalidArgumentException(\"Sandbox instance must be provided\")\n\n        logger.info(\"Creating code interpreter from sandbox: %s\", sandbox.id)\n        factory = AdapterFactorySync(sandbox.connection_config)\n        try:\n            endpoint = sandbox.get_endpoint(DEFAULT_EXECD_PORT)\n            code_service = factory.create_code_execution_service(endpoint)\n            logger.info(\"Code interpreter %s created successfully\", sandbox.id)\n            return cls(sandbox, code_service)\n        except Exception as e:\n            if isinstance(e, SandboxException):\n                raise\n            raise SandboxInternalException(f\"Failed to create code interpreter: {e}\", cause=e) from e\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/services/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous service interfaces (Protocols) for the Code Interpreter sync SDK.\n\nThese interfaces mirror the async interfaces under :mod:`code_interpreter.services`,\nbut are **blocking** and intended for use with :class:`code_interpreter.sync.code_interpreter.CodeInterpreterSync`.\n\"\"\"\n\nfrom code_interpreter.sync.services.code import CodesSync\n\n__all__ = [\n    \"CodesSync\",\n]\n"
  },
  {
    "path": "sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous code execution service interface.\n\nDefines the contract for multi-language code interpretation with context management,\nsession persistence, and real-time execution capabilities (SSE streaming), **in blocking form**.\n\nThis is the sync counterpart of :mod:`code_interpreter.services.code`.\n\"\"\"\n\nfrom typing import Protocol, overload\n\nfrom opensandbox.models.execd import Execution\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\n\nfrom code_interpreter.models.code_sync import CodeContextSync\n\n\nclass CodesSync(Protocol):\n    \"\"\"\n    Code execution service for multi-language code interpretation (sync).\n\n    This service provides advanced code execution capabilities with context management,\n    session persistence, and multi-language support.\n\n    Supported Languages (typical):\n        - Python\n        - JavaScript / TypeScript\n        - Bash\n        - Java\n        - Kotlin (depending on server image)\n\n    Key Features:\n        - Execution Contexts: Isolated environments with persistent state\n        - Variable Persistence: Variables and imports persist across executions in a context\n        - Real-time Interruption: Stop long-running code execution safely\n        - Output Streaming: Real-time stdout/stderr via SSE\n\n    Notes:\n        - All methods are **blocking** and executed in the current thread.\n        - For non-blocking usage, prefer the async :class:`code_interpreter.services.code.Codes`.\n    \"\"\"\n\n    def create_context(self, language: str) -> CodeContextSync:\n        \"\"\"\n        Create a new execution context for code interpretation (blocking).\n\n        An execution context maintains state (variables/imports/working directory) across\n        multiple code executions, enabling interactive sessions.\n\n        Args:\n            language: The programming language for this context (e.g., \"python\", \"typescript\").\n\n        Returns:\n            A new CodeContextSync.\n\n        Raises:\n            SandboxException: If the language is not supported or context creation fails.\n        \"\"\"\n        ...\n\n    def get_context(self, context_id: str) -> CodeContextSync:\n        \"\"\"Get an existing execution context by id (blocking).\"\"\"\n        ...\n\n    def list_contexts(self, language: str) -> list[CodeContextSync]:\n        \"\"\"List active contexts under a given language/runtime (blocking).\"\"\"\n        ...\n\n    def delete_context(self, context_id: str) -> None:\n        \"\"\"Delete an execution context by id (blocking).\"\"\"\n        ...\n\n    def delete_contexts(self, language: str) -> None:\n        \"\"\"Delete all contexts under a language/runtime (blocking).\"\"\"\n        ...\n\n    @overload\n    def run(\n        self,\n        code: str,\n        *,\n        context: CodeContextSync,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution: ...\n\n    @overload\n    def run(\n        self,\n        code: str,\n        *,\n        language: str,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution: ...\n\n    def run(\n        self,\n        code: str,\n        *,\n        language: str | None = None,\n        context: CodeContextSync | None = None,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution:\n        \"\"\"\n        Execute code within the specified context (blocking).\n\n        This method runs the provided code string in the language interpreter, capturing output,\n        errors, and execution metadata. Execution happens within the context's environment,\n        preserving variable state and working directory.\n\n        Execution behavior:\n            - Blocking: The call does not return until the stream finishes.\n            - Stateful: Variables and imports persist in the context.\n            - Streaming: Output is processed incrementally as SSE events arrive.\n            - Interruptible: Can be stopped using :meth:`interrupt`.\n\n        Args:\n            code: Source code to execute.\n            language: Convenience language selector for this run. If provided and ``context`` is None,\n                a **default context for this language** is used (execd will create/reuse a default\n                session when ``context.id`` is omitted). If both ``language`` and ``context`` are\n                provided, they must match.\n            context: Execution context (language + optional id). If None, the default Python context is used.\n            handlers: Optional streaming handlers for stdout/stderr/events.\n\n        Returns:\n            Execution with stdout/stderr/events and execution metadata.\n\n        Raises:\n            SandboxException: If execution fails or times out.\n        \"\"\"\n        ...\n\n    def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupt a currently running code execution.\n\n        This method attempts to safely terminate a running execution, cleaning up resources and\n        keeping the interpreter in a consistent state.\n\n        Args:\n            execution_id: The unique identifier of the execution to interrupt.\n\n        Raises:\n            SandboxException: If interruption fails.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/code-interpreter/python/tests/test_adapter_eager_init.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nimport pytest\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.adapters.code_adapter import CodesAdapter\n\n\n@pytest.mark.asyncio\nasync def test_code_service_eager_init_and_client_available() -> None:\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    client = await adapter._get_client()\n    assert client is not None\n"
  },
  {
    "path": "sdks/code-interpreter/python/tests/test_code_interpreter_create_and_delegation.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nimport pytest\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter import CodeInterpreter\n\n\nclass _FakeSandbox:\n    def __init__(self) -> None:\n        self._id = str(__import__(\"uuid\").uuid4())\n        self.connection_config = ConnectionConfig(protocol=\"http\")\n        self.files = object()\n        self.commands = object()\n        self.metrics = object()\n\n    @property\n    def id(self):\n        return self._id\n\n    async def get_endpoint(self, port: int) -> SandboxEndpoint:\n        return SandboxEndpoint(endpoint=\"localhost:44772\", port=port)\n\n    async def is_healthy(self) -> bool:\n        return True\n\n    async def get_info(self):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    async def get_metrics(self):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    async def renew(self, timeout):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n\n@pytest.mark.asyncio\nasync def test_create_requires_sandbox() -> None:\n    with pytest.raises(InvalidArgumentException):\n        await CodeInterpreter.create(sandbox=None)  # type: ignore[arg-type]\n\n\n@pytest.mark.asyncio\nasync def test_create_wires_code_service_and_delegates_properties() -> None:\n    sbx = _FakeSandbox()\n    ci = await CodeInterpreter.create(sandbox=sbx)  # type: ignore[arg-type]\n\n    assert ci.id == sbx.id\n    assert ci.files is sbx.files\n    assert ci.commands is sbx.commands\n    assert ci.metrics is sbx.metrics\n\n    # codes service should be present and callable (no network)\n    assert ci.codes is not None\n"
  },
  {
    "path": "sdks/code-interpreter/python/tests/test_code_service_adapter_openapi_calls.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nimport pytest\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.adapters.code_adapter import CodesAdapter\n\n\nclass _Resp:\n    def __init__(self, *, status_code: int, parsed) -> None:\n        self.status_code = status_code\n        self.parsed = parsed\n\n\n@pytest.mark.asyncio\nasync def test_create_context_uses_openapi_and_converts(monkeypatch: pytest.MonkeyPatch) -> None:\n    from opensandbox.api.execd.models.code_context import CodeContext as ApiCodeContext\n\n    async def _fake_asyncio_detailed(*, client, body):\n        assert body.language == \"python\"\n        return _Resp(status_code=200, parsed=ApiCodeContext(language=\"python\", id=\"ctx-1\"))\n\n    monkeypatch.setattr(\n        \"opensandbox.api.execd.api.code_interpreting.create_code_context.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = CodesAdapter(\n        SandboxEndpoint(endpoint=\"localhost:44772\", port=44772),\n        ConnectionConfig(protocol=\"http\"),\n    )\n    ctx = await adapter.create_context(\"python\")\n    assert ctx.id == \"ctx-1\"\n    assert ctx.language == \"python\"\n\n\n@pytest.mark.asyncio\nasync def test_interrupt_calls_openapi(monkeypatch: pytest.MonkeyPatch) -> None:\n    called = {\"id\": None}\n\n    async def _fake_asyncio_detailed(*, client, id):\n        called[\"id\"] = id\n        return _Resp(status_code=204, parsed=None)\n\n    monkeypatch.setattr(\n        \"opensandbox.api.execd.api.code_interpreting.interrupt_code.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = CodesAdapter(\n        SandboxEndpoint(endpoint=\"localhost:44772\", port=44772),\n        ConnectionConfig(protocol=\"http\"),\n    )\n    await adapter.interrupt(\"exec-1\")\n    assert called[\"id\"] == \"exec-1\"\n"
  },
  {
    "path": "sdks/code-interpreter/python/tests/test_code_service_adapter_streaming.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nimport json\n\nimport httpx\nimport pytest\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\nfrom code_interpreter.adapters.code_adapter import CodesAdapter\nfrom code_interpreter.adapters.converter.code_execution_converter import (\n    CodeExecutionConverter,\n)\nfrom code_interpreter.models.code import CodeContext, SupportedLanguage\n\n\nclass _SseTransport(httpx.AsyncBaseTransport):\n    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:\n        body = request.content.decode(\"utf-8\") if isinstance(request.content, (bytes, bytearray)) else \"\"\n        payload = json.loads(body) if body else {}\n\n        if request.url.path == \"/code\" and payload.get(\"code\") == \"print(1)\":\n            sse = (\n                b'data: {\"type\":\"init\",\"text\":\"exec-1\",\"timestamp\":1}\\n\\n'\n                b'data: {\"type\":\"stdout\",\"text\":\"1\\\\n\",\"timestamp\":2}\\n\\n'\n                b'data: {\"type\":\"execution_complete\",\"timestamp\":3,\"execution_time\":7}\\n\\n'\n            )\n            return httpx.Response(200, headers={\"Content-Type\": \"text/event-stream\"}, content=sse, request=request)\n\n        if request.url.path == \"/code\" and payload.get(\"code\") == \"print(2)\":\n            assert payload[\"context\"][\"language\"] == \"go\"\n            sse = (\n                b'data: {\"type\":\"init\",\"text\":\"exec-2\",\"timestamp\":1}\\n\\n'\n                b'data: {\"type\":\"stdout\",\"text\":\"2\\\\n\",\"timestamp\":2}\\n\\n'\n                b'data: {\"type\":\"execution_complete\",\"timestamp\":3,\"execution_time\":7}\\n\\n'\n            )\n            return httpx.Response(200, headers={\"Content-Type\": \"text/event-stream\"}, content=sse, request=request)\n\n        return httpx.Response(\n            400,\n            headers={\"x-request-id\": \"req-code-123\"},\n            content=b\"bad\",\n            request=request,\n        )\n\n\ndef test_code_execution_converter_includes_context() -> None:\n    ctx = CodeContext(id=\"c1\", language=SupportedLanguage.PYTHON)\n    d = CodeExecutionConverter.to_api_run_code_request(\"print(1)\", ctx)\n    assert d[\"code\"] == \"print(1)\"\n    assert d[\"context\"][\"id\"] == \"c1\"\n    assert d[\"context\"][\"language\"] == \"python\"\n\n\n@pytest.mark.asyncio\nasync def test_run_code_streaming_happy_path_updates_execution() -> None:\n    cfg = ConnectionConfig(protocol=\"http\", transport=_SseTransport())\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    execution = await adapter.run(\"print(1)\")\n    assert execution.id == \"exec-1\"\n    assert execution.logs.stdout[0].text == \"1\\n\"\n\n\n@pytest.mark.asyncio\nasync def test_run_code_can_accept_language_string_without_context() -> None:\n    cfg = ConnectionConfig(protocol=\"http\", transport=_SseTransport())\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    execution = await adapter.run(\"print(2)\", language=SupportedLanguage.GO)\n    assert execution.id == \"exec-2\"\n    assert execution.logs.stdout[0].text == \"2\\n\"\n\n\n@pytest.mark.asyncio\nasync def test_run_code_rejects_blank_code() -> None:\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    with pytest.raises(InvalidArgumentException):\n        await adapter.run(\"   \")\n\n\n@pytest.mark.asyncio\nasync def test_run_code_rejects_mismatched_language_and_context() -> None:\n    cfg = ConnectionConfig(protocol=\"http\", transport=_SseTransport())\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    with pytest.raises(InvalidArgumentException):\n        await adapter.run(\n            \"print(1)\",\n            context=CodeContext(language=SupportedLanguage.PYTHON),\n            language=SupportedLanguage.GO,\n        )\n\n\n@pytest.mark.asyncio\nasync def test_run_code_non_200_raises_api_exception() -> None:\n    cfg = ConnectionConfig(protocol=\"http\", transport=_SseTransport())\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CodesAdapter(endpoint, cfg)\n\n    with pytest.raises(SandboxApiException) as ei:\n        await adapter.run(\"other\")\n    assert ei.value.request_id == \"req-code-123\"\n"
  },
  {
    "path": "sdks/eslint.base.mjs",
    "content": "import js from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\nimport globals from \"globals\";\n\nexport function createBaseConfig({\n  tsconfigRootDir,\n  tsconfigPath = \"./tsconfig.json\",\n  extraIgnores = [],\n  includeScripts = false,\n  scriptGlobs = [\"scripts/**/*.{js,mjs,cjs}\"],\n} = {}) {\n  const ignores = [\"dist/**\", \"node_modules/**\", \"coverage/**\", ...extraIgnores];\n\n  const configs = [\n    { ignores },\n    js.configs.recommended,\n    ...tseslint.configs.recommended,\n    {\n      files: [\"src/**/*.{ts,mts,cts}\"],\n      languageOptions: {\n        globals: {\n          ...globals.nodeBuiltin,\n          ...globals.node,\n        },\n        parserOptions: {\n          project: [tsconfigPath],\n          tsconfigRootDir,\n        },\n      },\n      extends: [\n        ...tseslint.configs.stylisticTypeChecked,\n      ],\n      rules: {\n        \"@typescript-eslint/no-explicit-any\": \"off\",\n        \"@typescript-eslint/no-unused-vars\": [\n          \"error\",\n          { argsIgnorePattern: \"^_\", varsIgnorePattern: \"^_\" },\n        ],\n        \"no-console\": \"warn\",\n        \"no-debugger\": \"error\",\n        \"no-constant-condition\": \"warn\",\n      },\n    },\n  ];\n\n  if (includeScripts) {\n    configs.push({\n      files: scriptGlobs,\n      languageOptions: {\n        globals: {\n          ...globals.nodeBuiltin,\n          ...globals.node,\n        },\n      },\n      rules: {\n        \"no-console\": \"off\",\n      },\n    });\n  }\n\n  return tseslint.config(...configs);\n}\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/README.md",
    "content": "# OpenSandbox MCP Sandbox Server\n\n## 1. Overview\n\nOpenSandbox MCP Server exposes the OpenSandbox Python SDK as MCP tools for\nClaude Code, Cursor, and other MCP-capable clients. It provides focused\nsandbox lifecycle management, command execution, and text file operations.\n\n## 2. Installation & Startup\n\n### Source\n\n```bash\nuv sync\nuv run opensandbox-mcp\n```\n\n### Package\n\n```bash\npip install opensandbox-mcp\nopensandbox-mcp\n```\n\n### Configuration\n\nEnvironment variables:\n\n- `OPEN_SANDBOX_API_KEY`\n- `OPEN_SANDBOX_DOMAIN`\n\nCLI overrides:\n\n```bash\nopensandbox-mcp --api-key ... --domain ... --protocol https\n```\n\nConfig fields:\n\n- `api_key`: OpenSandbox API key for authentication.\n- `domain`: OpenSandbox API domain, for example `api.opensandbox.io`.\n- `protocol`: `http` or `https` for API requests.\n- `request_timeout_seconds`: HTTP request timeout in seconds.\n- `transport`: `stdio` by default, or `streamable-http`.\n\n### Streamable HTTP\n\n```bash\nopensandbox-mcp \\\n  --transport streamable-http\n```\n\n## 3. Integrations\n\n### Claude Code stdio\n\n```bash\nclaude mcp add opensandbox-sandbox --transport stdio -- \\\n  opensandbox-mcp --api-key \"$OPEN_SANDBOX_API_KEY\" --domain \"$OPEN_SANDBOX_DOMAIN\"\n```\n\n### Claude Code http\n\n```bash\nclaude mcp add opensandbox-sandbox --transport http http://localhost:8000/mcp\n```\n\n### Cursor stdio\n\n```json\n{\n  \"mcpServers\": {\n    \"opensandbox-sandbox\": {\n      \"command\": \"opensandbox-mcp\",\n      \"args\": [\n        \"--api-key\",\n        \"${OPEN_SANDBOX_API_KEY}\",\n        \"--domain\",\n        \"${OPEN_SANDBOX_DOMAIN}\"\n      ]\n    }\n  }\n}\n```\n\n### Cursor http\n\n```json\n{\n  \"mcpServers\": {\n    \"opensandbox-sandbox\": {\n      \"url\": \"http://localhost:8000/mcp\"\n    }\n  }\n}\n```\n\n## 4. Tools\n\nNotes:\n\n- All tools operate on a `sandbox_id` returned by `sandbox_create` or `sandbox_connect`.\n- `file_read`/`file_write` are text-only; use `encoding` and `range_header` for large files.\n\n### Sandbox\n\n- `sandbox_create`: create a new sandbox and register it locally\n- `sandbox_connect`: attach to an existing sandbox and register it locally\n- `sandbox_kill`: terminate a sandbox by ID\n- `sandbox_get_info`: fetch sandbox info by ID\n- `sandbox_list`: list sandboxes with optional `filter` object\n- `sandbox_renew`: extend sandbox expiration\n- `sandbox_healthcheck`: check if sandbox is healthy\n- `sandbox_get_metrics`: get resource metrics\n- `sandbox_get_endpoint`: get network endpoint for a port\n\n### Command Execution\n\n- `command_run`: run a command inside a sandbox\n- `command_interrupt`: interrupt a running command\n\n### Filesystem\n\n- `file_read`: read a text file\n- `file_write`: write a text file\n- `file_delete`: delete files\n- `file_search`: search for files by glob\n- `file_create_directories`: create directories\n- `file_delete_directories`: delete directories\n- `file_move`: move/rename files or directories\n- `file_replace_contents`: replace file content\n\n## 5. Minimal Workflow\n\n1. `sandbox_create` -> keep the `sandbox_id`.\n2. `file_write` code or assets into the sandbox.\n3. `command_run` to execute, install dependencies, or start a service.\n4. `sandbox_get_endpoint` if you expose a port.\n5. `sandbox_kill` when finished.\n\n## 6. Usage Examples\n\nHere are some examples of what you can ask an LLM to do:\n\n- \"Create a Python sandbox and run a quick health command.\"\n- \"Write a Python script into the sandbox and run it.\"\n- \"Download a GitHub repo, install dependencies, and run its tests.\"\n- \"Generate a CSV file with fake sales data and run a simple summary script.\"\n- \"Start a tiny web server on port 8000 and return the public URL.\"\n- \"Build a minimal REST API (hello + health) and expose it on port 8000.\"\n- \"Create a tar.gz of /app and report the file size.\"\n- \"Build a simple Snake game and return the web endpoint where it can be accessed.\"\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/README_zh.md",
    "content": "# OpenSandbox MCP 沙箱服务（Python）\n\n## 1. 简介\n\nOpenSandbox MCP Server 将 OpenSandbox Python SDK 以 MCP 工具形式暴露给\nClaude Code、Cursor 等客户端，提供精简的沙箱生命周期、命令执行与文本文件操作能力。\n\n## 2. 安装和启动\n\n### 源码方式（本地开发）\n\n```bash\nuv sync\nuv run opensandbox-mcp\n```\n\n### 下载包方式\n\n```bash\npip install opensandbox-mcp\nopensandbox-mcp\n```\n\n### 配置\n\n环境变量：\n\n- `OPEN_SANDBOX_API_KEY`\n- `OPEN_SANDBOX_DOMAIN`\n\nCLI 覆盖：\n\n```bash\nopensandbox-mcp --api-key ... --domain ... --protocol https\n```\n\n配置项说明：\n\n- `api_key`：OpenSandbox API Key（鉴权）。\n- `domain`：OpenSandbox API 域名（如 `api.opensandbox.io`）。\n- `protocol`：`http` 或 `https`。\n- `request_timeout_seconds`：HTTP 请求超时（秒）。\n- `transport`：`stdio`（默认）或 `streamable-http`。\n\n### Streamable HTTP\n\n```bash\nopensandbox-mcp \\\n  --transport streamable-http\n```\n\n## 3. 集成案例\n\n### Claude Code stdio\n\n```bash\nclaude mcp add opensandbox-sandbox --transport stdio -- \\\n  opensandbox-mcp --api-key \"$OPEN_SANDBOX_API_KEY\" --domain \"$OPEN_SANDBOX_DOMAIN\"\n```\n\n### Claude Code http\n\n```bash\nclaude mcp add opensandbox-sandbox --transport http http://localhost:8000/mcp\n```\n\n### Cursor stdio\n\n```json\n{\n  \"mcpServers\": {\n    \"opensandbox-sandbox\": {\n      \"command\": \"opensandbox-mcp\",\n      \"args\": [\n        \"--api-key\",\n        \"${OPEN_SANDBOX_API_KEY}\",\n        \"--domain\",\n        \"${OPEN_SANDBOX_DOMAIN}\"\n      ]\n    }\n  }\n}\n```\n\n### Cursor http\n\n```json\n{\n  \"mcpServers\": {\n    \"opensandbox-sandbox\": {\n      \"url\": \"http://localhost:8000/mcp\"\n    }\n  }\n}\n```\n\n## 4. 工具描述\n\n说明：\n\n- 所有工具均使用 `sandbox_create` / `sandbox_connect` 返回的 `sandbox_id`。\n- `file_read` / `file_write` 仅支持文本文件；大文件可用 `encoding` 和 `range_header`。\n\n### Sandbox 生命周期\n\n- `sandbox_create`: 创建沙箱并注册到本地会话\n- `sandbox_connect`: 连接已有沙箱并注册到本地会话\n- `sandbox_get_info`: 获取沙箱信息\n- `sandbox_list`: 使用 `filter` 列出沙箱\n- `sandbox_renew`: 续期\n- `sandbox_get_metrics`: 资源指标\n- `sandbox_healthcheck`: 沙箱健康检查\n- `sandbox_kill`: 终止沙箱\n- `sandbox_get_endpoint`: 获取指定端口的访问地址\n\n### 命令执行\n\n- `command_run`: 在沙箱内执行命令\n- `command_interrupt`: 中断命令\n\n### 文件系统\n\n- `file_read`: 读取文本文件\n- `file_write`: 写文本文件\n- `file_delete`: 删除文件\n- `file_search`: 按 glob 搜索\n- `file_create_directories`: 创建目录\n- `file_delete_directories`: 删除目录\n- `file_move`: 移动/重命名\n- `file_replace_contents`: 替换文件内容\n\n## 5. 最小流程\n\n1. `sandbox_create` -> 记录 `sandbox_id`。\n2. `file_write` 写入代码或资源。\n3. `command_run` 执行、安装依赖或启动服务。\n4. 对外暴露端口时使用 `sandbox_get_endpoint`。\n5. 完成后 `sandbox_kill`。\n\n## 6. 使用案例\n\n下面是一些你可以让 LLM 完成的指令示例：\n\n- \"创建一个 Python 沙箱并执行健康检查命令。\"\n- \"把一段 Python 脚本写入沙箱并执行。\"\n- \"下载一个 GitHub 仓库，安装依赖并运行测试。\"\n- \"生成一份销售数据 CSV，并运行简单统计脚本。\"\n- \"启动一个 8000 端口的 Web 服务并返回公网链接。\"\n- \"搭一个最小 REST API（hello + health）并对外暴露。\"\n- \"把 /app 打包成 tar.gz 并报告文件大小。\"\n- \"实现一个贪吃蛇小游戏，并且返回可访问的web链接\"\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/pyproject.toml",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"opensandbox-mcp\"\ndynamic = [\"version\"]\ndescription = \"OpenSandbox MCP Sandbox Server (Python)\"\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"ninan.nn@alibaba-inc.com\" }\n]\nlicense = { file = \"LICENSE\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nkeywords = [\"sandbox\", \"mcp\", \"sdk\", \"opensandbox\", \"server\"]\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Software Development :: Libraries\",\n    \"Typing :: Typed\",\n]\ndependencies = [\n    \"mcp[cli]\",\n    \"opensandbox>=0.1.4,<0.2.0\",\n]\n\n[project.urls]\nHomepage = \"https://open-sandbox.ai\"\nRepository = \"https://github.com/alibaba/OpenSandbox\"\nDocumentation = \"https://open-sandbox.ai\"\nIssues = \"https://github.com/alibaba/OpenSandbox/issues\"\n\n[project.scripts]\nopensandbox-mcp = \"opensandbox_mcp.__main__:main\"\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.hatch.version.raw-options]\n# This package is in a subdirectory; explicitly point setuptools-scm at the git root.\nroot = \"../../../..\"\ntag_regex = \"^python/mcp/sandbox/v(?P<version>\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[\\\\.\\\\w\\\\+\\\\-]*)?)$\"\ngit_describe_command = 'git describe --dirty --tags --long --match \"python/mcp/sandbox/v*\"'\nfallback_version = \"0.1.0\"\n\n[tool.hatch.build]\ninclude = [\n    \"LICENSE\",\n    \"src/**/py.typed\",\n    \"src/opensandbox_mcp\",\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/opensandbox_mcp\"]\n\n[dependency-groups]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"pytest-cov>=4.0.0\",\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.0\",\n]\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 88\n\n[tool.ruff.lint]\nselect = [\n    \"E\",  # pycodestyle errors\n    \"W\",  # pycodestyle warnings\n    \"F\",  # pyflakes\n    \"I\",  # isort\n    \"B\",  # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"UP\", # pyupgrade\n]\nignore = [\n    \"E501\", # line too long, handled by formatter\n    \"B008\", # do not perform function calls in argument defaults\n    \"C901\", # too complex\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.10\"\npythonPlatform = \"All\"\n\ninclude = [\"src\"]\n\nexclude = [\n    \"**/node_modules\",\n    \"**/__pycache__\",\n]\n\nvenvPath = \".\"\nvenv = \".venv\"\n\nreportMissingImports = true\nreportMissingTypeStubs = false\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = \"-ra -q --strict-markers --strict-config\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\", \"*_test.py\"]\nasyncio_mode = \"auto\"\n\n[tool.coverage.run]\nsource = [\"src\"]\nbranch = true\n\n[tool.uv.sources]\nopensandbox = { path = \"../../../sandbox/python\", editable = true }\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/src/opensandbox_mcp/__init__.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom importlib.metadata import PackageNotFoundError\nfrom importlib.metadata import version as _pkg_version\n\nfrom opensandbox_mcp.server import create_server\n\ntry:\n    __version__ = _pkg_version(\"opensandbox-mcp\")\nexcept PackageNotFoundError:  # pragma: no cover\n    __version__ = \"0.0.0\"\n\n__all__ = [\"create_server\"]\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/src/opensandbox_mcp/__main__.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom __future__ import annotations\n\nimport argparse\nfrom datetime import timedelta\n\nfrom opensandbox.config import ConnectionConfig\n\nfrom opensandbox_mcp.server import create_server\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        description=\"OpenSandbox MCP Sandbox server entrypoint.\"\n    )\n    parser.add_argument(\n        \"--transport\",\n        choices=(\"stdio\", \"streamable-http\"),\n        default=\"stdio\",\n        help=\"Transport to use. Default uses the MCP SDK default.\",\n    )\n    parser.add_argument(\n        \"--api-key\",\n        default=None,\n        help=\"OpenSandbox API key (overrides OPEN_SANDBOX_API_KEY).\",\n    )\n    parser.add_argument(\n        \"--domain\",\n        default=None,\n        help=\"OpenSandbox API domain (overrides OPEN_SANDBOX_DOMAIN).\",\n    )\n    parser.add_argument(\n        \"--protocol\",\n        choices=(\"http\", \"https\"),\n        default=\"http\",\n        help=\"Protocol to use for API requests.\",\n    )\n    parser.add_argument(\n        \"--request-timeout-seconds\",\n        type=float,\n        default=30,\n        help=\"HTTP request timeout in seconds.\",\n    )\n\n    args = parser.parse_args()\n    config_values = {}\n    if args.api_key:\n        config_values[\"api_key\"] = args.api_key\n    if args.domain:\n        config_values[\"domain\"] = args.domain\n    if args.protocol:\n        config_values[\"protocol\"] = args.protocol\n    if args.request_timeout_seconds is not None:\n        config_values[\"request_timeout\"] = timedelta(\n            seconds=args.request_timeout_seconds\n        )\n    connection_config = ConnectionConfig(**config_values) if config_values else None\n    mcp = create_server(connection_config=connection_config)\n\n    if args.transport == \"streamable-http\":\n        mcp.run(\n            transport=\"streamable-http\"\n        )\n        return\n\n    if args.transport == \"stdio\":\n        mcp.run(transport=\"stdio\")\n        return\n\n    mcp.run()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/src/opensandbox_mcp/py.typed",
    "content": "\n"
  },
  {
    "path": "sdks/mcp/sandbox/python/src/opensandbox_mcp/server.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom dataclasses import dataclass, field\nfrom datetime import timedelta\n\nfrom mcp.server.fastmcp import Context, FastMCP\nfrom mcp.server.session import ServerSession\nfrom opensandbox import Sandbox, SandboxManager\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.execd import Execution, RunCommandOpts\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    PagedSandboxInfos,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageAuth,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxMetrics,\n    SandboxRenewResponse,\n)\nfrom pydantic import BaseModel, Field\n\n\n@dataclass\nclass ServerState:\n    sandboxes: dict[str, Sandbox] = field(default_factory=dict)\n    connection_config: ConnectionConfig = field(default_factory=ConnectionConfig)\n    lock: asyncio.Lock = field(default_factory=asyncio.Lock)\n\n    async def add(self, sandbox: Sandbox) -> None:\n        async with self.lock:\n            self.sandboxes[sandbox.id] = sandbox\n\n    async def get(self, sandbox_id: str) -> Sandbox | None:\n        async with self.lock:\n            return self.sandboxes.get(sandbox_id)\n\n    async def remove(self, sandbox_id: str) -> Sandbox | None:\n        async with self.lock:\n            return self.sandboxes.pop(sandbox_id, None)\n\n\nclass StatusResponse(BaseModel):\n    status: str = Field(description=\"Operation status string.\")\n\nclass DirectoryEntryInput(BaseModel):\n    path: str = Field(description=\"Directory path.\")\n    mode: int = Field(default=755, description=\"Unix permissions for the directory.\")\n    owner: str | None = Field(default=None, description=\"Owner username.\")\n    group: str | None = Field(default=None, description=\"Group name.\")\n\nclass SandboxInfoResponse(BaseModel):\n    sandbox_id: str = Field(description=\"Sandbox identifier.\")\n    info: SandboxInfo = Field(description=\"Sandbox info payload.\")\n\nclass SandboxHealthResponse(BaseModel):\n    sandbox_id: str = Field(description=\"Sandbox identifier.\")\n    healthy: bool = Field(description=\"Sandbox health status.\")\n\nclass FileReadResponse(BaseModel):\n    path: str = Field(description=\"File path.\")\n    content: str = Field(description=\"File content.\")\n\n\ndef register_tools(\n    mcp: FastMCP,\n    *,\n    prefix: str = \"\",\n    state: ServerState | None = None,\n    connection_config: ConnectionConfig | None = None,\n) -> ServerState:\n    \"\"\"Register sandbox tools on a FastMCP instance.\"\"\"\n    config = (connection_config or ConnectionConfig()).with_transport_if_missing()\n    state = state or ServerState(connection_config=config)\n    name_prefix = f\"{prefix}_\" if prefix else \"\"\n\n    def tool():\n        def decorator(func):\n            if name_prefix:\n                func.__name__ = f\"{name_prefix}{func.__name__}\"\n            return mcp.tool()(func)\n\n        return decorator\n\n    async def _get_or_connect_sandbox(\n        sandbox_id: str,\n        *,\n        connect_if_missing: bool,\n    ) -> Sandbox:\n        sandbox = await state.get(sandbox_id)\n        if sandbox is not None:\n            return sandbox\n        if not connect_if_missing:\n            raise ValueError(\n                \"Sandbox not found in local registry. Call sandbox_connect or \"\n                \"set connect_if_missing=True with connection parameters.\"\n            )\n        sandbox = await Sandbox.connect(\n            sandbox_id, connection_config=state.connection_config\n        )\n        await state.add(sandbox)\n        return sandbox\n\n    @tool()\n    async def sandbox_create(\n        image: str,\n        ctx: Context[ServerSession, None] | None = None,\n        *,\n        auth_username: str | None = None,\n        auth_password: str | None = None,\n        timeout_seconds: float = 600,\n        ready_timeout_seconds: float = 30,\n        health_check_polling_interval_ms: int = 200,\n        skip_health_check: bool = False,\n        env: dict[str, str] | None = None,\n        metadata: dict[str, str] | None = None,\n        resource: dict[str, str] | None = None,\n        network_policy: NetworkPolicy | None = None,\n        extensions: dict[str, str] | None = None,\n        entrypoint: list[str] | None = None,\n    ) -> SandboxInfoResponse:\n        \"\"\"Create a sandbox and store it in the MCP server session.\n\n        This allocates a new sandbox instance using the OpenSandbox API and\n        tracks it locally so subsequent tool calls can reuse it.\n\n        Parameters:\n            image: Container image reference (e.g., \"python:3.11\").\n            ctx: MCP context for progress reporting (optional).\n            auth_username: Registry username for private images.\n            auth_password: Registry password/token for private images.\n            timeout_seconds: Sandbox lifetime in seconds (absolute TTL).\n            ready_timeout_seconds: Max time to wait for readiness checks.\n            health_check_polling_interval_ms: Interval between health checks in ms.\n            skip_health_check: If True, return before readiness checks complete.\n            env: Environment variables for the sandbox.\n            metadata: Custom metadata for the sandbox (string map).\n            resource: Resource limits (cpu/memory/etc.) as string map.\n            network_policy: Optional egress network policy (NetworkPolicy model).\n                Example: NetworkPolicy(\n                    default_action=\"deny\",\n                    egress=[{\"action\": \"allow\", \"target\": \"pypi.org\"}],\n                )\n            extensions: Opaque extension parameters passed through to the server.\n            entrypoint: Entrypoint command list.\n\n        Returns:\n            A dict with:\n                sandbox_id: The new sandbox identifier.\n                info: Sandbox info payload from the SDK.\n\n        Raises:\n            ValueError: If auth_username/auth_password are incomplete.\n            Exception: If sandbox creation fails.\n\n        Example:\n            result = await sandbox_create(\n                image=\"python:3.11\",\n                env={\"PYTHONPATH\": \"/app\"},\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n            )\n        \"\"\"\n        if ctx:\n            await ctx.report_progress(progress=0.1, total=1.0, message=\"Validating input\")\n        image_auth = None\n        if auth_username or auth_password:\n            if not auth_username or not auth_password:\n                raise ValueError(\"auth_username and auth_password must be provided together\")\n            image_auth = SandboxImageAuth(\n                username=auth_username,\n                password=auth_password,\n            )\n        image_spec = SandboxImageSpec(image=image, auth=image_auth)\n        if ctx:\n            await ctx.report_progress(\n                progress=0.3, total=1.0, message=\"Creating sandbox\"\n            )\n        sandbox = await Sandbox.create(\n            image_spec,\n            timeout=timedelta(seconds=timeout_seconds),\n            ready_timeout=timedelta(seconds=ready_timeout_seconds),\n            env=env,\n            metadata=metadata,\n            resource=resource,\n            network_policy=network_policy,\n            extensions=extensions,\n            entrypoint=entrypoint,\n            health_check_polling_interval=timedelta(\n                milliseconds=health_check_polling_interval_ms\n            ),\n            skip_health_check=skip_health_check,\n            connection_config=state.connection_config,\n        )\n        await state.add(sandbox)\n        if ctx:\n            await ctx.report_progress(\n                progress=0.8, total=1.0, message=\"Fetching sandbox info\"\n            )\n        info = await sandbox.get_info()\n        if ctx:\n            await ctx.report_progress(progress=1.0, total=1.0, message=\"Done\")\n        return SandboxInfoResponse(sandbox_id=sandbox.id, info=info)\n\n    @tool()\n    async def sandbox_connect(\n        sandbox_id: str,\n        *,\n        connect_timeout_seconds: float = 30,\n        health_check_polling_interval_ms: int = 200,\n        skip_health_check: bool = False,\n    ) -> SandboxInfoResponse:\n        \"\"\"Connect to an existing sandbox and store it locally.\n\n        Use this when a sandbox already exists and you want to use it in this\n        MCP server session without creating a new one.\n\n        Parameters:\n            sandbox_id: Existing sandbox identifier.\n            connect_timeout_seconds: Max time to wait for readiness.\n            health_check_polling_interval_ms: Interval between health checks in ms.\n            skip_health_check: If True, return before readiness checks complete.\n\n        Returns:\n            A dict with:\n                sandbox_id: The sandbox identifier.\n                info: Sandbox info payload from the SDK.\n\n        Example:\n            result = await sandbox_connect(sandbox_id=\"sbx_123\")\n        \"\"\"\n        sandbox = await Sandbox.connect(\n            sandbox_id,\n            connection_config=state.connection_config,\n            connect_timeout=timedelta(seconds=connect_timeout_seconds),\n            health_check_polling_interval=timedelta(\n                milliseconds=health_check_polling_interval_ms\n            ),\n            skip_health_check=skip_health_check,\n        )\n        await state.add(sandbox)\n        info = await sandbox.get_info()\n        return SandboxInfoResponse(sandbox_id=sandbox.id, info=info)\n\n    @tool()\n    async def sandbox_kill(\n        sandbox_id: str,\n    ) -> StatusResponse:\n        \"\"\"Terminate a sandbox by ID and remove it from local registry.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n\n        Returns:\n            {\"status\": \"killed\"} when successful.\n        \"\"\"\n        sandbox = await state.remove(sandbox_id)\n        if sandbox is None:\n            manager = await SandboxManager.create(\n                connection_config=state.connection_config\n            )\n            try:\n                await manager.kill_sandbox(sandbox_id)\n            finally:\n                await manager.close()\n        else:\n            try:\n                await sandbox.kill()\n            finally:\n                await sandbox.close()\n        return StatusResponse(status=\"killed\")\n\n    @tool()\n    async def sandbox_get_info(\n        sandbox_id: str,\n    ) -> SandboxInfo:\n        \"\"\"Fetch sandbox info by ID.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n\n        Returns:\n            Sandbox info dict from the SDK.\n        \"\"\"\n        sandbox = await state.get(sandbox_id)\n        if sandbox is not None:\n            return await sandbox.get_info()\n        manager = await SandboxManager.create(\n            connection_config=state.connection_config\n        )\n        try:\n            info = await manager.get_sandbox_info(sandbox_id)\n        finally:\n            await manager.close()\n        return info\n\n    @tool()\n    async def sandbox_list(\n        ctx: Context[ServerSession, None] | None = None,\n        *,\n        filter: SandboxFilter | None = None,\n    ) -> PagedSandboxInfos:\n        \"\"\"List sandboxes matching filter criteria.\n\n        Parameters:\n            ctx: MCP context for progress reporting (optional).\n            filter: SandboxFilter object (states, metadata, page, page_size).\n\n        Returns:\n            Paginated sandbox list.\n        \"\"\"\n        if ctx:\n            await ctx.report_progress(progress=0.1, total=1.0, message=\"Listing sandboxes\")\n        filter = filter or SandboxFilter()\n        manager = await SandboxManager.create(\n            connection_config=state.connection_config\n        )\n        try:\n            result = await manager.list_sandbox_infos(filter)\n        finally:\n            await manager.close()\n        if ctx:\n            await ctx.report_progress(progress=1.0, total=1.0, message=\"Done\")\n        return result\n\n    @tool()\n    async def sandbox_renew(\n        sandbox_id: str,\n        *,\n        timeout_seconds: float,\n    ) -> SandboxRenewResponse:\n        \"\"\"Renew sandbox expiration time.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            timeout_seconds: Additional lifetime in seconds.\n\n        Returns:\n            Renew response dict including new expiration time.\n        \"\"\"\n        sandbox = await state.get(sandbox_id)\n        if sandbox is None:\n            manager = await SandboxManager.create(\n                connection_config=state.connection_config\n            )\n            try:\n                response = await manager.renew_sandbox(\n                    sandbox_id, timedelta(seconds=timeout_seconds)\n                )\n            finally:\n                await manager.close()\n        else:\n            response = await sandbox.renew(timedelta(seconds=timeout_seconds))\n        return response\n\n    @tool()\n    async def sandbox_get_metrics(\n        sandbox_id: str,\n        *,\n        connect_if_missing: bool = False,\n    ) -> SandboxMetrics:\n        \"\"\"Get resource metrics for a sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            Metrics dict.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        metrics = await sandbox.get_metrics()\n        return metrics\n\n    @tool()\n    async def sandbox_healthcheck(\n        sandbox_id: str,\n        *,\n        connect_if_missing: bool = False,\n    ) -> SandboxHealthResponse:\n        \"\"\"Check if a sandbox is healthy.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"sandbox_id\": \"...\", \"healthy\": true|false}.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        healthy = await sandbox.is_healthy()\n        return SandboxHealthResponse(sandbox_id=sandbox_id, healthy=healthy)\n\n    @tool()\n    async def command_run(\n        sandbox_id: str,\n        command: str,\n        *,\n        background: bool = False,\n        working_directory: str | None = None,\n        connect_if_missing: bool = False,\n    ) -> Execution:\n        \"\"\"Run a command inside a sandbox.\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            command: Shell command to execute (supports pipes/redirects).\n            background: If True, run asynchronously and return immediately.\n            working_directory: Working directory for the command.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            Execution result dict with id, exit_code, logs, and duration.\n\n        Example:\n            result = await command_run(\"sbx_123\", \"ls -la\", working_directory=\"/\")\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        opts = RunCommandOpts(\n            background=background,\n            working_directory=working_directory,\n        )\n        execution = await sandbox.commands.run(command, opts=opts)\n        return execution\n\n    @tool()\n    async def command_interrupt(\n        sandbox_id: str,\n        execution_id: str,\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Interrupt a running command execution.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            execution_id: Execution identifier to interrupt.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"interrupted\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        await sandbox.commands.interrupt(execution_id)\n        return StatusResponse(status=\"interrupted\")\n\n    @tool()\n    async def file_read(\n        sandbox_id: str,\n        path: str,\n        *,\n        encoding: str = \"utf-8\",\n        range_header: str | None = None,\n        connect_if_missing: bool = False,\n    ) -> FileReadResponse:\n        \"\"\"Read a text file from the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            path: File path to read.\n            encoding: Text encoding.\n            range_header: Optional byte range header (e.g., \"bytes=0-1023\").\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"path\": \"...\", \"content\": \"...\"}.\n\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        content = await sandbox.files.read_file(\n            path, encoding=encoding, range_header=range_header\n        )\n        return FileReadResponse(path=path, content=content)\n\n    @tool()\n    async def file_write(\n        sandbox_id: str,\n        path: str,\n        content: str,\n        *,\n        encoding: str = \"utf-8\",\n        mode: int = 755,\n        owner: str | None = None,\n        group: str | None = None,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Write a text file inside the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            path: Destination file path.\n            content: File content.\n            encoding: Text encoding.\n            mode: Unix file permissions.\n            owner: Owner username.\n            group: Group name.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"written\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        await sandbox.files.write_file(\n            path,\n            content,\n            encoding=encoding,\n            mode=mode,\n            owner=owner,\n            group=group,\n        )\n        return StatusResponse(status=\"written\")\n\n    @tool()\n    async def file_delete(\n        sandbox_id: str,\n        paths: list[str],\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Delete files inside the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            paths: File paths to delete.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"deleted\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        await sandbox.files.delete_files(paths)\n        return StatusResponse(status=\"deleted\")\n\n    @tool()\n    async def file_search(\n        sandbox_id: str,\n        path: str,\n        pattern: str,\n        *,\n        connect_if_missing: bool = False,\n    ) -> list[EntryInfo]:\n        \"\"\"Search for files matching a pattern.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            path: Base directory to search.\n            pattern: Glob pattern (e.g., \"*.py\").\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            List of entry info objects.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        results = await sandbox.files.search(SearchEntry(path=path, pattern=pattern))\n        return results\n\n    @tool()\n    async def file_create_directories(\n        sandbox_id: str,\n        entries: list[DirectoryEntryInput],\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Create directories inside the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            entries: List of directory entries (path, mode, owner, group).\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"created\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        write_entries = [\n            WriteEntry(**entry.model_dump(exclude_none=True)) for entry in entries\n        ]\n        await sandbox.files.create_directories(write_entries)\n        return StatusResponse(status=\"created\")\n\n    @tool()\n    async def file_delete_directories(\n        sandbox_id: str,\n        paths: list[str],\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Delete directories inside the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            paths: Directory paths to delete.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"deleted\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        await sandbox.files.delete_directories(paths)\n        return StatusResponse(status=\"deleted\")\n\n    @tool()\n    async def file_move(\n        sandbox_id: str,\n        entries: list[MoveEntry],\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Move or rename files/directories inside the sandbox.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            entries: List of move entries (source, destination).\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"moved\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        await sandbox.files.move_files(entries)\n        return StatusResponse(status=\"moved\")\n\n    @tool()\n    async def file_replace_contents(\n        sandbox_id: str,\n        entries: list[ContentReplaceEntry],\n        *,\n        connect_if_missing: bool = False,\n    ) -> StatusResponse:\n        \"\"\"Replace content inside files.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            entries: List of replace entries (path, old_content, new_content).\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            {\"status\": \"updated\"} when successful.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        replace_entries = [\n            ContentReplaceEntry(**entry.model_dump(exclude_none=True))\n            for entry in entries\n        ]\n        await sandbox.files.replace_contents(replace_entries)\n        return StatusResponse(status=\"updated\")\n\n    @tool()\n    async def sandbox_get_endpoint(\n        sandbox_id: str,\n        port: int,\n        *,\n        connect_if_missing: bool = False,\n    ) -> SandboxEndpoint:\n        \"\"\"Get a sandbox network endpoint for a specific port.\n\n        Parameters:\n            sandbox_id: Target sandbox identifier.\n            port: Port number inside the sandbox.\n            connect_if_missing: Connect if sandbox not in local registry.\n\n        Returns:\n            Endpoint info dict.\n        \"\"\"\n        sandbox = await _get_or_connect_sandbox(\n            sandbox_id,\n            connect_if_missing=connect_if_missing,\n        )\n        endpoint = await sandbox.get_endpoint(port)\n        return endpoint\n\n    return state\n\n\ndef create_server(connection_config: ConnectionConfig | None = None) -> FastMCP:\n    \"\"\"Create the MCP server instance for OpenSandbox.\"\"\"\n    mcp = FastMCP(\n        \"OpenSandbox Sandbox\",\n        instructions=(\n            \"Use these tools to create and manage isolated sandboxes. \"\n            \"Always keep track of the sandbox_id returned by sandbox_create/connect. \"\n            \"Use command_run for execution, file_read/file_write for file IO, and \"\n            \"sandbox_kill to terminate remote sandboxes. Use sandbox_get_endpoint to \"\n            \"expose sandbox ports; for large files, prefer range reads.\"\n        ),\n    )\n    register_tools(mcp, connection_config=connection_config)\n    return mcp\n"
  },
  {
    "path": "sdks/package.json",
    "content": "{\n  \"name\": \"opensandbox-sdks\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@9.15.0\",\n  \"scripts\": {\n    \"build:js\": \"pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... --sort run build\",\n    \"lint:js\": \"pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... run lint\",\n    \"clean:js\": \"pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... --sort run clean\",\n    \"publish:js\": \"pnpm -r --filter @alibaba-group/opensandbox-code-interpreter... publish --access public --no-git-checks\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"eslint\": \"^9.39.2\",\n    \"globals\": \"^17.0.0\",\n    \"typescript\": \"^5.7.2\",\n    \"typescript-eslint\": \"^8.52.0\"\n  }\n}\n"
  },
  {
    "path": "sdks/pnpm-workspace.yaml",
    "content": "packages:\n  - \"sandbox/javascript\"\n  - \"code-interpreter/javascript\"\n"
  },
  {
    "path": "sdks/sandbox/csharp/.editorconfig",
    "content": "root = true\n\n[*.cs]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 4\n"
  },
  {
    "path": "sdks/sandbox/csharp/Directory.Build.props",
    "content": "<Project>\n  <Import Project=\"$(MSBuildThisFileDirectory)..\\..\\Directory.Build.props\" Condition=\"Exists('$(MSBuildThisFileDirectory)..\\..\\Directory.Build.props')\" />\n\n  <PropertyGroup>\n    <EnableNETAnalyzers>true</EnableNETAnalyzers>\n    <AnalysisLevel>latest</AnalysisLevel>\n    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>\n  </PropertyGroup>\n</Project>\n"
  },
  {
    "path": "sdks/sandbox/csharp/OpenSandbox.sln",
    "content": "\nMicrosoft Visual Studio Solution File, Format Version 12.00\n# Visual Studio Version 17\nVisualStudioVersion = 17.0.31903.59\nMinimumVisualStudioVersion = 10.0.40219.1\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"OpenSandbox\", \"src\\OpenSandbox\\OpenSandbox.csproj\", \"{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}\"\nEndProject\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"OpenSandbox.Tests\", \"tests\\OpenSandbox.Tests\\OpenSandbox.Tests.csproj\", \"{B2C3D4E5-F6A7-8901-BCDE-F12345678901}\"\nEndProject\nGlobal\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n\t\tDebug|Any CPU = Debug|Any CPU\n\t\tRelease|Any CPU = Release|Any CPU\n\tEndGlobalSection\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU\n\t\t{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU\n\tEndGlobalSection\nEndGlobal\n"
  },
  {
    "path": "sdks/sandbox/csharp/OpenSandbox.sln.DotSettings.user",
    "content": "﻿<wpf:ResourceDictionary xml:space=\"preserve\" xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\" xmlns:s=\"clr-namespace:System;assembly=mscorlib\" xmlns:ss=\"urn:shemas-jetbrains-com:settings-storage-xaml\" xmlns:wpf=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\">\n\t<s:String x:Key=\"/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=9dbda61d_002D52e7_002D49a4_002D99fc_002D81c70a977921/@EntryIndexedValue\">&lt;SessionState ContinuousTestingMode=\"0\" IsActive=\"True\" Name=\"ConnectionConfigTests\" xmlns=\"urn:schemas-jetbrains-com:jetbrains-ut-session\"&gt;&#xD;\n  &lt;TestAncestor&gt;&#xD;\n    &lt;TestId&gt;xUnit::B2C3D4E5-F6A7-8901-BCDE-F12345678901::net8.0::OpenSandbox.Tests.ConnectionConfigTests&lt;/TestId&gt;&#xD;\n    &lt;TestId&gt;xUnit::B2C3D4E5-F6A7-8901-BCDE-F12345678901::net8.0::OpenSandbox.Tests.SseParserTests&lt;/TestId&gt;&#xD;\n  &lt;/TestAncestor&gt;&#xD;\n&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>"
  },
  {
    "path": "sdks/sandbox/csharp/README.md",
    "content": "# OpenSandbox SDK for C#\n\nEnglish | [中文](README_zh.md)\n\nA C# SDK for low-level interaction with OpenSandbox. It provides the ability to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and reading resource metrics.\n\n## Installation\n\n### NuGet\n\n```bash\ndotnet add package Alibaba.OpenSandbox\n```\n\n### Package Manager\n\n```powershell\nInstall-Package Alibaba.OpenSandbox\n```\n\n## Quick Start\n\nThe following example shows how to create a sandbox and execute a shell command.\n\n> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions.\n\n```csharp\nusing OpenSandbox;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\n\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-api-key\",\n    // Protocol = ConnectionProtocol.Https,\n    // RequestTimeoutSeconds = 60,\n});\n\ntry\n{\n    await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n    {\n        ConnectionConfig = config,\n        Image = \"ubuntu\",\n        TimeoutSeconds = 10 * 60,\n    });\n\n    var execution = await sandbox.Commands.RunAsync(\"echo 'Hello Sandbox!'\");\n    Console.WriteLine(execution.Logs.Stdout.FirstOrDefault()?.Text);\n\n    // Optional but recommended: terminate the remote instance when you are done.\n    await sandbox.KillAsync();\n}\ncatch (SandboxException ex)\n{\n    Console.Error.WriteLine($\"Sandbox Error: [{ex.Error.Code}] {ex.Error.Message}\");\n    Console.Error.WriteLine($\"Request ID: {ex.RequestId}\");\n}\n```\n\n## Usage Examples\n\n### 1. Lifecycle Management\n\nManage the sandbox lifecycle, including renewal, pausing, and resuming.\n\n```csharp\nvar info = await sandbox.GetInfoAsync();\nConsole.WriteLine($\"State: {info.Status.State}\");\nConsole.WriteLine($\"Created: {info.CreatedAt}\");\nConsole.WriteLine($\"Expires: {info.ExpiresAt}\"); // null when manual cleanup mode is used\n\nawait sandbox.PauseAsync();\n\n// Resume returns a fresh, connected Sandbox instance.\nvar resumed = await sandbox.ResumeAsync();\n\n// Renew: expiresAt = now + timeoutSeconds\nawait resumed.RenewAsync(30 * 60);\n```\n\nCreate a non-expiring sandbox by setting `ManualCleanup = true`:\n\n```csharp\nvar manual = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"ubuntu\",\n    ManualCleanup = true,\n});\n```\n\nNote: unlike the Python, JavaScript, and Kotlin SDKs, the C# SDK uses an explicit\n`ManualCleanup` flag instead of `TimeoutSeconds = null`. This is intentional:\n`int?` in the current options model cannot reliably distinguish \"unset, use the\ndefault TTL\" from \"explicitly request manual cleanup\" without making the default\ncreation path ambiguous.\n\n### Connect to an Existing Sandbox\n\nUse `ConnectAsync` when you already have a sandbox ID and need a new SDK instance bound to it.\n\n```csharp\nvar connected = await Sandbox.ConnectAsync(new SandboxConnectOptions\n{\n    SandboxId = \"existing-sandbox-id\",\n    ConnectionConfig = config\n});\n```\n\n### 2. Custom Health Check\n\nDefine custom logic to determine whether the sandbox is ready/healthy.\n\n```csharp\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"nginx:latest\",\n    HealthCheck = async (sbx) =>\n    {\n        // Example: consider the sandbox healthy when port 80 endpoint becomes available\n        var ep = await sbx.GetEndpointAsync(80);\n        return !string.IsNullOrEmpty(ep.EndpointAddress);\n    },\n});\n```\n\n### 3. Command Execution & Streaming\n\nExecute commands and handle output streams in real-time.\n\n```csharp\nusing OpenSandbox.Models;\n\nvar handlers = new ExecutionHandlers\n{\n    OnStdout = msg => { Console.WriteLine($\"STDOUT: {msg.Text}\"); return Task.CompletedTask; },\n    OnStderr = msg => { Console.Error.WriteLine($\"STDERR: {msg.Text}\"); return Task.CompletedTask; },\n    OnExecutionComplete = c => { Console.WriteLine($\"Finished in {c.ExecutionTimeMs}ms\"); return Task.CompletedTask; },\n};\n\nawait sandbox.Commands.RunAsync(\n    \"for i in 1 2 3; do echo \\\"Count $i\\\"; sleep 0.2; done\",\n    handlers: handlers\n);\n```\n\nFor background commands, you can poll status and incremental logs:\n\n```csharp\nvar execution = await sandbox.Commands.RunAsync(\n    \"python /app/server.py\",\n    options: new RunCommandOptions\n    {\n        Background = true,\n        TimeoutSeconds = 120,\n    });\n\nvar status = await sandbox.Commands.GetCommandStatusAsync(execution.Id!);\nvar logs = await sandbox.Commands.GetBackgroundCommandLogsAsync(execution.Id!, cursor: 0);\nConsole.WriteLine($\"running={status.Running}, cursor={logs.Cursor}\");\n```\n\n### 4. Comprehensive File Operations\n\nManage files and directories, including read, write, list/search, and delete.\n\n```csharp\nawait sandbox.Files.CreateDirectoriesAsync(new[]\n{\n    new CreateDirectoryEntry { Path = \"/tmp/demo\", Mode = 755 }\n});\n\nawait sandbox.Files.WriteFilesAsync(new[]\n{\n    new WriteEntry { Path = \"/tmp/demo/hello.txt\", Data = \"Hello World\", Mode = 644 }\n});\n\nvar content = await sandbox.Files.ReadFileAsync(\"/tmp/demo/hello.txt\");\nConsole.WriteLine($\"Content: {content}\");\n\nvar files = await sandbox.Files.SearchAsync(new SearchEntry { Path = \"/tmp/demo\", Pattern = \"*.txt\" });\nforeach (var file in files)\n{\n    Console.WriteLine(file.Path);\n}\n\nawait sandbox.Files.DeleteDirectoriesAsync(new[] { \"/tmp/demo\" });\n\n// Delete one or more files directly.\nawait sandbox.Files.DeleteFilesAsync(new[] { \"/tmp/demo/hello.txt\" });\n```\n\n### 5. Endpoints\n\n`GetEndpointAsync()` returns an endpoint **without a scheme** (for example `\"localhost:44772\"`). Use `GetEndpointUrlAsync()` if you want a ready-to-use absolute URL.\n\n```csharp\nvar endpoint = await sandbox.GetEndpointAsync(44772);\nConsole.WriteLine(endpoint.EndpointAddress);\n\nvar url = await sandbox.GetEndpointUrlAsync(44772);\nConsole.WriteLine(url); // e.g., \"http://localhost:44772\"\n```\n\n### 6. Sandbox Management (Admin)\n\nUse `SandboxManager` for administrative tasks and finding existing sandboxes.\n\n```csharp\nawait using var manager = SandboxManager.Create(new SandboxManagerOptions\n{\n    ConnectionConfig = config\n});\n\nvar list = await manager.ListSandboxInfosAsync(new SandboxFilter\n{\n    States = new[] { SandboxStates.Running },\n    PageSize = 10\n});\n\nforeach (var s in list.Items)\n{\n    Console.WriteLine(s.Id);\n}\n```\n\n## Configuration\n\n### 1. Connection Configuration\n\nThe `ConnectionConfig` class manages API server connection settings.\n\n| Parameter | Description | Default | Environment Variable |\n| --- | --- | --- | --- |\n| `ApiKey` | API key for authentication | Optional | `OPEN_SANDBOX_API_KEY` |\n| `Domain` | Sandbox service domain (`host[:port]`) | `localhost:8080` | `OPEN_SANDBOX_DOMAIN` |\n| `Protocol` | HTTP protocol (`Http`/`Https`) | `Http` | - |\n| `RequestTimeoutSeconds` | Request timeout applied to SDK HTTP calls | `30` | - |\n| `UseServerProxy` | Request server-proxied sandbox endpoint URLs | `false` | - |\n| `Headers` | Extra headers applied to every request | `{}` | - |\n\n```csharp\nusing OpenSandbox.Config;\n\n// 1. Basic configuration\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-key\",\n    RequestTimeoutSeconds = 60,\n    // UseServerProxy = true, // Useful when the client cannot access sandbox endpoint directly\n});\n\n// 2. Advanced: custom headers\nvar config2 = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-key\",\n    Headers = new Dictionary<string, string>\n    {\n        [\"X-Custom-Header\"] = \"value\"\n    },\n});\n```\n\n### 2. Diagnostics and Logging\n\nThe SDK uses `Microsoft.Extensions.Logging` abstractions.\n\n```csharp\nusing Microsoft.Extensions.Logging;\nusing OpenSandbox.Config;\n\nusing var loggerFactory = LoggerFactory.Create(builder =>\n{\n    builder.SetMinimumLevel(LogLevel.Debug);\n    builder.AddConsole();\n});\n\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    Image = \"python:3.11\",\n    ConnectionConfig = new ConnectionConfig(),\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n```\n\n### 3. Sandbox Creation Configuration\n\n`Sandbox.CreateAsync()` allows configuring the sandbox environment.\n\n| Parameter | Description | Default |\n| --- | --- | --- |\n| `Image` | Docker image to use | Required |\n| `TimeoutSeconds` | Automatic termination timeout (server-side TTL) | 10 minutes |\n| `Entrypoint` | Container entrypoint command | `[\"tail\",\"-f\",\"/dev/null\"]` |\n| `Resource` | CPU and memory limits (string map) | `{\"cpu\":\"1\",\"memory\":\"2Gi\"}` |\n| `Env` | Environment variables | `{}` |\n| `Metadata` | Custom metadata tags | `{}` |\n| `NetworkPolicy` | Optional outbound network policy (egress) | - |\n| `Volumes` | Optional storage mounts (`Host` / `PVC`, supports `ReadOnly` and `SubPath`) | - |\n| `Extensions` | Extra server-defined fields | `{}` |\n| `SkipHealthCheck` | Skip readiness checks (`Running` + health check) | `false` |\n| `HealthCheck` | Custom readiness check | - |\n| `ReadyTimeoutSeconds` | Max time to wait for readiness | 30 seconds |\n| `HealthCheckPollingInterval` | Poll interval while waiting (milliseconds) | 200 ms |\n\nNote: metadata keys under `opensandbox.io/` are reserved for system-managed\nlabels and will be rejected by the server.\n\n```csharp\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"python:3.11\",\n    NetworkPolicy = new NetworkPolicy\n    {\n        DefaultAction = NetworkRuleAction.Deny,\n        Egress = new List<NetworkRule>\n        {\n            new() { Action = NetworkRuleAction.Allow, Target = \"pypi.org\" }\n        }\n    },\n    Volumes = new[]\n    {\n        new Volume\n        {\n            Name = \"workspace\",\n            Host = new Host { Path = \"/tmp/opensandbox-e2e/host-volume-test\" },\n            MountPath = \"/workspace\",\n            ReadOnly = false\n        }\n    }\n});\n```\n\n### 4. Runtime Egress Policy Updates\n\nRuntime egress reads and patches go directly to the sandbox egress sidecar.\nThe SDK first resolves the sandbox endpoint on port `18080`, then calls the sidecar `/policy` API.\n\nPatch uses merge semantics:\n- Incoming rules take priority over existing rules with the same `Target`.\n- Existing rules for other targets remain unchanged.\n- Within a single patch payload, the first rule for a `Target` wins.\n- The current `DefaultAction` is preserved.\n\n```csharp\nvar policy = await sandbox.GetEgressPolicyAsync();\n\nawait sandbox.PatchEgressRulesAsync(new[]\n{\n    new NetworkRule { Action = NetworkRuleAction.Allow, Target = \"www.github.com\" },\n    new NetworkRule { Action = NetworkRuleAction.Deny, Target = \"pypi.org\" }\n});\n```\n\n### 5. Timeout and Retry Behavior\n\n- `ConnectionConfig.RequestTimeoutSeconds` controls timeout for SDK HTTP calls.\n- `RunCommandOptions.TimeoutSeconds` controls command execution timeout for command runs.\n- `SandboxCreateOptions.TimeoutSeconds` controls sandbox server-side TTL.\n- `ReadyTimeoutSeconds` controls how long `CreateAsync` / `ConnectAsync` waits for readiness.\n- The SDK does not automatically retry failed API requests; implement retries in caller code where appropriate.\n\n### 6. Resource Cleanup\n\nBoth `Sandbox` and `SandboxManager` implement `IAsyncDisposable`. Use `await using` or call `DisposeAsync()` when done.\n\n```csharp\nawait using var sandbox = await Sandbox.CreateAsync(options);\n// ... use sandbox ...\n// Automatically disposed when leaving scope\n```\n\n## Error Handling\n\nThe SDK throws `SandboxException` (and derived exceptions such as `SandboxApiException`,\n`SandboxReadyTimeoutException`, and `InvalidArgumentException`) when operations fail.\n\n```csharp\ntry\n{\n    var execution = await sandbox.Commands.RunAsync(\"echo 'Hello Sandbox!'\");\n    Console.WriteLine(execution.Logs.Stdout.FirstOrDefault()?.Text);\n}\ncatch (SandboxReadyTimeoutException)\n{\n    Console.Error.WriteLine(\"Sandbox did not become ready before the configured timeout.\");\n}\ncatch (SandboxApiException ex)\n{\n    Console.Error.WriteLine($\"API Error: status={ex.StatusCode}, requestId={ex.RequestId}, message={ex.Message}\");\n}\ncatch (SandboxException ex)\n{\n    Console.Error.WriteLine($\"Sandbox Error: [{ex.Error.Code}] {ex.Error.Message}\");\n}\n```\n\n## Supported Frameworks\n\n- .NET Standard 2.0 (for maximum compatibility with .NET Framework 4.6.1+, .NET Core 2.0+, Mono, Xamarin, etc.)\n- .NET Standard 2.1\n- .NET 6.0 (LTS)\n- .NET 7.0\n- .NET 8.0 (LTS)\n- .NET 9.0\n- .NET 10.0\n\n## License\n\nApache License 2.0\n"
  },
  {
    "path": "sdks/sandbox/csharp/README_zh.md",
    "content": "# OpenSandbox SDK for C#\n\n[English](README.md) | 中文\n\n一个用于与 OpenSandbox 进行低级交互的 C# SDK。它提供了创建、管理和与安全沙箱环境交互的能力，包括执行 shell 命令、管理文件和读取资源指标。\n\n## 安装\n\n### NuGet\n\n```bash\ndotnet add package Alibaba.OpenSandbox\n```\n\n### Package Manager\n\n```powershell\nInstall-Package Alibaba.OpenSandbox\n```\n\n## 快速开始\n\n以下示例展示如何创建沙箱并执行 shell 命令。\n\n> **注意**：运行此示例之前，请确保 OpenSandbox 服务正在运行。有关启动说明，请参阅根目录的 [README_zh.md](../../../docs/README_zh.md)。\n\n```csharp\nusing OpenSandbox;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\n\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-api-key\",\n    // Protocol = ConnectionProtocol.Https,\n    // RequestTimeoutSeconds = 60,\n});\n\ntry\n{\n    await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n    {\n        ConnectionConfig = config,\n        Image = \"ubuntu\",\n        TimeoutSeconds = 10 * 60,\n    });\n\n    var execution = await sandbox.Commands.RunAsync(\"echo 'Hello Sandbox!'\");\n    Console.WriteLine(execution.Logs.Stdout.FirstOrDefault()?.Text);\n\n    // 可选但推荐：完成后终止远程实例\n    await sandbox.KillAsync();\n}\ncatch (SandboxException ex)\n{\n    Console.Error.WriteLine($\"沙箱错误: [{ex.Error.Code}] {ex.Error.Message}\");\n    Console.Error.WriteLine($\"Request ID: {ex.RequestId}\");\n}\n```\n\n## 使用示例\n\n### 1. 生命周期管理\n\n管理沙箱生命周期，包括续期、暂停和恢复。\n\n```csharp\nvar info = await sandbox.GetInfoAsync();\nConsole.WriteLine($\"状态: {info.Status.State}\");\nConsole.WriteLine($\"创建时间: {info.CreatedAt}\");\nConsole.WriteLine($\"过期时间: {info.ExpiresAt}\"); // 使用手动清理模式时为 null\n\nawait sandbox.PauseAsync();\n\n// Resume 返回一个新的、已连接的 Sandbox 实例\nvar resumed = await sandbox.ResumeAsync();\n\n// 续期: expiresAt = now + timeoutSeconds\nawait resumed.RenewAsync(30 * 60);\n```\n\n通过设置 `ManualCleanup = true` 创建一个不会自动过期的沙箱：\n\n```csharp\nvar manual = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"ubuntu\",\n    ManualCleanup = true,\n});\n```\n\n注意：与 Python、JavaScript、Kotlin SDK 不同，C# SDK 使用显式的\n`ManualCleanup` 开关，而不是 `TimeoutSeconds = null`。这是有意的设计，\n因为在当前的 options 模型里，`int?` 不能稳定地区分“未设置，沿用默认 TTL”\n和“显式请求手动清理”。\n\n### 2. 自定义健康检查\n\n定义自定义逻辑来确定沙箱是否就绪/健康。\n\n```csharp\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"nginx:latest\",\n    HealthCheck = async (sbx) =>\n    {\n        // 示例：当端口 80 端点可用时认为沙箱健康\n        var ep = await sbx.GetEndpointAsync(80);\n        return !string.IsNullOrEmpty(ep.EndpointAddress);\n    },\n});\n```\n\n### 3. 命令执行和流式处理\n\n执行命令并实时处理输出流。\n\n```csharp\nusing OpenSandbox.Models;\n\nvar handlers = new ExecutionHandlers\n{\n    OnStdout = msg => { Console.WriteLine($\"STDOUT: {msg.Text}\"); return Task.CompletedTask; },\n    OnStderr = msg => { Console.Error.WriteLine($\"STDERR: {msg.Text}\"); return Task.CompletedTask; },\n    OnExecutionComplete = c => { Console.WriteLine($\"完成，耗时 {c.ExecutionTimeMs}ms\"); return Task.CompletedTask; },\n};\n\nawait sandbox.Commands.RunAsync(\n    \"for i in 1 2 3; do echo \\\"Count $i\\\"; sleep 0.2; done\",\n    handlers: handlers\n);\n```\n\n对于后台命令，可以轮询状态和增量日志：\n\n```csharp\nvar execution = await sandbox.Commands.RunAsync(\n    \"python /app/server.py\",\n    options: new RunCommandOptions\n    {\n        Background = true,\n        TimeoutSeconds = 120,\n    });\n\nvar status = await sandbox.Commands.GetCommandStatusAsync(execution.Id!);\nvar logs = await sandbox.Commands.GetBackgroundCommandLogsAsync(execution.Id!, cursor: 0);\nConsole.WriteLine($\"running={status.Running}, cursor={logs.Cursor}\");\n```\n\n### 4. 全面的文件操作\n\n管理文件和目录，包括读取、写入、列出/搜索和删除。\n\n```csharp\nawait sandbox.Files.CreateDirectoriesAsync(new[]\n{\n    new CreateDirectoryEntry { Path = \"/tmp/demo\", Mode = 755 }\n});\n\nawait sandbox.Files.WriteFilesAsync(new[]\n{\n    new WriteEntry { Path = \"/tmp/demo/hello.txt\", Data = \"Hello World\", Mode = 644 }\n});\n\nvar content = await sandbox.Files.ReadFileAsync(\"/tmp/demo/hello.txt\");\nConsole.WriteLine($\"内容: {content}\");\n\nvar files = await sandbox.Files.SearchAsync(new SearchEntry { Path = \"/tmp/demo\", Pattern = \"*.txt\" });\nforeach (var file in files)\n{\n    Console.WriteLine(file.Path);\n}\n\nawait sandbox.Files.DeleteDirectoriesAsync(new[] { \"/tmp/demo\" });\n```\n\n### 5. 端点\n\n`GetEndpointAsync()` 返回**不带协议**的端点（例如 `\"localhost:44772\"`）。如果需要可直接使用的绝对 URL，请使用 `GetEndpointUrlAsync()`。\n\n```csharp\nvar endpoint = await sandbox.GetEndpointAsync(44772);\nConsole.WriteLine(endpoint.EndpointAddress);\n\nvar url = await sandbox.GetEndpointUrlAsync(44772);\nConsole.WriteLine(url); // 例如 \"http://localhost:44772\"\n```\n\n### 6. 沙箱管理（管理员）\n\n使用 `SandboxManager` 进行管理任务和查找现有沙箱。\n\n```csharp\nawait using var manager = SandboxManager.Create(new SandboxManagerOptions\n{\n    ConnectionConfig = config\n});\n\nvar list = await manager.ListSandboxInfosAsync(new SandboxFilter\n{\n    States = new[] { \"Running\" },\n    PageSize = 10\n});\n\nforeach (var s in list.Items)\n{\n    Console.WriteLine(s.Id);\n}\n```\n\n## 配置\n\n### 1. 连接配置\n\n`ConnectionConfig` 类管理 API 服务器连接设置。\n\n| 参数 | 描述 | 默认值 | 环境变量 |\n| --- | --- | --- | --- |\n| `ApiKey` | 用于身份验证的 API 密钥 | 可选 | `OPEN_SANDBOX_API_KEY` |\n| `Domain` | 沙箱服务域名 (`host[:port]`) | `localhost:8080` | `OPEN_SANDBOX_DOMAIN` |\n| `Protocol` | HTTP 协议 (`Http`/`Https`) | `Http` | - |\n| `RequestTimeoutSeconds` | 应用于 SDK HTTP 调用的请求超时 | `30` | - |\n| `UseServerProxy` | 是否请求服务端代理的沙箱访问端点 URL | `false` | - |\n| `Headers` | 应用于每个请求的额外头部 | `{}` | - |\n\n```csharp\nusing OpenSandbox.Config;\n\n// 1. 基本配置\nvar config = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-key\",\n    RequestTimeoutSeconds = 60,\n    // UseServerProxy = true, // 当客户端无法直连沙箱 endpoint 时建议开启\n});\n\n// 2. 高级：自定义头部\nvar config2 = new ConnectionConfig(new ConnectionConfigOptions\n{\n    Domain = \"api.opensandbox.io\",\n    ApiKey = \"your-key\",\n    Headers = new Dictionary<string, string>\n    {\n        [\"X-Custom-Header\"] = \"value\"\n    },\n});\n```\n\n### 2. 诊断与日志\n\nSDK 使用 `Microsoft.Extensions.Logging` 抽象。\n\n```csharp\nusing Microsoft.Extensions.Logging;\nusing OpenSandbox.Config;\n\nusing var loggerFactory = LoggerFactory.Create(builder =>\n{\n    builder.SetMinimumLevel(LogLevel.Debug);\n    builder.AddConsole();\n});\n\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    Image = \"python:3.11\",\n    ConnectionConfig = new ConnectionConfig(),\n    Diagnostics = new SdkDiagnosticsOptions\n    {\n        LoggerFactory = loggerFactory\n    }\n});\n```\n\n### 3. 沙箱创建配置\n\n`Sandbox.CreateAsync()` 允许配置沙箱环境。\n\n| 参数 | 描述 | 默认值 |\n| --- | --- | --- |\n| `Image` | 要使用的 Docker 镜像 | 必需 |\n| `TimeoutSeconds` | 自动终止超时（服务器端 TTL） | 10 分钟 |\n| `Entrypoint` | 容器入口点命令 | `[\"tail\",\"-f\",\"/dev/null\"]` |\n| `Resource` | CPU 和内存限制（字符串映射） | `{\"cpu\":\"1\",\"memory\":\"2Gi\"}` |\n| `Env` | 环境变量 | `{}` |\n| `Metadata` | 自定义元数据标签 | `{}` |\n| `NetworkPolicy` | 可选的出站网络策略（egress） | - |\n| `Volumes` | 可选存储挂载（`Host` / `PVC`，支持 `ReadOnly` 与 `SubPath`） | - |\n| `Extensions` | 额外的服务器定义字段 | `{}` |\n| `SkipHealthCheck` | 跳过就绪检查（`Running` + 健康检查） | `false` |\n| `HealthCheck` | 自定义就绪检查 | - |\n| `ReadyTimeoutSeconds` | 等待就绪的最大时间 | 30 秒 |\n| `HealthCheckPollingInterval` | 等待时的轮询间隔（毫秒） | 200 ms |\n\n注意：`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签，服务端会拒绝用户传入。\n\n```csharp\nvar sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n{\n    ConnectionConfig = config,\n    Image = \"python:3.11\",\n    NetworkPolicy = new NetworkPolicy\n    {\n        DefaultAction = NetworkRuleAction.Deny,\n        Egress = new List<NetworkRule>\n        {\n            new() { Action = NetworkRuleAction.Allow, Target = \"pypi.org\" }\n        }\n    },\n    Volumes = new[]\n    {\n        new Volume\n        {\n            Name = \"workspace\",\n            Host = new Host { Path = \"/tmp/opensandbox-e2e/host-volume-test\" },\n            MountPath = \"/workspace\",\n            ReadOnly = false\n        }\n    }\n});\n```\n\n### 3. 运行时 Egress 策略更新\n\n运行时的 egress 查询和 patch 会直接访问沙箱内的 egress sidecar。\nSDK 会先解析 `18080` 端口对应的 sandbox endpoint，再调用 sidecar 的 `/policy` API。\n\n```csharp\nvar policy = await sandbox.GetEgressPolicyAsync();\n\nawait sandbox.PatchEgressRulesAsync(new[]\n{\n    new NetworkRule { Action = NetworkRuleAction.Allow, Target = \"www.github.com\" },\n    new NetworkRule { Action = NetworkRuleAction.Deny, Target = \"pypi.org\" }\n});\n```\n\n### 4. 资源清理\n\n`Sandbox` 和 `SandboxManager` 都实现了 `IAsyncDisposable`。完成后使用 `await using` 或调用 `DisposeAsync()`。\n\n```csharp\nawait using var sandbox = await Sandbox.CreateAsync(options);\n// ... 使用沙箱 ...\n// 离开作用域时自动释放\n```\n\n## 支持的框架\n\n- .NET Standard 2.0（最大兼容性，支持 .NET Framework 4.6.1+、.NET Core 2.0+、Mono、Xamarin 等）\n- .NET Standard 2.1\n- .NET 6.0 (LTS)\n- .NET 7.0\n- .NET 8.0 (LTS)\n- .NET 9.0\n- .NET 10.0\n\n## 许可证\n\nApache License 2.0\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Adapter for the execd commands service.\n/// </summary>\ninternal sealed class CommandsAdapter : IExecdCommands\n{\n    private readonly HttpClientWrapper _client;\n    private readonly HttpClient _sseHttpClient;\n    private readonly string _baseUrl;\n    private readonly IReadOnlyDictionary<string, string> _headers;\n    private readonly ILogger _logger;\n\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n    };\n\n    public CommandsAdapter(\n        HttpClientWrapper client,\n        HttpClient sseHttpClient,\n        string baseUrl,\n        IReadOnlyDictionary<string, string> headers,\n        ILogger logger)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n        _sseHttpClient = sseHttpClient ?? throw new ArgumentNullException(nameof(sseHttpClient));\n        _baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));\n        _headers = headers ?? new Dictionary<string, string>();\n        _logger = logger ?? throw new ArgumentNullException(nameof(logger));\n    }\n\n    public async IAsyncEnumerable<ServerStreamEvent> RunStreamAsync(\n        string command,\n        RunCommandOptions? options = null,\n        [EnumeratorCancellation] CancellationToken cancellationToken = default)\n    {\n        if (options?.Gid.HasValue == true && options.Uid.HasValue != true)\n        {\n            throw new InvalidArgumentException(\"uid is required when gid is provided\");\n        }\n        if (options?.Uid.HasValue == true && options.Uid.Value < 0)\n        {\n            throw new InvalidArgumentException(\"uid must be >= 0\");\n        }\n        if (options?.Gid.HasValue == true && options.Gid.Value < 0)\n        {\n            throw new InvalidArgumentException(\"gid must be >= 0\");\n        }\n\n        var url = $\"{_baseUrl}/command\";\n        _logger.LogDebug(\"Running command stream (commandLength={CommandLength})\", command.Length);\n        var requestBody = new RunCommandRequest\n        {\n            Command = command,\n            Cwd = options?.WorkingDirectory,\n            Background = options?.Background,\n            Timeout = options?.TimeoutSeconds.HasValue == true ? options.TimeoutSeconds.Value * 1000L : null,\n            Uid = options?.Uid,\n            Gid = options?.Gid,\n            Envs = options?.Envs\n        };\n\n        var json = JsonSerializer.Serialize(requestBody, JsonOptions);\n        using var request = new HttpRequestMessage(HttpMethod.Post, url)\n        {\n            Content = new StringContent(json, Encoding.UTF8, \"application/json\")\n        };\n\n        request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue(\"text/event-stream\"));\n\n        foreach (var header in _headers)\n        {\n            request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n        }\n\n        using var response = await _sseHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);\n\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response, \"Run command failed\", cancellationToken).ConfigureAwait(false))\n        {\n            yield return ev;\n        }\n    }\n\n    public async Task<Execution> RunAsync(\n        string command,\n        RunCommandOptions? options = null,\n        ExecutionHandlers? handlers = null,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogDebug(\"Running command (commandLength={CommandLength})\", command.Length);\n        var execution = new Execution();\n        var dispatcher = new ExecutionEventDispatcher(execution, handlers);\n\n        await foreach (var ev in RunStreamAsync(command, options, cancellationToken).ConfigureAwait(false))\n        {\n            // Keep legacy behavior: if server sends \"init\" with empty id, preserve previous id\n            if (ev.Type == ServerStreamEventTypes.Init && string.IsNullOrEmpty(ev.Text) && !string.IsNullOrEmpty(execution.Id))\n            {\n                ev.Text = execution.Id;\n            }\n\n            await dispatcher.DispatchAsync(ev).ConfigureAwait(false);\n        }\n\n        return execution;\n    }\n\n    public async Task InterruptAsync(string sessionId, CancellationToken cancellationToken = default)\n    {\n        _logger.LogInformation(\"Interrupting execution: {ExecutionId}\", sessionId);\n        var queryParams = new Dictionary<string, string?> { [\"id\"] = sessionId };\n        await _client.DeleteAsync(\"/command\", queryParams, cancellationToken).ConfigureAwait(false);\n    }\n\n    public Task<CommandStatus> GetCommandStatusAsync(string executionId, CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(executionId))\n        {\n            throw new InvalidArgumentException(\"executionId cannot be empty\");\n        }\n\n        _logger.LogDebug(\"Fetching command status: {ExecutionId}\", executionId);\n        return _client.GetAsync<CommandStatus>($\"/command/status/{Uri.EscapeDataString(executionId)}\", cancellationToken: cancellationToken);\n    }\n\n    public async Task<CommandLogs> GetBackgroundCommandLogsAsync(\n        string executionId,\n        long? cursor = null,\n        CancellationToken cancellationToken = default)\n    {\n        if (string.IsNullOrWhiteSpace(executionId))\n        {\n            throw new InvalidArgumentException(\"executionId cannot be empty\");\n        }\n\n        _logger.LogDebug(\"Fetching command logs: {ExecutionId} (cursor={Cursor})\", executionId, cursor);\n        var path = $\"/command/{Uri.EscapeDataString(executionId)}/logs\";\n        var query = cursor.HasValue ? $\"?cursor={cursor.Value}\" : string.Empty;\n        var url = $\"{_baseUrl}{path}{query}\";\n\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false);\n\n        var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n        if (!response.IsSuccessStatusCode)\n        {\n            throw CreateApiException(response, content);\n        }\n\n        var cursorHeader = response.Headers.TryGetValues(\"EXECD-COMMANDS-TAIL-CURSOR\", out var values)\n            ? values.FirstOrDefault()\n            : null;\n        var parsedCursor = long.TryParse(cursorHeader, out var c) ? c : (long?)null;\n\n        return new CommandLogs\n        {\n            Content = content,\n            Cursor = parsedCursor\n        };\n    }\n\n    private static SandboxApiException CreateApiException(HttpResponseMessage response, string content)\n    {\n        var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n            ? values.FirstOrDefault()\n            : null;\n\n        string? errorMessage = null;\n        string? errorCode = null;\n        object? rawBody = content;\n\n        if (!string.IsNullOrEmpty(content))\n        {\n            try\n            {\n                var parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(content, JsonOptions);\n                if (parsed != null)\n                {\n                    rawBody = parsed;\n                    if (parsed.TryGetValue(\"message\", out var msg))\n                    {\n                        errorMessage = msg.GetString();\n                    }\n\n                    if (parsed.TryGetValue(\"code\", out var code))\n                    {\n                        errorCode = code.GetString();\n                    }\n                }\n            }\n            catch\n            {\n                // Ignore JSON parse errors and fallback to raw body.\n            }\n        }\n\n        var message = errorMessage ?? $\"Request failed with status code {(int)response.StatusCode}\";\n        return new SandboxApiException(\n            message: message,\n            statusCode: (int)response.StatusCode,\n            requestId: requestId,\n            rawBody: rawBody,\n            error: new SandboxError(errorCode ?? SandboxErrorCodes.UnexpectedResponse, errorMessage ?? message));\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/EgressAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json;\nusing System.Linq;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\n\nnamespace OpenSandbox.Adapters;\n\ninternal sealed class EgressAdapter : IEgress\n{\n    private readonly HttpClientWrapper _client;\n\n    public EgressAdapter(HttpClientWrapper client)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n    }\n\n    public async Task<NetworkPolicy> GetPolicyAsync(CancellationToken cancellationToken = default)\n    {\n        var response = await _client.GetAsync<JsonElement>(\"/policy\", cancellationToken: cancellationToken).ConfigureAwait(false);\n        if (!response.TryGetProperty(\"policy\", out var policyElement) || policyElement.ValueKind != JsonValueKind.Object)\n        {\n            throw new SandboxApiException(\"Missing policy in egress response\");\n        }\n\n        return ParseNetworkPolicy(policyElement);\n    }\n\n    public async Task PatchRulesAsync(\n        IReadOnlyList<NetworkRule> rules,\n        CancellationToken cancellationToken = default)\n    {\n        var normalizedRules = rules.Select(r => new Dictionary<string, object?>\n        {\n            [\"action\"] = r.Action == NetworkRuleAction.Allow ? \"allow\" : \"deny\",\n            [\"target\"] = r.Target\n        }).ToList();\n\n        await _client.PatchAsync(\"/policy\", normalizedRules, cancellationToken).ConfigureAwait(false);\n    }\n\n    private static NetworkPolicy ParseNetworkPolicy(JsonElement element)\n    {\n        var policy = new NetworkPolicy();\n\n        if (element.TryGetProperty(\"defaultAction\", out var defaultAction) &&\n            defaultAction.ValueKind == JsonValueKind.String)\n        {\n            policy.DefaultAction = ParseNetworkRuleAction(defaultAction.GetString());\n        }\n\n        if (element.TryGetProperty(\"egress\", out var egress) &&\n            egress.ValueKind == JsonValueKind.Array)\n        {\n            policy.Egress = egress.EnumerateArray().Select(ParseNetworkRule).ToList();\n        }\n\n        return policy;\n    }\n\n    private static NetworkRule ParseNetworkRule(JsonElement element)\n    {\n        var actionText = element.GetProperty(\"action\").GetString();\n        var target = element.GetProperty(\"target\").GetString();\n        return new NetworkRule\n        {\n            Action = ParseNetworkRuleAction(actionText),\n            Target = target ?? throw new SandboxApiException(\"Missing target in network rule\")\n        };\n    }\n\n    private static NetworkRuleAction ParseNetworkRuleAction(string? action)\n    {\n        return action?.ToLowerInvariant() switch\n        {\n            \"allow\" => NetworkRuleAction.Allow,\n            \"deny\" => NetworkRuleAction.Deny,\n            _ => throw new SandboxApiException($\"Invalid network rule action: {action ?? \"<null>\"}\")\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Net.Http.Headers;\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Adapter for the execd filesystem service.\n/// </summary>\ninternal sealed class FilesystemAdapter : ISandboxFiles\n{\n    private readonly HttpClientWrapper _client;\n    private readonly HttpClient _httpClient;\n    private readonly string _baseUrl;\n    private readonly IReadOnlyDictionary<string, string> _headers;\n\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        PropertyNameCaseInsensitive = true,\n        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n    };\n\n    public FilesystemAdapter(\n        HttpClientWrapper client,\n        HttpClient httpClient,\n        string baseUrl,\n        IReadOnlyDictionary<string, string> headers)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));\n        _baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));\n        _headers = headers ?? new Dictionary<string, string>();\n    }\n\n    public async Task<IReadOnlyDictionary<string, SandboxFileInfo>> GetFileInfoAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default)\n    {\n        var pathWithQuery = BuildRepeatedPathQuery(\"/files/info\", \"path\", paths);\n        var response = await _client.GetAsync<JsonElement>(pathWithQuery, cancellationToken: cancellationToken).ConfigureAwait(false);\n        return ParseFilesInfoResponse(response);\n    }\n\n    public async Task<IReadOnlyList<SandboxFileInfo>> SearchAsync(\n        SearchEntry entry,\n        CancellationToken cancellationToken = default)\n    {\n        var queryParams = new Dictionary<string, string?>\n        {\n            [\"path\"] = entry.Path,\n            [\"pattern\"] = entry.Pattern\n        };\n\n        var response = await _client.GetAsync<JsonElement>(\"/files/search\", queryParams, cancellationToken).ConfigureAwait(false);\n        return ParseSearchFilesResponse(response);\n    }\n\n    public async Task CreateDirectoriesAsync(\n        IEnumerable<CreateDirectoryEntry> entries,\n        CancellationToken cancellationToken = default)\n    {\n        var body = entries.ToDictionary(\n            e => e.Path,\n            e => new Permission\n            {\n                Mode = e.Mode ?? 755,\n                Owner = e.Owner,\n                Group = e.Group\n            });\n\n        await _client.PostAsync(\"/directories\", body, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task DeleteDirectoriesAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default)\n    {\n        var pathWithQuery = BuildRepeatedPathQuery(\"/directories\", \"path\", paths);\n        await _client.DeleteAsync(pathWithQuery, cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task WriteFilesAsync(\n        IEnumerable<WriteEntry> entries,\n        CancellationToken cancellationToken = default)\n    {\n        var entryList = entries.ToList();\n        if (entryList.Count == 0)\n        {\n            return;\n        }\n        var url = $\"{_baseUrl}/files/upload\";\n\n        using var form = new MultipartFormDataContent();\n        foreach (var entry in entryList)\n        {\n            var fileName = GetFileName(entry.Path);\n            var metadata = new FileMetadata\n            {\n                Path = entry.Path,\n                Mode = entry.Mode,\n                Owner = entry.Owner,\n                Group = entry.Group\n            };\n\n            var metadataJson = JsonSerializer.Serialize(metadata, JsonOptions);\n            var metadataContent = new StringContent(metadataJson, Encoding.UTF8, \"application/json\");\n            form.Add(metadataContent, \"metadata\", \"metadata\");\n\n            var fileContent = CreateFileContent(entry.Data);\n            form.Add(fileContent, \"file\", fileName);\n        }\n\n        using var request = new HttpRequestMessage(HttpMethod.Post, url)\n        {\n            Content = form\n        };\n\n        foreach (var header in _headers)\n        {\n            request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n\n        if (!response.IsSuccessStatusCode)\n        {\n            var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n            var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n                ? values.FirstOrDefault()\n                : null;\n\n            throw new SandboxApiException(\n                message: $\"Upload failed (status={(int)response.StatusCode})\",\n                statusCode: (int)response.StatusCode,\n                requestId: requestId,\n                rawBody: content);\n        }\n    }\n\n    public async Task<string> ReadFileAsync(\n        string path,\n        ReadFileOptions? options = null,\n        CancellationToken cancellationToken = default)\n    {\n        var bytes = await ReadBytesAsync(path, new ReadBytesOptions { Range = options?.Range }, cancellationToken).ConfigureAwait(false);\n        var encoding = GetEncoding(options?.Encoding ?? \"utf-8\");\n        return encoding.GetString(bytes);\n    }\n\n    public async Task<byte[]> ReadBytesAsync(\n        string path,\n        ReadBytesOptions? options = null,\n        CancellationToken cancellationToken = default)\n    {\n        var headers = new Dictionary<string, string>();\n        var range = options?.Range;\n        if (range != null && range.Length > 0)\n        {\n            headers[\"Range\"] = range;\n        }\n\n        var queryParams = new Dictionary<string, string?>\n        {\n            [\"path\"] = path\n        };\n\n        return await _client.GetBytesAsync(\"/files/download\", queryParams, headers, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async IAsyncEnumerable<byte[]> ReadBytesStreamAsync(\n        string path,\n        ReadBytesOptions? options = null,\n        [EnumeratorCancellation] CancellationToken cancellationToken = default)\n    {\n        var url = $\"{_baseUrl}/files/download?path={Uri.EscapeDataString(path)}\";\n\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        foreach (var header in _headers)\n        {\n            request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n        }\n\n        var range = options?.Range;\n        if (range != null && range.Length > 0)\n        {\n            request.Headers.TryAddWithoutValidation(\"Range\", range);\n        }\n\n        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);\n\n        if (!response.IsSuccessStatusCode)\n        {\n            var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n            var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n                ? values.FirstOrDefault()\n                : null;\n\n            throw new SandboxApiException(\n                message: \"Download stream failed\",\n                statusCode: (int)response.StatusCode,\n                requestId: requestId,\n                rawBody: content);\n        }\n\n        var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);\n        var buffer = new byte[8192];\n        int bytesRead;\n\n        while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0)\n        {\n            var chunk = new byte[bytesRead];\n            Array.Copy(buffer, chunk, bytesRead);\n            yield return chunk;\n        }\n    }\n\n    public async Task DeleteFilesAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default)\n    {\n        var pathWithQuery = BuildRepeatedPathQuery(\"/files\", \"path\", paths);\n        await _client.DeleteAsync(pathWithQuery, cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task MoveFilesAsync(\n        IEnumerable<MoveEntry> entries,\n        CancellationToken cancellationToken = default)\n    {\n        var body = entries.Select(e => new RenameFileItem\n        {\n            Src = e.Src,\n            Dest = e.Dest\n        }).ToList();\n\n        await _client.PostAsync(\"/files/mv\", body, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task ReplaceContentsAsync(\n        IEnumerable<ContentReplaceEntry> entries,\n        CancellationToken cancellationToken = default)\n    {\n        var body = entries.ToDictionary(\n            e => e.Path,\n            e => new ReplaceFileContentItem\n            {\n                Old = e.OldContent,\n                New = e.NewContent\n            });\n\n        await _client.PostAsync(\"/files/replace\", body, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task SetPermissionsAsync(\n        IEnumerable<SetPermissionEntry> entries,\n        CancellationToken cancellationToken = default)\n    {\n        var body = entries.ToDictionary(\n            e => e.Path,\n            e => new Permission\n            {\n                Mode = e.Mode,\n                Owner = e.Owner,\n                Group = e.Group\n            });\n\n        await _client.PostAsync(\"/files/permissions\", body, cancellationToken).ConfigureAwait(false);\n    }\n\n    private static HttpContent CreateFileContent(object? data)\n    {\n        return data switch\n        {\n            null => new ByteArrayContent(Array.Empty<byte>()),\n            string str => new StringContent(str, Encoding.UTF8),\n            byte[] bytes => new ByteArrayContent(bytes),\n            Stream stream => new StreamContent(stream),\n            _ => throw new InvalidArgumentException($\"Unsupported file data type: {data.GetType().FullName}\")\n        };\n    }\n\n    private static string GetFileName(string path)\n    {\n        var parts = path.Split('/', '\\\\');\n        return parts.Length > 0 ? parts[^1] : \"file\";\n    }\n\n    private static string BuildRepeatedPathQuery(string route, string key, IEnumerable<string> values)\n    {\n        var encodedValues = values\n            .Where(v => !string.IsNullOrEmpty(v))\n            .Select(v => $\"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(v)}\")\n            .ToList();\n\n        if (encodedValues.Count == 0)\n        {\n            return route;\n        }\n\n        return $\"{route}?{string.Join(\"&\", encodedValues)}\";\n    }\n\n    private static Encoding GetEncoding(string encodingName)\n    {\n        return encodingName.ToLowerInvariant() switch\n        {\n            \"utf-8\" or \"utf8\" => Encoding.UTF8,\n            \"ascii\" => Encoding.ASCII,\n            \"utf-16\" or \"utf16\" or \"unicode\" => Encoding.Unicode,\n            \"utf-32\" or \"utf32\" => Encoding.UTF32,\n            _ => Encoding.GetEncoding(encodingName)\n        };\n    }\n\n    private static IReadOnlyDictionary<string, SandboxFileInfo> ParseFilesInfoResponse(JsonElement element)\n    {\n        var result = new Dictionary<string, SandboxFileInfo>();\n\n        if (element.ValueKind != JsonValueKind.Object)\n            return result;\n\n        foreach (var property in element.EnumerateObject())\n        {\n            result[property.Name] = ParseFileInfo(property.Value);\n        }\n\n        return result;\n    }\n\n    private static IReadOnlyList<SandboxFileInfo> ParseSearchFilesResponse(JsonElement element)\n    {\n        if (element.ValueKind != JsonValueKind.Array)\n            return Array.Empty<SandboxFileInfo>();\n\n        return element.EnumerateArray().Select(ParseFileInfo).ToList();\n    }\n\n    private static SandboxFileInfo ParseFileInfo(JsonElement element)\n    {\n        return new SandboxFileInfo\n        {\n            Path = element.GetProperty(\"path\").GetString() ?? string.Empty,\n            Size = element.TryGetProperty(\"size\", out var size) && size.ValueKind == JsonValueKind.Number\n                ? size.GetInt64()\n                : null,\n            ModifiedAt = element.TryGetProperty(\"modified_at\", out var modifiedAt) && modifiedAt.ValueKind == JsonValueKind.String\n                ? DateTime.TryParse(modifiedAt.GetString(), out var modDate) ? modDate : null\n                : null,\n            CreatedAt = element.TryGetProperty(\"created_at\", out var createdAt) && createdAt.ValueKind == JsonValueKind.String\n                ? DateTime.TryParse(createdAt.GetString(), out var createDate) ? createDate : null\n                : null,\n            Mode = element.TryGetProperty(\"mode\", out var mode) && mode.ValueKind == JsonValueKind.Number\n                ? mode.GetInt32()\n                : null,\n            Owner = element.TryGetProperty(\"owner\", out var owner) ? owner.GetString() : null,\n            Group = element.TryGetProperty(\"group\", out var group) ? group.GetString() : null\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/HealthAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Internal;\nusing OpenSandbox.Services;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Adapter for the execd health service.\n/// </summary>\ninternal sealed class HealthAdapter : IExecdHealth\n{\n    private readonly HttpClientWrapper _client;\n\n    public HealthAdapter(HttpClientWrapper client)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n    }\n\n    public async Task<bool> PingAsync(CancellationToken cancellationToken = default)\n    {\n        try\n        {\n            await _client.GetAsync(\"/ping\", cancellationToken: cancellationToken).ConfigureAwait(false);\n            return true;\n        }\n        catch\n        {\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/MetricsAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Adapter for the execd metrics service.\n/// </summary>\ninternal sealed class MetricsAdapter : IExecdMetrics\n{\n    private readonly HttpClientWrapper _client;\n\n    public MetricsAdapter(HttpClientWrapper client)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n    }\n\n    public async Task<SandboxMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)\n    {\n        var metrics = await _client.GetAsync<Metrics>(\"/metrics\", cancellationToken: cancellationToken).ConfigureAwait(false);\n        return NormalizeMetrics(metrics);\n    }\n\n    private static SandboxMetrics NormalizeMetrics(Metrics m)\n    {\n        return new SandboxMetrics\n        {\n            CpuCount = m.CpuCount ?? 0,\n            CpuUsedPercentage = m.CpuUsedPct ?? 0,\n            MemoryTotalMiB = m.MemTotalMib ?? 0,\n            MemoryUsedMiB = m.MemUsedMib ?? 0,\n            Timestamp = m.Timestamp ?? 0\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json;\nusing System.Linq;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Adapter for the sandbox lifecycle service.\n/// </summary>\ninternal sealed class SandboxesAdapter : ISandboxes\n{\n    private readonly HttpClientWrapper _client;\n\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        PropertyNameCaseInsensitive = true,\n        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n    };\n\n    public SandboxesAdapter(HttpClientWrapper client)\n    {\n        _client = client ?? throw new ArgumentNullException(nameof(client));\n    }\n\n    public async Task<CreateSandboxResponse> CreateSandboxAsync(\n        CreateSandboxRequest request,\n        CancellationToken cancellationToken = default)\n    {\n        var response = await _client.PostAsync<JsonElement>(\"/sandboxes\", request, cancellationToken).ConfigureAwait(false);\n        return ParseCreateSandboxResponse(response);\n    }\n\n    public async Task<SandboxInfo> GetSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        var response = await _client.GetAsync<JsonElement>($\"/sandboxes/{Uri.EscapeDataString(sandboxId)}\", cancellationToken: cancellationToken).ConfigureAwait(false);\n        return ParseSandboxInfo(response);\n    }\n\n    public async Task<ListSandboxesResponse> ListSandboxesAsync(\n        ListSandboxesParams? @params = null,\n        CancellationToken cancellationToken = default)\n    {\n        var queryParts = new List<string>();\n\n        if (@params?.States != null && @params.States.Count > 0)\n        {\n            // The API expects repeated query params: ?state=Running&state=Paused\n            queryParts.AddRange(@params.States.Select(state => $\"state={Uri.EscapeDataString(state)}\"));\n        }\n\n        if (@params?.Metadata != null && @params.Metadata.Count > 0)\n        {\n            // Encode metadata as k=v&k2=v2\n            var metadataStr = string.Join(\"&\", @params.Metadata.Select(kv => $\"{kv.Key}={kv.Value}\"));\n            queryParts.Add($\"metadata={Uri.EscapeDataString(metadataStr)}\");\n        }\n\n        if (@params?.Page.HasValue == true)\n        {\n            queryParts.Add($\"page={@params.Page.Value}\");\n        }\n\n        if (@params?.PageSize.HasValue == true)\n        {\n            queryParts.Add($\"pageSize={@params.PageSize.Value}\");\n        }\n\n        var path = queryParts.Count > 0\n            ? $\"/sandboxes?{string.Join(\"&\", queryParts)}\"\n            : \"/sandboxes\";\n\n        var response = await _client.GetAsync<JsonElement>(path, cancellationToken: cancellationToken).ConfigureAwait(false);\n        return ParseListSandboxesResponse(response);\n    }\n\n    public async Task DeleteSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        await _client.DeleteAsync($\"/sandboxes/{Uri.EscapeDataString(sandboxId)}\", cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task PauseSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        await _client.PostAsync($\"/sandboxes/{Uri.EscapeDataString(sandboxId)}/pause\", cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task ResumeSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        await _client.PostAsync($\"/sandboxes/{Uri.EscapeDataString(sandboxId)}/resume\", cancellationToken: cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<RenewSandboxExpirationResponse> RenewSandboxExpirationAsync(\n        string sandboxId,\n        RenewSandboxExpirationRequest request,\n        CancellationToken cancellationToken = default)\n    {\n        var response = await _client.PostAsync<JsonElement>(\n            $\"/sandboxes/{Uri.EscapeDataString(sandboxId)}/renew-expiration\",\n            request,\n            cancellationToken).ConfigureAwait(false);\n\n        return ParseRenewSandboxExpirationResponse(response);\n    }\n\n    public async Task<Endpoint> GetSandboxEndpointAsync(\n        string sandboxId,\n        int port,\n        bool useServerProxy = false,\n        CancellationToken cancellationToken = default)\n    {\n        var queryParams = new Dictionary<string, string?>\n        {\n            [\"use_server_proxy\"] = useServerProxy ? \"true\" : \"false\"\n        };\n\n        var response = await _client.GetAsync<JsonElement>(\n            $\"/sandboxes/{Uri.EscapeDataString(sandboxId)}/endpoints/{port}\",\n            queryParams,\n            cancellationToken: cancellationToken).ConfigureAwait(false);\n\n        return new Endpoint\n        {\n            EndpointAddress = response.GetProperty(\"endpoint\").GetString() ?? throw new SandboxApiException(\"Missing endpoint in response\"),\n            Headers = response.TryGetProperty(\"headers\", out var headersElement) && headersElement.ValueKind == JsonValueKind.Object\n                ? headersElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? string.Empty)\n                : new Dictionary<string, string>()\n        };\n    }\n\n    private static DateTime ParseIsoDate(string fieldName, JsonElement element)\n    {\n        var value = element.GetString();\n        if (string.IsNullOrEmpty(value))\n        {\n            throw new SandboxApiException($\"Invalid {fieldName}: expected ISO string, got null or empty\");\n        }\n\n        if (!DateTime.TryParse(value, out var date))\n        {\n            throw new SandboxApiException($\"Invalid {fieldName}: {value}\");\n        }\n\n        return date.ToUniversalTime();\n    }\n\n    private static DateTime? ParseOptionalIsoDate(string fieldName, JsonElement element)\n    {\n        return element.ValueKind == JsonValueKind.Null ? null : ParseIsoDate(fieldName, element);\n    }\n\n    private static SandboxInfo ParseSandboxInfo(JsonElement element)\n    {\n        var status = element.GetProperty(\"status\");\n        var image = element.GetProperty(\"image\");\n\n        return new SandboxInfo\n        {\n            Id = element.GetProperty(\"id\").GetString() ?? throw new SandboxApiException(\"Missing id in response\"),\n            Image = new ImageSpec\n            {\n                Uri = image.GetProperty(\"uri\").GetString() ?? throw new SandboxApiException(\"Missing image.uri in response\"),\n                Auth = image.TryGetProperty(\"auth\", out var auth) && auth.ValueKind != JsonValueKind.Null\n                    ? JsonSerializer.Deserialize<ImageAuth>(auth.GetRawText(), JsonOptions)\n                    : null\n            },\n            Entrypoint = element.GetProperty(\"entrypoint\").EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToList(),\n            Metadata = element.TryGetProperty(\"metadata\", out var metadata) && metadata.ValueKind == JsonValueKind.Object\n                ? metadata.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? string.Empty)\n                : null,\n            Status = new SandboxStatus\n            {\n                State = status.GetProperty(\"state\").GetString() ?? throw new SandboxApiException(\"Missing status.state in response\"),\n                Reason = status.TryGetProperty(\"reason\", out var reason) ? reason.GetString() : null,\n                Message = status.TryGetProperty(\"message\", out var message) ? message.GetString() : null\n            },\n            CreatedAt = ParseIsoDate(\"createdAt\", element.GetProperty(\"createdAt\")),\n            ExpiresAt = element.TryGetProperty(\"expiresAt\", out var expiresAtElement)\n                ? ParseOptionalIsoDate(\"expiresAt\", expiresAtElement)\n                : null\n        };\n    }\n\n    private static CreateSandboxResponse ParseCreateSandboxResponse(JsonElement element)\n    {\n        var status = element.GetProperty(\"status\");\n\n        return new CreateSandboxResponse\n        {\n            Id = element.GetProperty(\"id\").GetString() ?? throw new SandboxApiException(\"Missing id in response\"),\n            Status = new SandboxStatus\n            {\n                State = status.GetProperty(\"state\").GetString() ?? throw new SandboxApiException(\"Missing status.state in response\"),\n                Reason = status.TryGetProperty(\"reason\", out var reason) ? reason.GetString() : null,\n                Message = status.TryGetProperty(\"message\", out var message) ? message.GetString() : null\n            },\n            Metadata = element.TryGetProperty(\"metadata\", out var metadata) && metadata.ValueKind == JsonValueKind.Object\n                ? metadata.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? string.Empty)\n                : null,\n            CreatedAt = ParseIsoDate(\"createdAt\", element.GetProperty(\"createdAt\")),\n            ExpiresAt = element.TryGetProperty(\"expiresAt\", out var expiresAtElement)\n                ? ParseOptionalIsoDate(\"expiresAt\", expiresAtElement)\n                : null,\n            Entrypoint = element.GetProperty(\"entrypoint\").EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToList()\n        };\n    }\n\n    private static ListSandboxesResponse ParseListSandboxesResponse(JsonElement element)\n    {\n        var items = element.GetProperty(\"items\").EnumerateArray().Select(ParseSandboxInfo).ToList();\n\n        PaginationInfo? pagination = null;\n        if (element.TryGetProperty(\"pagination\", out var paginationElement) && paginationElement.ValueKind == JsonValueKind.Object)\n        {\n            pagination = new PaginationInfo\n            {\n                Page = paginationElement.TryGetProperty(\"page\", out var page) ? page.GetInt32() : 0,\n                PageSize = paginationElement.TryGetProperty(\"pageSize\", out var pageSize) ? pageSize.GetInt32() : 0,\n                TotalItems = paginationElement.TryGetProperty(\"totalItems\", out var totalItems) ? totalItems.GetInt32() : 0,\n                TotalPages = paginationElement.TryGetProperty(\"totalPages\", out var totalPages) ? totalPages.GetInt32() : 0,\n                HasNextPage = paginationElement.TryGetProperty(\"hasNextPage\", out var hasNextPage) && hasNextPage.GetBoolean()\n            };\n        }\n\n        return new ListSandboxesResponse\n        {\n            Items = items,\n            Pagination = pagination\n        };\n    }\n\n    private static RenewSandboxExpirationResponse ParseRenewSandboxExpirationResponse(JsonElement element)\n    {\n        DateTime? expiresAt = null;\n        if (element.TryGetProperty(\"expiresAt\", out var expiresAtElement) && expiresAtElement.ValueKind == JsonValueKind.String)\n        {\n            expiresAt = ParseIsoDate(\"expiresAt\", expiresAtElement);\n        }\n\n        return new RenewSandboxExpirationResponse\n        {\n            ExpiresAt = expiresAt\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Adapters/SseParser.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Adapters;\n\n/// <summary>\n/// Parser for Server-Sent Events (SSE) streams.\n/// Supports both standard SSE frames (data: {...}) and newline-delimited JSON.\n/// </summary>\ninternal static class SseParser\n{\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNameCaseInsensitive = true\n    };\n\n    /// <summary>\n    /// Parses an SSE-like stream that may be either:\n    /// - standard SSE frames (data: {...}\\n\\n)\n    /// - newline-delimited JSON (one JSON object per line)\n    /// </summary>\n    /// <typeparam name=\"T\">The type to deserialize each event to.</typeparam>\n    /// <param name=\"response\">The HTTP response to parse.</param>\n    /// <param name=\"fallbackErrorMessage\">Error message to use if parsing fails.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>An async enumerable of parsed events.</returns>\n    public static async IAsyncEnumerable<T> ParseJsonEventStreamAsync<T>(\n        HttpResponseMessage response,\n        string? fallbackErrorMessage = null,\n        [EnumeratorCancellation] CancellationToken cancellationToken = default)\n    {\n        if (!response.IsSuccessStatusCode)\n        {\n            var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n            var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n                ? values.FirstOrDefault()\n                : null;\n\n            object? parsed = null;\n            string? errorMessage = null;\n            string? errorCode = null;\n\n            if (!string.IsNullOrEmpty(text))\n            {\n                try\n                {\n                    parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(text, JsonOptions);\n                    if (parsed is Dictionary<string, object> dict)\n                    {\n                        if (dict.TryGetValue(\"message\", out var msg))\n                            errorMessage = msg?.ToString();\n                        if (dict.TryGetValue(\"code\", out var code))\n                            errorCode = code?.ToString();\n                    }\n                }\n                catch\n                {\n                    // Ignore JSON parse errors\n                }\n            }\n\n            var message = errorMessage ?? fallbackErrorMessage ?? $\"Stream request failed (status={(int)response.StatusCode})\";\n            var sandboxErrorCode = errorCode ?? SandboxErrorCodes.UnexpectedResponse;\n\n            throw new SandboxApiException(\n                message: message,\n                statusCode: (int)response.StatusCode,\n                requestId: requestId,\n                rawBody: parsed ?? text,\n                error: new SandboxError(sandboxErrorCode, errorMessage ?? message));\n        }\n\n        var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);\n        using var reader = new StreamReader(stream, Encoding.UTF8);\n\n        while (true)\n        {\n            cancellationToken.ThrowIfCancellationRequested();\n\n#if NET7_0_OR_GREATER\n            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);\n#else\n            var line = await reader.ReadLineAsync().ConfigureAwait(false);\n#endif\n            if (line == null)\n                break;\n\n            var trimmedLine = line.Trim();\n\n            // Skip empty lines\n            if (string.IsNullOrEmpty(trimmedLine))\n                continue;\n\n            // Skip SSE comments\n            if (trimmedLine.StartsWith(\":\"))\n                continue;\n\n            // Skip SSE metadata lines\n            if (trimmedLine.StartsWith(\"event:\", StringComparison.OrdinalIgnoreCase) ||\n                trimmedLine.StartsWith(\"id:\", StringComparison.OrdinalIgnoreCase) ||\n                trimmedLine.StartsWith(\"retry:\", StringComparison.OrdinalIgnoreCase))\n                continue;\n\n            // Extract JSON from SSE data line or use as-is for NDJSON\n            var jsonLine = trimmedLine.StartsWith(\"data:\", StringComparison.OrdinalIgnoreCase)\n                ? trimmedLine.Substring(5).Trim()\n                : trimmedLine;\n\n            if (string.IsNullOrEmpty(jsonLine))\n                continue;\n\n            var parsedEvent = TryParseJson<T>(jsonLine);\n            if (parsedEvent != null)\n            {\n                yield return parsedEvent;\n            }\n        }\n    }\n\n    private static T? TryParseJson<T>(string json)\n    {\n        try\n        {\n            return JsonSerializer.Deserialize<T>(json, JsonOptions);\n        }\n        catch\n        {\n            return default;\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Config/ConnectionConfig.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Config;\n\n/// <summary>\n/// Connection protocol for the OpenSandbox API.\n/// </summary>\npublic enum ConnectionProtocol\n{\n    /// <summary>\n    /// HTTP protocol.\n    /// </summary>\n    Http,\n\n    /// <summary>\n    /// HTTPS protocol.\n    /// </summary>\n    Https\n}\n\n/// <summary>\n/// Options for configuring a <see cref=\"ConnectionConfig\"/>.\n/// </summary>\npublic class ConnectionConfigOptions\n{\n    /// <summary>\n    /// Gets or sets the API server domain (host[:port]) without scheme.\n    /// Examples: \"localhost:8080\", \"api.opensandbox.io\"\n    /// You may also pass a full URL (e.g. \"http://localhost:8080\" or \"https://api.example.com\").\n    /// </summary>\n    public string? Domain { get; set; }\n\n    /// <summary>\n    /// Gets or sets the connection protocol (http or https).\n    /// </summary>\n    public ConnectionProtocol? Protocol { get; set; }\n\n    /// <summary>\n    /// Gets or sets the API key for authentication.\n    /// </summary>\n    public string? ApiKey { get; set; }\n\n    /// <summary>\n    /// Gets or sets additional headers to include in requests.\n    /// </summary>\n    public Dictionary<string, string>? Headers { get; set; }\n\n    /// <summary>\n    /// Gets or sets the request timeout in seconds.\n    /// Defaults to 30 seconds.\n    /// </summary>\n    public int? RequestTimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether to use server-proxied endpoint URLs.\n    /// </summary>\n    public bool? UseServerProxy { get; set; }\n}\n\n/// <summary>\n/// Configuration for connecting to the OpenSandbox API.\n/// </summary>\n/// <remarks>\n/// This type is thread-safe for concurrent reads and lazy <see cref=\"GetHttpClient\"/> initialization.\n/// The HttpClient returned by <see cref=\"GetHttpClient\"/> is shared per <see cref=\"ConnectionConfig\"/> instance.\n/// </remarks>\npublic sealed class ConnectionConfig\n{\n    /// <summary>\n    /// Gets the connection protocol.\n    /// </summary>\n    public ConnectionProtocol Protocol { get; }\n\n    /// <summary>\n    /// Gets the API server domain.\n    /// </summary>\n    public string Domain { get; }\n\n    /// <summary>\n    /// Gets the API key for authentication.\n    /// </summary>\n    public string? ApiKey { get; }\n\n    /// <summary>\n    /// Gets the additional headers to include in requests.\n    /// </summary>\n    public IReadOnlyDictionary<string, string> Headers { get; }\n\n    /// <summary>\n    /// Gets the request timeout in seconds.\n    /// </summary>\n    public int RequestTimeoutSeconds { get; }\n\n    /// <summary>\n    /// Gets whether server-proxied endpoint URLs should be requested.\n    /// </summary>\n    public bool UseServerProxy { get; }\n\n    /// <summary>\n    /// Gets the user agent string.\n    /// </summary>\n    public string UserAgent { get; } = Constants.DefaultUserAgent;\n\n    private HttpClient? _httpClient;\n    private readonly object _httpClientLock = new();\n\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"ConnectionConfig\"/> class.\n    /// </summary>\n    /// <param name=\"options\">The configuration options.</param>\n    public ConnectionConfig(ConnectionConfigOptions? options = null)\n    {\n        options ??= new ConnectionConfigOptions();\n\n        var envDomain = Environment.GetEnvironmentVariable(Constants.EnvDomain);\n        var envApiKey = Environment.GetEnvironmentVariable(Constants.EnvApiKey);\n\n        var rawDomain = options.Domain ?? envDomain ?? \"localhost:8080\";\n        var (protocol, domainBase) = NormalizeDomainBase(rawDomain);\n\n        Protocol = protocol ?? options.Protocol ?? ConnectionProtocol.Http;\n        Domain = domainBase;\n        ApiKey = options.ApiKey ?? envApiKey;\n        RequestTimeoutSeconds = options.RequestTimeoutSeconds ?? Constants.DefaultRequestTimeoutSeconds;\n        UseServerProxy = options.UseServerProxy ?? false;\n\n        var headers = new Dictionary<string, string>(options.Headers ?? new Dictionary<string, string>());\n\n        // Add API key header if not already present\n        if (!string.IsNullOrEmpty(ApiKey) && !headers.ContainsKey(Constants.ApiKeyHeader))\n        {\n            headers[Constants.ApiKeyHeader] = ApiKey;\n        }\n\n        Headers = headers;\n    }\n\n    /// <summary>\n    /// Gets the base URL for API requests.\n    /// </summary>\n    /// <returns>The base URL including the /v1 prefix.</returns>\n    public string GetBaseUrl()\n    {\n        if (Domain.StartsWith(\"http://\", StringComparison.OrdinalIgnoreCase) ||\n            Domain.StartsWith(\"https://\", StringComparison.OrdinalIgnoreCase))\n        {\n            return $\"{StripV1Suffix(Domain)}/v1\";\n        }\n\n        var scheme = Protocol == ConnectionProtocol.Https ? \"https\" : \"http\";\n        return $\"{scheme}://{StripV1Suffix(Domain)}/v1\";\n    }\n\n    /// <summary>\n    /// Gets or creates an HttpClient configured for this connection.\n    /// </summary>\n    /// <returns>A configured HttpClient instance.</returns>\n    public HttpClient GetHttpClient()\n    {\n        if (_httpClient != null)\n        {\n            return _httpClient;\n        }\n\n        lock (_httpClientLock)\n        {\n            if (_httpClient != null)\n            {\n                return _httpClient;\n            }\n\n            _httpClient = CreateHttpClient();\n            return _httpClient;\n        }\n    }\n\n    /// <summary>\n    /// Creates a new HttpClient configured for this connection.\n    /// </summary>\n    /// <returns>A new configured HttpClient instance.</returns>\n    public HttpClient CreateHttpClient()\n    {\n        var handler = new HttpClientHandler\n        {\n            AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate\n        };\n\n        var client = new HttpClient(handler)\n        {\n            Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)\n        };\n\n        // Set default headers\n        client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);\n\n        foreach (var header in Headers)\n        {\n            if (!client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value))\n            {\n                // Some headers need to be added differently\n                if (header.Key.Equals(\"Content-Type\", StringComparison.OrdinalIgnoreCase))\n                {\n                    continue; // Content-Type is set per request\n                }\n            }\n        }\n\n        return client;\n    }\n\n    /// <summary>\n    /// Creates a new HttpClient configured for SSE (Server-Sent Events) streaming.\n    /// This client has no timeout to allow for long-running streams.\n    /// </summary>\n    /// <returns>A new configured HttpClient instance for SSE.</returns>\n    public HttpClient CreateSseHttpClient()\n    {\n        var handler = new HttpClientHandler\n        {\n            AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate\n        };\n\n        var client = new HttpClient(handler)\n        {\n            Timeout = Timeout.InfiniteTimeSpan\n        };\n\n        // Set default headers\n        client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);\n\n        foreach (var header in Headers)\n        {\n            client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);\n        }\n\n        return client;\n    }\n\n    private static (ConnectionProtocol?, string) NormalizeDomainBase(string input)\n    {\n        // Accept a full URL and preserve its path prefix (if any)\n        if (input.StartsWith(\"http://\", StringComparison.OrdinalIgnoreCase) ||\n            input.StartsWith(\"https://\", StringComparison.OrdinalIgnoreCase))\n        {\n            var uri = new Uri(input);\n            var protocol = uri.Scheme.Equals(\"https\", StringComparison.OrdinalIgnoreCase)\n                ? ConnectionProtocol.Https\n                : ConnectionProtocol.Http;\n\n            var baseUrl = $\"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}\";\n            return (protocol, StripV1Suffix(baseUrl.TrimEnd('/')));\n        }\n\n        // No scheme: treat as \"host[:port]\" or \"host[:port]/prefix\"\n        return (null, StripV1Suffix(input.TrimEnd('/')));\n    }\n\n    private static string StripV1Suffix(string s)\n    {\n        var trimmed = s.TrimEnd('/');\n        return trimmed.EndsWith(\"/v1\", StringComparison.OrdinalIgnoreCase)\n            ? trimmed[..^3]\n            : trimmed;\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Config/DiagnosticsOptions.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.Config;\n\n/// <summary>\n/// Diagnostics options for SDK logging.\n/// </summary>\npublic sealed class SdkDiagnosticsOptions\n{\n    /// <summary>\n    /// Gets or sets the logger factory used by the SDK.\n    /// </summary>\n    public ILoggerFactory? LoggerFactory { get; set; }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Core/Constants.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nnamespace OpenSandbox.Core;\n\n/// <summary>\n/// Default constants used throughout the OpenSandbox SDK.\n/// </summary>\npublic static class Constants\n{\n    /// <summary>\n    /// Default port for the execd service.\n    /// </summary>\n    public const int DefaultExecdPort = 44772;\n\n    /// <summary>\n    /// Default port for the egress sidecar service.\n    /// </summary>\n    public const int DefaultEgressPort = 18080;\n\n    /// <summary>\n    /// Default entrypoint command for sandbox containers.\n    /// </summary>\n    public static readonly string[] DefaultEntrypoint = new[] { \"tail\", \"-f\", \"/dev/null\" };\n\n    /// <summary>\n    /// Default resource limits for sandbox containers.\n    /// </summary>\n    public static readonly IReadOnlyDictionary<string, string> DefaultResourceLimits = new Dictionary<string, string>\n    {\n        [\"cpu\"] = \"1\",\n        [\"memory\"] = \"2Gi\"\n    };\n\n    /// <summary>\n    /// Default sandbox timeout in seconds (10 minutes).\n    /// </summary>\n    public const int DefaultTimeoutSeconds = 600;\n\n    /// <summary>\n    /// Default timeout for waiting until sandbox is ready in seconds.\n    /// </summary>\n    public const int DefaultReadyTimeoutSeconds = 30;\n\n    /// <summary>\n    /// Default polling interval for health checks in milliseconds.\n    /// </summary>\n    public const int DefaultHealthCheckPollingIntervalMillis = 200;\n\n    /// <summary>\n    /// Default HTTP request timeout in seconds.\n    /// </summary>\n    public const int DefaultRequestTimeoutSeconds = 30;\n\n    /// <summary>\n    /// Default user agent string for SDK HTTP requests.\n    /// </summary>\n    public const string DefaultUserAgent = \"OpenSandbox-CSharp-SDK/0.1.0\";\n\n    /// <summary>\n    /// Environment variable name for the OpenSandbox domain.\n    /// </summary>\n    public const string EnvDomain = \"OPEN_SANDBOX_DOMAIN\";\n\n    /// <summary>\n    /// Environment variable name for the OpenSandbox API key.\n    /// </summary>\n    public const string EnvApiKey = \"OPEN_SANDBOX_API_KEY\";\n\n    /// <summary>\n    /// Header name for the API key.\n    /// </summary>\n    public const string ApiKeyHeader = \"OPEN-SANDBOX-API-KEY\";\n\n    /// <summary>\n    /// Header name for request ID.\n    /// </summary>\n    public const string RequestIdHeader = \"x-request-id\";\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Core/Exceptions.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nnamespace OpenSandbox.Core;\n\n/// <summary>\n/// Error codes used by the OpenSandbox SDK.\n/// </summary>\npublic static class SandboxErrorCodes\n{\n    /// <summary>\n    /// An internal unknown error occurred.\n    /// </summary>\n    public const string InternalUnknownError = \"INTERNAL_UNKNOWN_ERROR\";\n\n    /// <summary>\n    /// Timeout waiting for sandbox to become ready.\n    /// </summary>\n    public const string ReadyTimeout = \"READY_TIMEOUT\";\n\n    /// <summary>\n    /// Sandbox is unhealthy.\n    /// </summary>\n    public const string Unhealthy = \"UNHEALTHY\";\n\n    /// <summary>\n    /// Invalid argument provided.\n    /// </summary>\n    public const string InvalidArgument = \"INVALID_ARGUMENT\";\n\n    /// <summary>\n    /// Unexpected response from the server.\n    /// </summary>\n    public const string UnexpectedResponse = \"UNEXPECTED_RESPONSE\";\n}\n\n/// <summary>\n/// Structured error payload carried by <see cref=\"SandboxException\"/>.\n/// </summary>\npublic sealed class SandboxError\n{\n    /// <summary>\n    /// Gets the stable programmatic error code.\n    /// </summary>\n    public string Code { get; }\n\n    /// <summary>\n    /// Gets the optional human-readable error message.\n    /// </summary>\n    public string? Message { get; }\n\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxError\"/> class.\n    /// </summary>\n    /// <param name=\"code\">The error code.</param>\n    /// <param name=\"message\">The optional error message.</param>\n    public SandboxError(string code, string? message = null)\n    {\n        Code = code ?? throw new ArgumentNullException(nameof(code));\n        Message = message;\n    }\n\n    /// <inheritdoc />\n    public override string ToString() => Message != null ? $\"[{Code}] {Message}\" : $\"[{Code}]\";\n}\n\n/// <summary>\n/// Base exception class for all OpenSandbox SDK errors.\n/// </summary>\npublic class SandboxException : Exception\n{\n    /// <summary>\n    /// Gets the structured error information.\n    /// </summary>\n    public SandboxError Error { get; }\n\n    /// <summary>\n    /// Gets the request ID from the server response when available.\n    /// </summary>\n    public string? RequestId { get; }\n\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxException\"/> class.\n    /// Kept for binary compatibility with previous SDK versions.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    /// <param name=\"error\">The structured error information.</param>\n    public SandboxException(\n        string? message,\n        Exception? innerException,\n        SandboxError? error)\n        : this(message, innerException, error, null)\n    {\n    }\n\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    /// <param name=\"error\">The structured error information.</param>\n    /// <param name=\"requestId\">The request ID.</param>\n    public SandboxException(\n        string? message = null,\n        Exception? innerException = null,\n        SandboxError? error = null,\n        string? requestId = null)\n        : base(message ?? error?.Message, innerException)\n    {\n        Error = error ?? new SandboxError(SandboxErrorCodes.InternalUnknownError, message);\n        RequestId = requestId;\n    }\n}\n\n/// <summary>\n/// Exception thrown when an API request fails.\n/// </summary>\npublic class SandboxApiException : SandboxException\n{\n    /// <summary>\n    /// Gets the HTTP status code of the failed request.\n    /// </summary>\n    public int? StatusCode { get; }\n\n    /// <summary>\n    /// Gets the request ID from the server response when available.\n    /// Kept on the derived type for binary compatibility with older releases.\n    /// </summary>\n    public new string? RequestId => base.RequestId;\n\n    /// <summary>\n    /// Gets the raw response body.\n    /// </summary>\n    public object? RawBody { get; }\n\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxApiException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"statusCode\">The HTTP status code.</param>\n    /// <param name=\"requestId\">The request ID.</param>\n    /// <param name=\"rawBody\">The raw response body.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    /// <param name=\"error\">The structured error information.</param>\n    public SandboxApiException(\n        string? message = null,\n        int? statusCode = null,\n        string? requestId = null,\n        object? rawBody = null,\n        Exception? innerException = null,\n        SandboxError? error = null)\n        : base(message, innerException, error ?? new SandboxError(SandboxErrorCodes.UnexpectedResponse, message), requestId)\n    {\n        StatusCode = statusCode;\n        RawBody = rawBody;\n    }\n}\n\n/// <summary>\n/// Exception thrown when an internal SDK error occurs.\n/// </summary>\npublic class SandboxInternalException : SandboxException\n{\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxInternalException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    public SandboxInternalException(string? message = null, Exception? innerException = null)\n        : base(message, innerException, new SandboxError(SandboxErrorCodes.InternalUnknownError, message))\n    {\n    }\n}\n\n/// <summary>\n/// Exception thrown when a sandbox is unhealthy.\n/// </summary>\npublic class SandboxUnhealthyException : SandboxException\n{\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxUnhealthyException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    public SandboxUnhealthyException(string? message = null, Exception? innerException = null)\n        : base(message, innerException, new SandboxError(SandboxErrorCodes.Unhealthy, message))\n    {\n    }\n}\n\n/// <summary>\n/// Exception thrown when waiting for sandbox readiness times out.\n/// </summary>\npublic class SandboxReadyTimeoutException : SandboxException\n{\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"SandboxReadyTimeoutException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    public SandboxReadyTimeoutException(string? message = null, Exception? innerException = null)\n        : base(message, innerException, new SandboxError(SandboxErrorCodes.ReadyTimeout, message))\n    {\n    }\n}\n\n/// <summary>\n/// Exception thrown when an invalid argument is provided.\n/// </summary>\npublic class InvalidArgumentException : SandboxException\n{\n    /// <summary>\n    /// Initializes a new instance of the <see cref=\"InvalidArgumentException\"/> class.\n    /// </summary>\n    /// <param name=\"message\">The error message.</param>\n    /// <param name=\"innerException\">The inner exception.</param>\n    public InvalidArgumentException(string? message = null, Exception? innerException = null)\n        : base(message, innerException, new SandboxError(SandboxErrorCodes.InvalidArgument, message))\n    {\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Adapters;\nusing OpenSandbox.Internal;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.Factory;\n\n/// <summary>\n/// Default implementation of the adapter factory.\n/// </summary>\npublic sealed class DefaultAdapterFactory : IAdapterFactory\n{\n    /// <summary>\n    /// Creates a new instance of the default adapter factory.\n    /// </summary>\n    /// <returns>A new adapter factory instance.</returns>\n    public static IAdapterFactory Create() => new DefaultAdapterFactory();\n\n    /// <inheritdoc />\n    public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options)\n    {\n        var clientWrapper = new HttpClientWrapper(\n            options.HttpClientProvider.HttpClient,\n            options.LifecycleBaseUrl,\n            options.ConnectionConfig.Headers,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.HttpClientWrapper\"));\n\n        var sandboxes = new SandboxesAdapter(clientWrapper);\n\n        return new LifecycleStack\n        {\n            Sandboxes = sandboxes\n        };\n    }\n\n    /// <inheritdoc />\n    public ExecdStack CreateExecdStack(CreateExecdStackOptions options)\n    {\n        var headers = options.ExecdHeaders ?? options.ConnectionConfig.Headers;\n\n        var clientWrapper = new HttpClientWrapper(\n            options.HttpClientProvider.HttpClient,\n            options.ExecdBaseUrl,\n            headers,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.HttpClientWrapper\"));\n\n        var health = new HealthAdapter(clientWrapper);\n        var metrics = new MetricsAdapter(clientWrapper);\n        var files = new FilesystemAdapter(\n            clientWrapper,\n            options.HttpClientProvider.HttpClient,\n            options.ExecdBaseUrl,\n            headers);\n        var commands = new CommandsAdapter(\n            clientWrapper,\n            options.HttpClientProvider.SseHttpClient,\n            options.ExecdBaseUrl,\n            headers,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.CommandsAdapter\"));\n\n        return new ExecdStack\n        {\n            Commands = commands,\n            Files = files,\n            Health = health,\n            Metrics = metrics\n        };\n    }\n\n    /// <inheritdoc />\n    public EgressStack CreateEgressStack(CreateEgressStackOptions options)\n    {\n        var headers = options.EgressHeaders ?? options.ConnectionConfig.Headers;\n\n        var clientWrapper = new HttpClientWrapper(\n            options.HttpClientProvider.HttpClient,\n            options.EgressBaseUrl,\n            headers,\n            options.LoggerFactory.CreateLogger(\"OpenSandbox.HttpClientWrapper\"));\n\n        return new EgressStack\n        {\n            Egress = new EgressAdapter(clientWrapper)\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing OpenSandbox.Services;\nusing OpenSandbox;\nusing Microsoft.Extensions.Logging;\n\nnamespace OpenSandbox.Factory;\n\n/// <summary>\n/// Options for creating a lifecycle service stack.\n/// </summary>\npublic class CreateLifecycleStackOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public required ConnectionConfig ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets the lifecycle API base URL.\n    /// </summary>\n    public required string LifecycleBaseUrl { get; set; }\n\n    /// <summary>\n    /// Gets or sets the HTTP client provider for this SDK instance.\n    /// </summary>\n    public required HttpClientProvider HttpClientProvider { get; set; }\n\n    /// <summary>\n    /// Gets or sets the logger factory for this SDK instance.\n    /// </summary>\n    public required ILoggerFactory LoggerFactory { get; set; }\n}\n\n/// <summary>\n/// Options for creating an execd service stack.\n/// </summary>\npublic class CreateExecdStackOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public required ConnectionConfig ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execd API base URL.\n    /// </summary>\n    public required string ExecdBaseUrl { get; set; }\n\n    /// <summary>\n    /// Gets or sets headers to apply to execd requests.\n    /// If null, <see cref=\"ConnectionConfig.Headers\"/> is used.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? ExecdHeaders { get; set; }\n\n    /// <summary>\n    /// Gets or sets the HTTP client provider for this SDK instance.\n    /// </summary>\n    public required HttpClientProvider HttpClientProvider { get; set; }\n\n    /// <summary>\n    /// Gets or sets the logger factory for this SDK instance.\n    /// </summary>\n    public required ILoggerFactory LoggerFactory { get; set; }\n}\n\n/// <summary>\n/// Stack of lifecycle services.\n/// </summary>\npublic class LifecycleStack\n{\n    /// <summary>\n    /// Gets the sandboxes service.\n    /// </summary>\n    public required ISandboxes Sandboxes { get; init; }\n}\n\n/// <summary>\n/// Stack of execd services.\n/// </summary>\npublic class ExecdStack\n{\n    /// <summary>\n    /// Gets the commands service.\n    /// </summary>\n    public required IExecdCommands Commands { get; init; }\n\n    /// <summary>\n    /// Gets the files service.\n    /// </summary>\n    public required ISandboxFiles Files { get; init; }\n\n    /// <summary>\n    /// Gets the health service.\n    /// </summary>\n    public required IExecdHealth Health { get; init; }\n\n    /// <summary>\n    /// Gets the metrics service.\n    /// </summary>\n    public required IExecdMetrics Metrics { get; init; }\n}\n\npublic class CreateEgressStackOptions\n{\n    public required ConnectionConfig ConnectionConfig { get; set; }\n\n    public required string EgressBaseUrl { get; set; }\n\n    public IReadOnlyDictionary<string, string>? EgressHeaders { get; set; }\n\n    public required HttpClientProvider HttpClientProvider { get; set; }\n\n    public required ILoggerFactory LoggerFactory { get; set; }\n}\n\npublic class EgressStack\n{\n    public required IEgress Egress { get; init; }\n}\n\n/// <summary>\n/// Factory interface for creating service adapters.\n/// </summary>\npublic interface IAdapterFactory\n{\n    /// <summary>\n    /// Creates a lifecycle service stack.\n    /// </summary>\n    /// <param name=\"options\">The creation options.</param>\n    /// <returns>The lifecycle stack.</returns>\n    LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options);\n\n    /// <summary>\n    /// Creates an execd service stack.\n    /// </summary>\n    /// <param name=\"options\">The creation options.</param>\n    /// <returns>The execd stack.</returns>\n    ExecdStack CreateExecdStack(CreateExecdStackOptions options);\n\n    /// <summary>\n    /// Creates an egress service stack.\n    /// </summary>\n    /// <param name=\"options\">The creation options.</param>\n    /// <returns>The egress stack.</returns>\n    EgressStack CreateEgressStack(CreateEgressStackOptions options);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/HttpClientProvider.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace OpenSandbox;\n\n/// <summary>\n/// Provides the HTTP clients used by a sandbox SDK instance.\n/// </summary>\npublic sealed class HttpClientProvider : IDisposable\n{\n    private bool _disposed;\n    private readonly ILogger _logger;\n\n    internal HttpClientProvider(ConnectionConfig connectionConfig, ILoggerFactory loggerFactory)\n    {\n        _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(\"OpenSandbox.HttpClientProvider\");\n        _logger.LogDebug(\"Creating HTTP clients for SDK instance\");\n        HttpClient = connectionConfig.CreateHttpClient();\n        SseHttpClient = connectionConfig.CreateSseHttpClient();\n    }\n\n    /// <summary>\n    /// Gets the HTTP client used for non-streaming requests.\n    /// </summary>\n    public HttpClient HttpClient { get; }\n\n    /// <summary>\n    /// Gets the HTTP client used for streaming requests.\n    /// </summary>\n    public HttpClient SseHttpClient { get; }\n\n    /// <summary>\n    /// Releases HTTP client resources.\n    /// </summary>\n    public void Dispose()\n    {\n        if (_disposed)\n        {\n            return;\n        }\n\n        _disposed = true;\n        _logger.LogDebug(\"Disposing HTTP clients for SDK instance\");\n        HttpClient.Dispose();\n        SseHttpClient.Dispose();\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Internal/ExecutionEventDispatcher.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\n\nnamespace OpenSandbox.Internal;\n\n/// <summary>\n/// Dispatches streamed execution events to handlers and builds the execution result.\n/// </summary>\ninternal sealed class ExecutionEventDispatcher\n{\n    private readonly Execution _execution;\n    private readonly ExecutionHandlers? _handlers;\n\n    public ExecutionEventDispatcher(Execution execution, ExecutionHandlers? handlers = null)\n    {\n        _execution = execution ?? throw new ArgumentNullException(nameof(execution));\n        _handlers = handlers;\n    }\n\n    public async Task DispatchAsync(ServerStreamEvent ev)\n    {\n        var timestamp = ev.Timestamp ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n\n        switch (ev.Type)\n        {\n            case ServerStreamEventTypes.Init:\n                await HandleInitAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n\n            case ServerStreamEventTypes.Stdout:\n                await HandleStdoutAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n\n            case ServerStreamEventTypes.Stderr:\n                await HandleStderrAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n\n            case ServerStreamEventTypes.Result:\n                await HandleResultAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n\n            case ServerStreamEventTypes.ExecutionCount:\n                HandleExecutionCount(ev);\n                break;\n\n            case ServerStreamEventTypes.ExecutionComplete:\n                await HandleExecutionCompleteAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n\n            case ServerStreamEventTypes.Error:\n                await HandleErrorAsync(ev, timestamp).ConfigureAwait(false);\n                break;\n        }\n    }\n\n    private async Task HandleInitAsync(ServerStreamEvent ev, long timestamp)\n    {\n        var id = ev.Text ?? string.Empty;\n        if (!string.IsNullOrEmpty(id))\n        {\n            _execution.Id = id;\n        }\n\n        var init = new ExecutionInit\n        {\n            Id = id,\n            Timestamp = timestamp\n        };\n\n        if (_handlers?.OnInit != null)\n        {\n            await _handlers.OnInit(init).ConfigureAwait(false);\n        }\n    }\n\n    private async Task HandleStdoutAsync(ServerStreamEvent ev, long timestamp)\n    {\n        var msg = new OutputMessage\n        {\n            Text = ev.Text ?? string.Empty,\n            Timestamp = timestamp,\n            IsError = false\n        };\n\n        _execution.Logs.Stdout.Add(msg);\n\n        if (_handlers?.OnStdout != null)\n        {\n            await _handlers.OnStdout(msg).ConfigureAwait(false);\n        }\n    }\n\n    private async Task HandleStderrAsync(ServerStreamEvent ev, long timestamp)\n    {\n        var msg = new OutputMessage\n        {\n            Text = ev.Text ?? string.Empty,\n            Timestamp = timestamp,\n            IsError = true\n        };\n\n        _execution.Logs.Stderr.Add(msg);\n\n        if (_handlers?.OnStderr != null)\n        {\n            await _handlers.OnStderr(msg).ConfigureAwait(false);\n        }\n    }\n\n    private async Task HandleResultAsync(ServerStreamEvent ev, long timestamp)\n    {\n        var text = ExtractText(ev.Results);\n        var result = new ExecutionResult\n        {\n            Text = text,\n            Timestamp = timestamp,\n            Raw = ev.Results?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)\n        };\n\n        _execution.Results.Add(result);\n\n        if (_handlers?.OnResult != null)\n        {\n            await _handlers.OnResult(result).ConfigureAwait(false);\n        }\n    }\n\n    private void HandleExecutionCount(ServerStreamEvent ev)\n    {\n        if (ev.ExecutionCount.HasValue)\n        {\n            _execution.ExecutionCount = ev.ExecutionCount.Value;\n        }\n    }\n\n    private async Task HandleExecutionCompleteAsync(ServerStreamEvent ev, long timestamp)\n    {\n        var complete = new ExecutionComplete\n        {\n            Timestamp = timestamp,\n            ExecutionTimeMs = ev.ExecutionTime ?? 0\n        };\n\n        _execution.Complete = complete;\n\n        if (_handlers?.OnExecutionComplete != null)\n        {\n            await _handlers.OnExecutionComplete(complete).ConfigureAwait(false);\n        }\n    }\n\n    private async Task HandleErrorAsync(ServerStreamEvent ev, long timestamp)\n    {\n        if (ev.Error == null)\n            return;\n\n        var error = new ExecutionError\n        {\n            Name = GetStringValue(ev.Error, \"ename\") ?? GetStringValue(ev.Error, \"name\") ?? string.Empty,\n            Value = GetStringValue(ev.Error, \"evalue\") ?? GetStringValue(ev.Error, \"value\") ?? string.Empty,\n            Timestamp = timestamp,\n            Traceback = GetStringArrayValue(ev.Error, \"traceback\") ?? Array.Empty<string>()\n        };\n\n        _execution.Error = error;\n\n        if (_handlers?.OnError != null)\n        {\n            await _handlers.OnError(error).ConfigureAwait(false);\n        }\n    }\n\n    private static string? ExtractText(Dictionary<string, object>? results)\n    {\n        if (results == null)\n            return null;\n\n        if (results.TryGetValue(\"text/plain\", out var textPlain))\n            return textPlain?.ToString();\n\n        if (results.TryGetValue(\"text\", out var text))\n            return text?.ToString();\n\n        if (results.TryGetValue(\"textPlain\", out var textPlain2))\n            return textPlain2?.ToString();\n\n        return null;\n    }\n\n    private static string? GetStringValue(Dictionary<string, object> dict, string key)\n    {\n        if (dict.TryGetValue(key, out var value))\n            return value?.ToString();\n        return null;\n    }\n\n    private static IReadOnlyList<string>? GetStringArrayValue(Dictionary<string, object> dict, string key)\n    {\n        if (!dict.TryGetValue(key, out var value))\n            return null;\n\n        if (value is IEnumerable<object> enumerable)\n            return enumerable.Select(x => x?.ToString() ?? string.Empty).ToList();\n\n        if (value is System.Text.Json.JsonElement jsonElement && jsonElement.ValueKind == System.Text.Json.JsonValueKind.Array)\n            return jsonElement.EnumerateArray().Select(x => x.GetString() ?? string.Empty).ToList();\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text;\nusing System.Text.Json;\nusing OpenSandbox.Core;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace OpenSandbox.Internal;\n\n/// <summary>\n/// Internal HTTP client wrapper for making API requests.\n/// </summary>\ninternal sealed class HttpClientWrapper\n{\n    private readonly HttpClient _httpClient;\n    private readonly string _baseUrl;\n    private readonly IReadOnlyDictionary<string, string> _defaultHeaders;\n    private readonly ILogger _logger;\n\n    private static readonly JsonSerializerOptions JsonOptions = new()\n    {\n        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,\n        PropertyNameCaseInsensitive = true,\n        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n    };\n\n    public HttpClientWrapper(\n        HttpClient httpClient,\n        string baseUrl,\n        IReadOnlyDictionary<string, string>? defaultHeaders = null,\n        ILogger? logger = null)\n    {\n        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));\n        _baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));\n        _defaultHeaders = defaultHeaders ?? new Dictionary<string, string>();\n        _logger = logger ?? NullLogger.Instance;\n    }\n\n    public string BaseUrl => _baseUrl;\n\n    public async Task<T> GetAsync<T>(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        _logger.LogDebug(\"HTTP GET {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        ApplyDefaultHeaders(request);\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        return await HandleResponseAsync<T>(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task GetAsync(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        _logger.LogDebug(\"HTTP GET {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        ApplyDefaultHeaders(request);\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<T> PostAsync<T>(\n        string path,\n        object? body = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path);\n        _logger.LogDebug(\"HTTP POST {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Post, url);\n        ApplyDefaultHeaders(request);\n\n        if (body != null)\n        {\n            var json = JsonSerializer.Serialize(body, JsonOptions);\n            request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        return await HandleResponseAsync<T>(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task PostAsync(\n        string path,\n        object? body = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path);\n        _logger.LogDebug(\"HTTP POST {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Post, url);\n        ApplyDefaultHeaders(request);\n\n        if (body != null)\n        {\n            var json = JsonSerializer.Serialize(body, JsonOptions);\n            request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<T> PatchAsync<T>(\n        string path,\n        object? body = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path);\n        _logger.LogDebug(\"HTTP PATCH {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Patch, url);\n        ApplyDefaultHeaders(request);\n\n        if (body != null)\n        {\n            var json = JsonSerializer.Serialize(body, JsonOptions);\n            request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        return await HandleResponseAsync<T>(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task PatchAsync(\n        string path,\n        object? body = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path);\n        _logger.LogDebug(\"HTTP PATCH {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Patch, url);\n        ApplyDefaultHeaders(request);\n\n        if (body != null)\n        {\n            var json = JsonSerializer.Serialize(body, JsonOptions);\n            request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<T> DeleteAsync<T>(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        _logger.LogDebug(\"HTTP DELETE {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Delete, url);\n        ApplyDefaultHeaders(request);\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        return await HandleResponseAsync<T>(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task DeleteAsync(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        _logger.LogDebug(\"HTTP DELETE {Url}\", url);\n        using var request = new HttpRequestMessage(HttpMethod.Delete, url);\n        ApplyDefaultHeaders(request);\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<HttpResponseMessage> SendAsync(\n        HttpRequestMessage request,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogDebug(\"HTTP {Method} {Url}\", request.Method, request.RequestUri);\n        ApplyDefaultHeaders(request);\n        return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);\n    }\n\n    public async Task<byte[]> GetBytesAsync(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        Dictionary<string, string>? headers = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        ApplyDefaultHeaders(request);\n\n        if (headers != null)\n        {\n            foreach (var header in headers)\n            {\n                request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n            }\n        }\n\n        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n        return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);\n    }\n\n    public async Task<Stream> GetStreamAsync(\n        string path,\n        Dictionary<string, string?>? queryParams = null,\n        Dictionary<string, string>? headers = null,\n        CancellationToken cancellationToken = default)\n    {\n        var url = BuildUrl(path, queryParams);\n        using var request = new HttpRequestMessage(HttpMethod.Get, url);\n        ApplyDefaultHeaders(request);\n\n        if (headers != null)\n        {\n            foreach (var header in headers)\n            {\n                request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n            }\n        }\n\n        var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);\n        await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);\n        return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);\n    }\n\n    private string BuildUrl(string path, Dictionary<string, string?>? queryParams = null)\n    {\n        var url = path.StartsWith(\"/\") ? $\"{_baseUrl}{path}\" : $\"{_baseUrl}/{path}\";\n\n        if (queryParams == null || queryParams.Count == 0)\n            return url;\n\n        var queryString = string.Join(\"&\",\n            queryParams\n                .Where(kv => kv.Value != null)\n                .Select(kv => $\"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value!)}\"));\n\n        return string.IsNullOrEmpty(queryString) ? url : $\"{url}?{queryString}\";\n    }\n\n    private void ApplyDefaultHeaders(HttpRequestMessage request)\n    {\n        foreach (var header in _defaultHeaders)\n        {\n            if (!request.Headers.Contains(header.Key))\n            {\n                request.Headers.TryAddWithoutValidation(header.Key, header.Value);\n            }\n        }\n    }\n\n    private async Task<T> HandleResponseAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)\n    {\n        var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n\n        if (!response.IsSuccessStatusCode)\n        {\n            LogHttpFailure(response);\n            ThrowApiException(response, content);\n        }\n\n        if (string.IsNullOrEmpty(content))\n        {\n            throw new SandboxApiException(\n                message: \"Unexpected empty response body\",\n                statusCode: (int)response.StatusCode,\n                error: new SandboxError(SandboxErrorCodes.UnexpectedResponse, \"Unexpected empty response body\"),\n                rawBody: content);\n        }\n\n        try\n        {\n            return JsonSerializer.Deserialize<T>(content, JsonOptions)!;\n        }\n        catch (JsonException ex)\n        {\n            throw new SandboxApiException(\n                message: $\"Failed to deserialize response: {ex.Message}\",\n                statusCode: (int)response.StatusCode,\n                rawBody: content,\n                innerException: ex);\n        }\n    }\n\n    private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)\n    {\n        if (!response.IsSuccessStatusCode)\n        {\n            var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);\n            LogHttpFailure(response);\n            ThrowApiException(response, content);\n        }\n    }\n\n    private void LogHttpFailure(HttpResponseMessage response)\n    {\n        var request = response.RequestMessage;\n        var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n            ? values.FirstOrDefault()\n            : null;\n\n        _logger.LogError(\n            \"HTTP request failed: method={Method}, url={Url}, status={StatusCode}, requestId={RequestId}\",\n            request?.Method.Method ?? \"UNKNOWN\",\n            request?.RequestUri?.ToString() ?? \"UNKNOWN\",\n            (int)response.StatusCode,\n            requestId ?? string.Empty);\n    }\n\n    private static void ThrowApiException(HttpResponseMessage response, string content)\n    {\n        var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)\n            ? values.FirstOrDefault()\n            : null;\n\n        string? errorMessage = null;\n        string? errorCode = null;\n        object? rawBody = content;\n\n        if (!string.IsNullOrEmpty(content))\n        {\n            try\n            {\n                var parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(content, JsonOptions);\n                if (parsed != null)\n                {\n                    rawBody = parsed;\n                    if (parsed.TryGetValue(\"message\", out var msg))\n                        errorMessage = msg.GetString();\n                    if (parsed.TryGetValue(\"error\", out var err) && err.ValueKind == JsonValueKind.Object)\n                    {\n                        if (err.TryGetProperty(\"message\", out var errMsg))\n                            errorMessage = errorMessage ?? errMsg.GetString();\n                        if (err.TryGetProperty(\"code\", out var errCode))\n                            errorCode = errCode.GetString();\n                    }\n                    if (parsed.TryGetValue(\"code\", out var code))\n                        errorCode = errorCode ?? code.GetString();\n                }\n            }\n            catch\n            {\n                // Ignore JSON parse errors\n            }\n        }\n\n        var message = errorMessage ?? $\"Request failed with status code {(int)response.StatusCode}\";\n        var sandboxErrorCode = errorCode ?? SandboxErrorCodes.UnexpectedResponse;\n\n        throw new SandboxApiException(\n            message: message,\n            statusCode: (int)response.StatusCode,\n            requestId: requestId,\n            rawBody: rawBody,\n            error: new SandboxError(sandboxErrorCode, errorMessage ?? message));\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json.Serialization;\n\nnamespace OpenSandbox.Models;\n\n/// <summary>\n/// A server-sent event from command execution.\n/// </summary>\npublic class ServerStreamEvent\n{\n    /// <summary>\n    /// Gets or sets the event type.\n    /// </summary>\n    [JsonPropertyName(\"type\")]\n    public required string Type { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    [JsonPropertyName(\"timestamp\")]\n    public long? Timestamp { get; set; }\n\n    /// <summary>\n    /// Gets or sets the text content.\n    /// </summary>\n    [JsonPropertyName(\"text\")]\n    public string? Text { get; set; }\n\n    /// <summary>\n    /// Gets or sets the results map.\n    /// </summary>\n    [JsonPropertyName(\"results\")]\n    public Dictionary<string, object>? Results { get; set; }\n\n    /// <summary>\n    /// Gets or sets the error information.\n    /// </summary>\n    [JsonPropertyName(\"error\")]\n    public Dictionary<string, object>? Error { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution count.\n    /// </summary>\n    [JsonPropertyName(\"execution_count\")]\n    public int? ExecutionCount { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution time in milliseconds.\n    /// </summary>\n    [JsonPropertyName(\"execution_time\")]\n    public long? ExecutionTime { get; set; }\n}\n\n/// <summary>\n/// Known event types for server stream events.\n/// </summary>\npublic static class ServerStreamEventTypes\n{\n    /// <summary>\n    /// Initialization event.\n    /// </summary>\n    public const string Init = \"init\";\n\n    /// <summary>\n    /// Standard output event.\n    /// </summary>\n    public const string Stdout = \"stdout\";\n\n    /// <summary>\n    /// Standard error event.\n    /// </summary>\n    public const string Stderr = \"stderr\";\n\n    /// <summary>\n    /// Result event.\n    /// </summary>\n    public const string Result = \"result\";\n\n    /// <summary>\n    /// Execution count event.\n    /// </summary>\n    public const string ExecutionCount = \"execution_count\";\n\n    /// <summary>\n    /// Execution complete event.\n    /// </summary>\n    public const string ExecutionComplete = \"execution_complete\";\n\n    /// <summary>\n    /// Error event.\n    /// </summary>\n    public const string Error = \"error\";\n}\n\n/// <summary>\n/// Request to run a command.\n/// </summary>\npublic class RunCommandRequest\n{\n    /// <summary>\n    /// Gets or sets the command to run.\n    /// </summary>\n    [JsonPropertyName(\"command\")]\n    public required string Command { get; set; }\n\n    /// <summary>\n    /// Gets or sets the working directory.\n    /// </summary>\n    [JsonPropertyName(\"cwd\")]\n    public string? Cwd { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether to run in background.\n    /// </summary>\n    [JsonPropertyName(\"background\")]\n    public bool? Background { get; set; }\n\n    /// <summary>\n    /// Gets or sets the maximum execution time in milliseconds.\n    /// </summary>\n    [JsonPropertyName(\"timeout\")]\n    public long? Timeout { get; set; }\n\n    /// <summary>\n    /// Gets or sets the Unix user ID used to run the command process.\n    /// </summary>\n    [JsonPropertyName(\"uid\")]\n    public int? Uid { get; set; }\n\n    /// <summary>\n    /// Gets or sets the Unix group ID used to run the command process.\n    /// Requires <see cref=\"Uid\"/> to be set.\n    /// </summary>\n    [JsonPropertyName(\"gid\")]\n    public int? Gid { get; set; }\n\n    /// <summary>\n    /// Gets or sets environment variables injected into the command process.\n    /// </summary>\n    [JsonPropertyName(\"envs\")]\n    public Dictionary<string, string>? Envs { get; set; }\n}\n\n/// <summary>\n/// Options for running a command.\n/// </summary>\npublic class RunCommandOptions\n{\n    /// <summary>\n    /// Gets or sets the working directory for command execution.\n    /// </summary>\n    public string? WorkingDirectory { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether to run the command in detached mode.\n    /// </summary>\n    public bool Background { get; set; }\n\n    /// <summary>\n    /// Gets or sets the maximum execution time in seconds.\n    /// The server terminates the command when this duration is reached.\n    /// </summary>\n    public int? TimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets the Unix user ID used to run the command process.\n    /// </summary>\n    public int? Uid { get; set; }\n\n    /// <summary>\n    /// Gets or sets the Unix group ID used to run the command process.\n    /// Requires <see cref=\"Uid\"/> to be set.\n    /// </summary>\n    public int? Gid { get; set; }\n\n    /// <summary>\n    /// Gets or sets environment variables injected into the command process.\n    /// </summary>\n    public Dictionary<string, string>? Envs { get; set; }\n}\n\n/// <summary>\n/// Status information for a foreground or background command.\n/// </summary>\npublic class CommandStatus\n{\n    /// <summary>\n    /// Gets or sets the command ID.\n    /// </summary>\n    [JsonPropertyName(\"id\")]\n    public string? Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the original command text.\n    /// </summary>\n    [JsonPropertyName(\"content\")]\n    public string? Content { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether the command is still running.\n    /// </summary>\n    [JsonPropertyName(\"running\")]\n    public bool? Running { get; set; }\n\n    /// <summary>\n    /// Gets or sets the exit code when the command has finished.\n    /// </summary>\n    [JsonPropertyName(\"exit_code\")]\n    public int? ExitCode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the error message if the command failed.\n    /// </summary>\n    [JsonPropertyName(\"error\")]\n    public string? Error { get; set; }\n\n    /// <summary>\n    /// Gets or sets the command start time in RFC3339 format.\n    /// </summary>\n    [JsonPropertyName(\"started_at\")]\n    public DateTime? StartedAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the command finish time in RFC3339 format.\n    /// </summary>\n    [JsonPropertyName(\"finished_at\")]\n    public DateTime? FinishedAt { get; set; }\n}\n\n/// <summary>\n/// Background command logs and incremental cursor.\n/// </summary>\npublic class CommandLogs\n{\n    /// <summary>\n    /// Gets or sets raw stdout/stderr content.\n    /// </summary>\n    public required string Content { get; set; }\n\n    /// <summary>\n    /// Gets or sets the latest cursor for incremental log polling.\n    /// </summary>\n    public long? Cursor { get; set; }\n}\n\n/// <summary>\n/// Supported programming languages for code execution.\n/// </summary>\npublic static class SupportedLanguages\n{\n    /// <summary>\n    /// Python language.\n    /// </summary>\n    public const string Python = \"python\";\n\n    /// <summary>\n    /// Go language.\n    /// </summary>\n    public const string Go = \"go\";\n\n    /// <summary>\n    /// JavaScript language.\n    /// </summary>\n    public const string JavaScript = \"javascript\";\n\n    /// <summary>\n    /// TypeScript language.\n    /// </summary>\n    public const string TypeScript = \"typescript\";\n\n    /// <summary>\n    /// Bash shell.\n    /// </summary>\n    public const string Bash = \"bash\";\n\n    /// <summary>\n    /// Java language.\n    /// </summary>\n    public const string Java = \"java\";\n}\n\n/// <summary>\n/// Raw metrics from the execd service.\n/// </summary>\npublic class Metrics\n{\n    /// <summary>\n    /// Gets or sets the CPU count.\n    /// </summary>\n    [JsonPropertyName(\"cpu_count\")]\n    public int? CpuCount { get; set; }\n\n    /// <summary>\n    /// Gets or sets the CPU usage percentage.\n    /// </summary>\n    [JsonPropertyName(\"cpu_used_pct\")]\n    public double? CpuUsedPct { get; set; }\n\n    /// <summary>\n    /// Gets or sets the total memory in MiB.\n    /// </summary>\n    [JsonPropertyName(\"mem_total_mib\")]\n    public double? MemTotalMib { get; set; }\n\n    /// <summary>\n    /// Gets or sets the used memory in MiB.\n    /// </summary>\n    [JsonPropertyName(\"mem_used_mib\")]\n    public double? MemUsedMib { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp.\n    /// </summary>\n    [JsonPropertyName(\"timestamp\")]\n    public long? Timestamp { get; set; }\n}\n\n/// <summary>\n/// Normalized sandbox metrics.\n/// </summary>\npublic class SandboxMetrics\n{\n    /// <summary>\n    /// Gets or sets the CPU count.\n    /// </summary>\n    public int CpuCount { get; set; }\n\n    /// <summary>\n    /// Gets or sets the CPU usage percentage.\n    /// </summary>\n    public double CpuUsedPercentage { get; set; }\n\n    /// <summary>\n    /// Gets or sets the total memory in MiB.\n    /// </summary>\n    public double MemoryTotalMiB { get; set; }\n\n    /// <summary>\n    /// Gets or sets the used memory in MiB.\n    /// </summary>\n    public double MemoryUsedMiB { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp.\n    /// </summary>\n    public long Timestamp { get; set; }\n}\n\n/// <summary>\n/// Response from ping endpoint.\n/// </summary>\npublic class PingResponse\n{\n    // Empty response - ping just returns 200 OK\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Models/Execution.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json.Serialization;\n\nnamespace OpenSandbox.Models;\n\n/// <summary>\n/// An output message from command execution.\n/// </summary>\npublic class OutputMessage\n{\n    /// <summary>\n    /// Gets or sets the text content.\n    /// </summary>\n    public required string Text { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    public required long Timestamp { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether this is an error message.\n    /// </summary>\n    public bool IsError { get; set; }\n}\n\n/// <summary>\n/// A result from command execution.\n/// </summary>\npublic class ExecutionResult\n{\n    /// <summary>\n    /// Gets or sets the text content.\n    /// </summary>\n    public string? Text { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    public required long Timestamp { get; set; }\n\n    /// <summary>\n    /// Gets or sets the raw mime map from execd event.\n    /// </summary>\n    public IReadOnlyDictionary<string, object>? Raw { get; set; }\n}\n\n/// <summary>\n/// An error from command execution.\n/// </summary>\npublic class ExecutionError\n{\n    /// <summary>\n    /// Gets or sets the error name.\n    /// </summary>\n    public required string Name { get; set; }\n\n    /// <summary>\n    /// Gets or sets the error value.\n    /// </summary>\n    public required string Value { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    public required long Timestamp { get; set; }\n\n    /// <summary>\n    /// Gets or sets the traceback lines.\n    /// </summary>\n    public required IReadOnlyList<string> Traceback { get; set; }\n}\n\n/// <summary>\n/// Completion information for command execution.\n/// </summary>\npublic class ExecutionComplete\n{\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    public required long Timestamp { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution time in milliseconds.\n    /// </summary>\n    public required long ExecutionTimeMs { get; set; }\n}\n\n/// <summary>\n/// Initialization information for command execution.\n/// </summary>\npublic class ExecutionInit\n{\n    /// <summary>\n    /// Gets or sets the execution ID.\n    /// </summary>\n    public required string Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timestamp in milliseconds.\n    /// </summary>\n    public required long Timestamp { get; set; }\n}\n\n/// <summary>\n/// Logs from command execution.\n/// </summary>\npublic class ExecutionLogs\n{\n    /// <summary>\n    /// Gets the stdout messages.\n    /// </summary>\n    public List<OutputMessage> Stdout { get; } = new();\n\n    /// <summary>\n    /// Gets the stderr messages.\n    /// </summary>\n    public List<OutputMessage> Stderr { get; } = new();\n}\n\n/// <summary>\n/// Result of a command execution.\n/// </summary>\npublic class Execution\n{\n    /// <summary>\n    /// Gets or sets the execution ID.\n    /// </summary>\n    public string? Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the execution count.\n    /// </summary>\n    public int? ExecutionCount { get; set; }\n\n    /// <summary>\n    /// Gets the execution logs.\n    /// </summary>\n    public ExecutionLogs Logs { get; } = new();\n\n    /// <summary>\n    /// Gets the execution results.\n    /// </summary>\n    public List<ExecutionResult> Results { get; } = new();\n\n    /// <summary>\n    /// Gets or sets the execution error.\n    /// </summary>\n    public ExecutionError? Error { get; set; }\n\n    /// <summary>\n    /// Gets or sets the completion information.\n    /// </summary>\n    public ExecutionComplete? Complete { get; set; }\n}\n\n/// <summary>\n/// Handlers for execution events.\n/// </summary>\npublic class ExecutionHandlers\n{\n    /// <summary>\n    /// Gets or sets the handler for stdout messages.\n    /// </summary>\n    public Func<OutputMessage, Task>? OnStdout { get; set; }\n\n    /// <summary>\n    /// Gets or sets the handler for stderr messages.\n    /// </summary>\n    public Func<OutputMessage, Task>? OnStderr { get; set; }\n\n    /// <summary>\n    /// Gets or sets the handler for execution results.\n    /// </summary>\n    public Func<ExecutionResult, Task>? OnResult { get; set; }\n\n    /// <summary>\n    /// Gets or sets the handler for execution completion.\n    /// </summary>\n    public Func<ExecutionComplete, Task>? OnExecutionComplete { get; set; }\n\n    /// <summary>\n    /// Gets or sets the handler for execution errors.\n    /// </summary>\n    public Func<ExecutionError, Task>? OnError { get; set; }\n\n    /// <summary>\n    /// Gets or sets the handler for execution initialization.\n    /// </summary>\n    public Func<ExecutionInit, Task>? OnInit { get; set; }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json.Serialization;\n\nnamespace OpenSandbox.Models;\n\n/// <summary>\n/// Information about a file in the sandbox.\n/// </summary>\npublic class SandboxFileInfo\n{\n    /// <summary>\n    /// Gets or sets the file path.\n    /// </summary>\n    [JsonPropertyName(\"path\")]\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file size in bytes.\n    /// </summary>\n    [JsonPropertyName(\"size\")]\n    public long? Size { get; set; }\n\n    /// <summary>\n    /// Gets or sets the last modification time.\n    /// </summary>\n    [JsonPropertyName(\"modified_at\")]\n    public DateTime? ModifiedAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the creation time.\n    /// </summary>\n    [JsonPropertyName(\"created_at\")]\n    public DateTime? CreatedAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file mode (permissions).\n    /// </summary>\n    [JsonPropertyName(\"mode\")]\n    public int? Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file owner.\n    /// </summary>\n    [JsonPropertyName(\"owner\")]\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file group.\n    /// </summary>\n    [JsonPropertyName(\"group\")]\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// File permission settings.\n/// </summary>\npublic class Permission\n{\n    /// <summary>\n    /// Gets or sets the file mode (permissions).\n    /// </summary>\n    [JsonPropertyName(\"mode\")]\n    public int Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file owner.\n    /// </summary>\n    [JsonPropertyName(\"owner\")]\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file group.\n    /// </summary>\n    [JsonPropertyName(\"group\")]\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// File metadata for upload operations.\n/// </summary>\npublic class FileMetadata\n{\n    /// <summary>\n    /// Gets or sets the file path.\n    /// </summary>\n    [JsonPropertyName(\"path\")]\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file mode (permissions).\n    /// </summary>\n    [JsonPropertyName(\"mode\")]\n    public int? Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file owner.\n    /// </summary>\n    [JsonPropertyName(\"owner\")]\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file group.\n    /// </summary>\n    [JsonPropertyName(\"group\")]\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// Entry for writing a file.\n/// </summary>\npublic class WriteEntry\n{\n    /// <summary>\n    /// Gets or sets the file path.\n    /// </summary>\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file data.\n    /// Supports: string, byte[], Stream.\n    /// </summary>\n    public object? Data { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file mode (permissions).\n    /// </summary>\n    public int? Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file owner.\n    /// </summary>\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file group.\n    /// </summary>\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// Entry for creating a directory.\n/// </summary>\npublic class CreateDirectoryEntry\n{\n    /// <summary>\n    /// Gets or sets the directory path.\n    /// </summary>\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the directory mode (permissions).\n    /// </summary>\n    public int? Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the directory owner.\n    /// </summary>\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the directory group.\n    /// </summary>\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// Entry for searching files.\n/// </summary>\npublic class SearchEntry\n{\n    /// <summary>\n    /// Gets or sets the search path.\n    /// </summary>\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the search pattern (e.g., \"*.txt\").\n    /// </summary>\n    public string? Pattern { get; set; }\n}\n\n/// <summary>\n/// Entry for moving/renaming a file.\n/// </summary>\npublic class MoveEntry\n{\n    /// <summary>\n    /// Gets or sets the source path.\n    /// </summary>\n    public required string Src { get; set; }\n\n    /// <summary>\n    /// Gets or sets the destination path.\n    /// </summary>\n    public required string Dest { get; set; }\n}\n\n/// <summary>\n/// Entry for replacing content in a file.\n/// </summary>\npublic class ContentReplaceEntry\n{\n    /// <summary>\n    /// Gets or sets the file path.\n    /// </summary>\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the old content to replace.\n    /// </summary>\n    public required string OldContent { get; set; }\n\n    /// <summary>\n    /// Gets or sets the new content.\n    /// </summary>\n    public required string NewContent { get; set; }\n}\n\n/// <summary>\n/// Entry for setting file permissions.\n/// </summary>\npublic class SetPermissionEntry\n{\n    /// <summary>\n    /// Gets or sets the file path.\n    /// </summary>\n    public required string Path { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file mode (permissions).\n    /// </summary>\n    public required int Mode { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file owner.\n    /// </summary>\n    public string? Owner { get; set; }\n\n    /// <summary>\n    /// Gets or sets the file group.\n    /// </summary>\n    public string? Group { get; set; }\n}\n\n/// <summary>\n/// Options for reading a file as text.\n/// </summary>\npublic class ReadFileOptions\n{\n    /// <summary>\n    /// Gets or sets the text encoding (default: utf-8).\n    /// </summary>\n    public string? Encoding { get; set; }\n\n    /// <summary>\n    /// Gets or sets the byte range to read (e.g., \"bytes=0-1023\").\n    /// </summary>\n    public string? Range { get; set; }\n}\n\n/// <summary>\n/// Options for reading a file as bytes.\n/// </summary>\npublic class ReadBytesOptions\n{\n    /// <summary>\n    /// Gets or sets the byte range to read (e.g., \"bytes=0-1023\").\n    /// </summary>\n    public string? Range { get; set; }\n}\n\n/// <summary>\n/// API request model for renaming files.\n/// </summary>\npublic class RenameFileItem\n{\n    /// <summary>\n    /// Gets or sets the source path.\n    /// </summary>\n    [JsonPropertyName(\"src\")]\n    public required string Src { get; set; }\n\n    /// <summary>\n    /// Gets or sets the destination path.\n    /// </summary>\n    [JsonPropertyName(\"dest\")]\n    public required string Dest { get; set; }\n}\n\n/// <summary>\n/// API request model for replacing file content.\n/// </summary>\npublic class ReplaceFileContentItem\n{\n    /// <summary>\n    /// Gets or sets the old content to replace.\n    /// </summary>\n    [JsonPropertyName(\"old\")]\n    public required string Old { get; set; }\n\n    /// <summary>\n    /// Gets or sets the new content.\n    /// </summary>\n    [JsonPropertyName(\"new\")]\n    public required string New { get; set; }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Text.Json.Serialization;\n\nnamespace OpenSandbox.Models;\n\n/// <summary>\n/// Authentication credentials for pulling container images.\n/// </summary>\npublic class ImageAuth\n{\n    /// <summary>\n    /// Gets or sets the username for authentication.\n    /// </summary>\n    [JsonPropertyName(\"username\")]\n    public string? Username { get; set; }\n\n    /// <summary>\n    /// Gets or sets the password for authentication.\n    /// </summary>\n    [JsonPropertyName(\"password\")]\n    public string? Password { get; set; }\n\n    /// <summary>\n    /// Gets or sets the token for authentication.\n    /// </summary>\n    [JsonPropertyName(\"token\")]\n    public string? Token { get; set; }\n}\n\n/// <summary>\n/// Specification for a container image.\n/// </summary>\npublic class ImageSpec\n{\n    /// <summary>\n    /// Gets or sets the image URI (e.g., \"python:3.11\").\n    /// </summary>\n    [JsonPropertyName(\"uri\")]\n    public required string Uri { get; set; }\n\n    /// <summary>\n    /// Gets or sets the optional authentication credentials.\n    /// </summary>\n    [JsonPropertyName(\"auth\")]\n    public ImageAuth? Auth { get; set; }\n}\n\n/// <summary>\n/// Action for a network rule.\n/// </summary>\n[JsonConverter(typeof(JsonStringEnumConverter))]\npublic enum NetworkRuleAction\n{\n    /// <summary>\n    /// Allow the network traffic.\n    /// </summary>\n    [JsonPropertyName(\"allow\")]\n    Allow,\n\n    /// <summary>\n    /// Deny the network traffic.\n    /// </summary>\n    [JsonPropertyName(\"deny\")]\n    Deny\n}\n\n/// <summary>\n/// A network rule for egress traffic.\n/// </summary>\npublic class NetworkRule\n{\n    /// <summary>\n    /// Gets or sets whether to allow or deny matching targets.\n    /// </summary>\n    [JsonPropertyName(\"action\")]\n    public required NetworkRuleAction Action { get; set; }\n\n    /// <summary>\n    /// Gets or sets the FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n    /// </summary>\n    [JsonPropertyName(\"target\")]\n    public required string Target { get; set; }\n}\n\n/// <summary>\n/// Network policy for sandbox egress traffic.\n/// </summary>\npublic class NetworkPolicy\n{\n    /// <summary>\n    /// Gets or sets the default action when no egress rule matches. Defaults to \"deny\".\n    /// </summary>\n    [JsonPropertyName(\"defaultAction\")]\n    public NetworkRuleAction? DefaultAction { get; set; }\n\n    /// <summary>\n    /// Gets or sets the list of egress rules evaluated in order.\n    /// </summary>\n    [JsonPropertyName(\"egress\")]\n    public List<NetworkRule>? Egress { get; set; }\n}\n\n/// <summary>\n/// Host path bind mount backend for a volume.\n/// </summary>\npublic class Host\n{\n    /// <summary>\n    /// Gets or sets the absolute host path.\n    /// </summary>\n    [JsonPropertyName(\"path\")]\n    public required string Path { get; set; }\n}\n\n/// <summary>\n/// Platform-managed named volume backend (PVC in k8s, named volume in Docker).\n/// </summary>\npublic class PVC\n{\n    /// <summary>\n    /// Gets or sets the target claim/volume name.\n    /// </summary>\n    [JsonPropertyName(\"claimName\")]\n    public required string ClaimName { get; set; }\n}\n\n/// <summary>\n/// Storage mount definition for sandbox creation.\n/// Exactly one backend (Host or PVC) should be provided per volume.\n/// </summary>\npublic class Volume\n{\n    /// <summary>\n    /// Gets or sets the unique volume name within this sandbox request.\n    /// </summary>\n    [JsonPropertyName(\"name\")]\n    public required string Name { get; set; }\n\n    /// <summary>\n    /// Gets or sets the host-path backend configuration.\n    /// </summary>\n    [JsonPropertyName(\"host\")]\n    public Host? Host { get; set; }\n\n    /// <summary>\n    /// Gets or sets the PVC/named-volume backend configuration.\n    /// </summary>\n    [JsonPropertyName(\"pvc\")]\n    public PVC? Pvc { get; set; }\n\n    /// <summary>\n    /// Gets or sets the absolute mount path inside the container.\n    /// </summary>\n    [JsonPropertyName(\"mountPath\")]\n    public required string MountPath { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether this volume is mounted read-only.\n    /// </summary>\n    [JsonPropertyName(\"readOnly\")]\n    public bool? ReadOnly { get; set; }\n\n    /// <summary>\n    /// Gets or sets the optional relative subpath under the volume backend.\n    /// </summary>\n    [JsonPropertyName(\"subPath\")]\n    public string? SubPath { get; set; }\n}\n\n/// <summary>\n/// Status of a sandbox.\n/// </summary>\npublic class SandboxStatus\n{\n    /// <summary>\n    /// Gets or sets the current state of the sandbox.\n    /// </summary>\n    [JsonPropertyName(\"state\")]\n    public required string State { get; set; }\n\n    /// <summary>\n    /// Gets or sets the reason for the current state.\n    /// </summary>\n    [JsonPropertyName(\"reason\")]\n    public string? Reason { get; set; }\n\n    /// <summary>\n    /// Gets or sets additional message about the current state.\n    /// </summary>\n    [JsonPropertyName(\"message\")]\n    public string? Message { get; set; }\n}\n\n/// <summary>\n/// Information about a sandbox.\n/// </summary>\npublic class SandboxInfo\n{\n    /// <summary>\n    /// Gets or sets the sandbox ID.\n    /// </summary>\n    [JsonPropertyName(\"id\")]\n    public required string Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the container image specification.\n    /// </summary>\n    [JsonPropertyName(\"image\")]\n    public required ImageSpec Image { get; set; }\n\n    /// <summary>\n    /// Gets or sets the entrypoint command.\n    /// </summary>\n    [JsonPropertyName(\"entrypoint\")]\n    public required IReadOnlyList<string> Entrypoint { get; set; }\n\n    /// <summary>\n    /// Gets or sets the custom metadata tags.\n    /// </summary>\n    [JsonPropertyName(\"metadata\")]\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox status.\n    /// </summary>\n    [JsonPropertyName(\"status\")]\n    public required SandboxStatus Status { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox creation time.\n    /// </summary>\n    [JsonPropertyName(\"createdAt\")]\n    public required DateTime CreatedAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox expiration time.\n    /// </summary>\n    [JsonPropertyName(\"expiresAt\")]\n    public DateTime? ExpiresAt { get; set; }\n}\n\n/// <summary>\n/// Request to create a new sandbox.\n/// </summary>\npublic class CreateSandboxRequest\n{\n    /// <summary>\n    /// Gets or sets the container image specification.\n    /// </summary>\n    [JsonPropertyName(\"image\")]\n    public required ImageSpec Image { get; set; }\n\n    /// <summary>\n    /// Gets or sets the entrypoint command.\n    /// </summary>\n    [JsonPropertyName(\"entrypoint\")]\n    public required IReadOnlyList<string> Entrypoint { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timeout in seconds.\n    /// </summary>\n    [JsonPropertyName(\"timeout\")]\n    public int? Timeout { get; set; }\n\n    /// <summary>\n    /// Gets or sets the resource limits.\n    /// </summary>\n    [JsonPropertyName(\"resourceLimits\")]\n    public required IReadOnlyDictionary<string, string> ResourceLimits { get; set; }\n\n    /// <summary>\n    /// Gets or sets the environment variables.\n    /// </summary>\n    [JsonPropertyName(\"env\")]\n    public IReadOnlyDictionary<string, string>? Env { get; set; }\n\n    /// <summary>\n    /// Gets or sets the custom metadata tags.\n    /// </summary>\n    [JsonPropertyName(\"metadata\")]\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the network policy.\n    /// </summary>\n    [JsonPropertyName(\"networkPolicy\")]\n    public NetworkPolicy? NetworkPolicy { get; set; }\n\n    /// <summary>\n    /// Gets or sets storage volumes to mount into the sandbox.\n    /// </summary>\n    [JsonPropertyName(\"volumes\")]\n    public IReadOnlyList<Volume>? Volumes { get; set; }\n\n    /// <summary>\n    /// Gets or sets the extension parameters.\n    /// </summary>\n    [JsonPropertyName(\"extensions\")]\n    public IReadOnlyDictionary<string, object>? Extensions { get; set; }\n}\n\n/// <summary>\n/// Response from creating a sandbox.\n/// </summary>\npublic class CreateSandboxResponse\n{\n    /// <summary>\n    /// Gets or sets the sandbox ID.\n    /// </summary>\n    [JsonPropertyName(\"id\")]\n    public required string Id { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox status.\n    /// </summary>\n    [JsonPropertyName(\"status\")]\n    public required SandboxStatus Status { get; set; }\n\n    /// <summary>\n    /// Gets or sets the custom metadata tags.\n    /// </summary>\n    [JsonPropertyName(\"metadata\")]\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox expiration time.\n    /// </summary>\n    [JsonPropertyName(\"expiresAt\")]\n    public DateTime? ExpiresAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox creation time.\n    /// </summary>\n    [JsonPropertyName(\"createdAt\")]\n    public required DateTime CreatedAt { get; set; }\n\n    /// <summary>\n    /// Gets or sets the entrypoint command.\n    /// </summary>\n    [JsonPropertyName(\"entrypoint\")]\n    public required IReadOnlyList<string> Entrypoint { get; set; }\n}\n\n/// <summary>\n/// Pagination information for list responses.\n/// </summary>\npublic class PaginationInfo\n{\n    /// <summary>\n    /// Gets or sets the current page number.\n    /// </summary>\n    [JsonPropertyName(\"page\")]\n    public int Page { get; set; }\n\n    /// <summary>\n    /// Gets or sets the page size.\n    /// </summary>\n    [JsonPropertyName(\"pageSize\")]\n    public int PageSize { get; set; }\n\n    /// <summary>\n    /// Gets or sets the total number of items.\n    /// </summary>\n    [JsonPropertyName(\"totalItems\")]\n    public int TotalItems { get; set; }\n\n    /// <summary>\n    /// Gets or sets the total number of pages.\n    /// </summary>\n    [JsonPropertyName(\"totalPages\")]\n    public int TotalPages { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether there is a next page.\n    /// </summary>\n    [JsonPropertyName(\"hasNextPage\")]\n    public bool HasNextPage { get; set; }\n}\n\n/// <summary>\n/// Response from listing sandboxes.\n/// </summary>\npublic class ListSandboxesResponse\n{\n    /// <summary>\n    /// Gets or sets the list of sandboxes.\n    /// </summary>\n    [JsonPropertyName(\"items\")]\n    public required IReadOnlyList<SandboxInfo> Items { get; set; }\n\n    /// <summary>\n    /// Gets or sets the pagination information.\n    /// </summary>\n    [JsonPropertyName(\"pagination\")]\n    public PaginationInfo? Pagination { get; set; }\n}\n\n/// <summary>\n/// Parameters for listing sandboxes.\n/// </summary>\npublic class ListSandboxesParams\n{\n    /// <summary>\n    /// Gets or sets the states to filter by.\n    /// </summary>\n    public IReadOnlyList<string>? States { get; set; }\n\n    /// <summary>\n    /// Gets or sets the metadata to filter by.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the page number.\n    /// </summary>\n    public int? Page { get; set; }\n\n    /// <summary>\n    /// Gets or sets the page size.\n    /// </summary>\n    public int? PageSize { get; set; }\n}\n\n/// <summary>\n/// Request to renew sandbox expiration.\n/// </summary>\npublic class RenewSandboxExpirationRequest\n{\n    /// <summary>\n    /// Gets or sets the new expiration time as ISO 8601 string.\n    /// </summary>\n    [JsonPropertyName(\"expiresAt\")]\n    public required string ExpiresAt { get; set; }\n}\n\n/// <summary>\n/// Response from renewing sandbox expiration.\n/// </summary>\npublic class RenewSandboxExpirationResponse\n{\n    /// <summary>\n    /// Gets or sets the updated expiration time.\n    /// </summary>\n    [JsonPropertyName(\"expiresAt\")]\n    public DateTime? ExpiresAt { get; set; }\n}\n\n/// <summary>\n/// Endpoint information for a sandbox port.\n/// </summary>\npublic class Endpoint\n{\n    /// <summary>\n    /// Gets or sets the endpoint address (host:port or path).\n    /// </summary>\n    [JsonPropertyName(\"endpoint\")]\n    public required string EndpointAddress { get; set; }\n\n    /// <summary>\n    /// Gets or sets headers that must be included when calling this endpoint.\n    /// </summary>\n    [JsonPropertyName(\"headers\")]\n    public IReadOnlyDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>();\n}\n\n/// <summary>\n/// Known sandbox states.\n/// </summary>\npublic static class SandboxStates\n{\n    /// <summary>\n    /// Sandbox is being created.\n    /// </summary>\n    public const string Creating = \"Creating\";\n\n    /// <summary>\n    /// Sandbox is running.\n    /// </summary>\n    public const string Running = \"Running\";\n\n    /// <summary>\n    /// Sandbox is being paused.\n    /// </summary>\n    public const string Pausing = \"Pausing\";\n\n    /// <summary>\n    /// Sandbox is paused.\n    /// </summary>\n    public const string Paused = \"Paused\";\n\n    /// <summary>\n    /// Sandbox is being resumed.\n    /// </summary>\n    public const string Resuming = \"Resuming\";\n\n    /// <summary>\n    /// Sandbox is being deleted.\n    /// </summary>\n    public const string Deleting = \"Deleting\";\n\n    /// <summary>\n    /// Sandbox has been deleted.\n    /// </summary>\n    public const string Deleted = \"Deleted\";\n\n    /// <summary>\n    /// Sandbox is in an error state.\n    /// </summary>\n    public const string Error = \"Error\";\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/OpenSandbox.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFrameworks>netstandard2.0;netstandard2.1;net6.0;net7.0;net8.0;net9.0;net10.0</TargetFrameworks>\n    <LangVersion>latest</LangVersion>\n    <Nullable>enable</Nullable>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <RootNamespace>OpenSandbox</RootNamespace>\n    <AssemblyName>OpenSandbox</AssemblyName>\n\n    <!-- Package Information -->\n    <PackageId>Alibaba.OpenSandbox</PackageId>\n    <Version>$(OpenSandboxPackageVersion)</Version>\n    <Authors>Alibaba Group</Authors>\n    <Company>Alibaba Group Holding Ltd.</Company>\n    <Product>OpenSandbox SDK</Product>\n    <Description>A C# SDK for low-level interaction with OpenSandbox. It provides the ability to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and reading resource metrics.</Description>\n    <Copyright>Copyright 2026 Alibaba Group Holding Ltd.</Copyright>\n    <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>\n    <PackageProjectUrl>https://open-sandbox.ai</PackageProjectUrl>\n    <RepositoryUrl>https://github.com/alibaba/OpenSandbox.git</RepositoryUrl>\n    <RepositoryType>git</RepositoryType>\n    <PackageTags>sandbox;container;docker;execution;opensandbox;alibaba</PackageTags>\n    <PackageReadmeFile>README.md</PackageReadmeFile>\n\n    <!-- Build Settings -->\n    <GenerateDocumentationFile>true</GenerateDocumentationFile>\n    <NoWarn>$(NoWarn);CS1591</NoWarn>\n    <TreatWarningsAsErrors Condition=\"'$(Configuration)' == 'Release'\">true</TreatWarningsAsErrors>\n  </PropertyGroup>\n\n  <!-- Expose internals to test project and code interpreter SDK -->\n  <ItemGroup>\n    <InternalsVisibleTo Include=\"OpenSandbox.Tests\" />\n    <InternalsVisibleTo Include=\"OpenSandbox.CodeInterpreter\" />\n    <InternalsVisibleTo Include=\"OpenSandbox.CodeInterpreter.Tests\" />\n  </ItemGroup>\n\n  <!-- Common Dependencies -->\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.Extensions.Logging.Abstractions\" Version=\"8.0.2\" />\n    <PackageReference Include=\"System.Text.Json\" Version=\"8.0.5\" />\n    <PackageReference Include=\"PolySharp\" Version=\"1.14.1\">\n      <PrivateAssets>all</PrivateAssets>\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n    </PackageReference>\n  </ItemGroup>\n\n  <!-- .NET Standard 2.0 specific dependencies -->\n  <ItemGroup Condition=\"'$(TargetFramework)' == 'netstandard2.0'\">\n    <PackageReference Include=\"Microsoft.Bcl.AsyncInterfaces\" Version=\"8.0.0\" />\n  </ItemGroup>\n\n\n  <!-- Package files -->\n  <ItemGroup>\n    <None Include=\"..\\..\\README.md\" Pack=\"true\" PackagePath=\"\\\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Options.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing OpenSandbox.Factory;\nusing OpenSandbox.Models;\n\nnamespace OpenSandbox;\n\n/// <summary>\n/// Options for creating a new sandbox.\n/// </summary>\npublic class SandboxCreateOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public ConnectionConfig? ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets diagnostics options such as logging.\n    /// </summary>\n    public SdkDiagnosticsOptions? Diagnostics { get; set; }\n\n    /// <summary>\n    /// Gets or sets the adapter factory for advanced customization.\n    /// </summary>\n    public IAdapterFactory? AdapterFactory { get; set; }\n\n    /// <summary>\n    /// Gets or sets the container image URI (e.g., \"python:3.11\").\n    /// Can also be an ImageSpec object with authentication.\n    /// </summary>\n    public required string Image { get; set; }\n\n    /// <summary>\n    /// Gets or sets the image authentication credentials.\n    /// </summary>\n    public ImageAuth? ImageAuth { get; set; }\n\n    /// <summary>\n    /// Gets or sets the entrypoint command for the sandbox.\n    /// Defaults to [\"tail\", \"-f\", \"/dev/null\"].\n    /// </summary>\n    public IReadOnlyList<string>? Entrypoint { get; set; }\n\n    /// <summary>\n    /// Gets or sets the environment variables to inject into the sandbox.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Env { get; set; }\n\n    /// <summary>\n    /// Gets or sets the custom metadata tags.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the network policy for the sandbox.\n    /// </summary>\n    public NetworkPolicy? NetworkPolicy { get; set; }\n\n    /// <summary>\n    /// Gets or sets storage volumes mounted into the sandbox.\n    /// </summary>\n    public IReadOnlyList<Volume>? Volumes { get; set; }\n\n    /// <summary>\n    /// Gets or sets the extension parameters.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Extensions { get; set; }\n\n    /// <summary>\n    /// Gets or sets the resource limits.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Resource { get; set; }\n\n    /// <summary>\n    /// Gets or sets the sandbox timeout in seconds.\n    /// </summary>\n    public int? TimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether the sandbox should disable automatic expiration and require explicit cleanup.\n    /// </summary>\n    public bool ManualCleanup { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether to skip health checks during creation.\n    /// </summary>\n    public bool SkipHealthCheck { get; set; }\n\n    /// <summary>\n    /// Gets or sets a custom health check function.\n    /// </summary>\n    public Func<Sandbox, Task<bool>>? HealthCheck { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timeout for waiting until ready in seconds.\n    /// </summary>\n    public int? ReadyTimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets the health check polling interval in milliseconds.\n    /// </summary>\n    public int? HealthCheckPollingInterval { get; set; }\n}\n\n/// <summary>\n/// Options for connecting to an existing sandbox.\n/// </summary>\npublic class SandboxConnectOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public ConnectionConfig? ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets diagnostics options such as logging.\n    /// </summary>\n    public SdkDiagnosticsOptions? Diagnostics { get; set; }\n\n    /// <summary>\n    /// Gets or sets the adapter factory for advanced customization.\n    /// </summary>\n    public IAdapterFactory? AdapterFactory { get; set; }\n\n    /// <summary>\n    /// Gets or sets the ID of the sandbox to connect to.\n    /// </summary>\n    public required string SandboxId { get; set; }\n\n    /// <summary>\n    /// Gets or sets whether to skip health checks after connecting.\n    /// </summary>\n    public bool SkipHealthCheck { get; set; }\n\n    /// <summary>\n    /// Gets or sets a custom health check function.\n    /// </summary>\n    public Func<Sandbox, Task<bool>>? HealthCheck { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timeout for waiting until ready in seconds.\n    /// </summary>\n    public int? ReadyTimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets the health check polling interval in milliseconds.\n    /// </summary>\n    public int? HealthCheckPollingInterval { get; set; }\n}\n\n/// <summary>\n/// Options for resuming a sandbox.\n/// </summary>\npublic class SandboxResumeOptions\n{\n    /// <summary>\n    /// Gets or sets whether to skip health checks after resuming.\n    /// </summary>\n    public bool SkipHealthCheck { get; set; }\n\n    /// <summary>\n    /// Gets or sets the timeout for waiting until ready in seconds.\n    /// </summary>\n    public int? ReadyTimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets the health check polling interval in milliseconds.\n    /// </summary>\n    public int? HealthCheckPollingInterval { get; set; }\n}\n\n/// <summary>\n/// Options for waiting until a sandbox is ready.\n/// </summary>\npublic class WaitUntilReadyOptions\n{\n    /// <summary>\n    /// Gets or sets the timeout in seconds.\n    /// </summary>\n    public int ReadyTimeoutSeconds { get; set; }\n\n    /// <summary>\n    /// Gets or sets the polling interval in milliseconds.\n    /// </summary>\n    public int PollingIntervalMillis { get; set; }\n\n    /// <summary>\n    /// Gets or sets a custom health check function.\n    /// </summary>\n    public Func<Sandbox, Task<bool>>? HealthCheck { get; set; }\n}\n\n/// <summary>\n/// Options for creating a sandbox manager.\n/// </summary>\npublic class SandboxManagerOptions\n{\n    /// <summary>\n    /// Gets or sets the connection configuration.\n    /// </summary>\n    public ConnectionConfig? ConnectionConfig { get; set; }\n\n    /// <summary>\n    /// Gets or sets diagnostics options such as logging.\n    /// </summary>\n    public SdkDiagnosticsOptions? Diagnostics { get; set; }\n\n    /// <summary>\n    /// Gets or sets the adapter factory for advanced customization.\n    /// </summary>\n    public IAdapterFactory? AdapterFactory { get; set; }\n}\n\n/// <summary>\n/// Filter options for listing sandboxes.\n/// </summary>\npublic class SandboxFilter\n{\n    /// <summary>\n    /// Gets or sets the states to filter by.\n    /// </summary>\n    public IReadOnlyList<string>? States { get; set; }\n\n    /// <summary>\n    /// Gets or sets the metadata to filter by.\n    /// </summary>\n    public IReadOnlyDictionary<string, string>? Metadata { get; set; }\n\n    /// <summary>\n    /// Gets or sets the page number (1-indexed).\n    /// </summary>\n    public int? Page { get; set; }\n\n    /// <summary>\n    /// Gets or sets the page size.\n    /// </summary>\n    public int? PageSize { get; set; }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Factory;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace OpenSandbox;\n\n/// <summary>\n/// Main entry point for interacting with a sandbox.\n/// </summary>\n/// <remarks>\n/// <see cref=\"DisposeAsync\"/> releases local SDK resources (HTTP clients and adapters) only.\n/// To terminate the remote sandbox instance, call <see cref=\"KillAsync\"/>.\n/// </remarks>\npublic sealed class Sandbox : IAsyncDisposable\n{\n    /// <summary>\n    /// Gets the sandbox ID.\n    /// </summary>\n    public string Id { get; }\n\n    /// <summary>\n    /// Gets the connection configuration.\n    /// </summary>\n    public ConnectionConfig ConnectionConfig { get; }\n\n    /// <summary>\n    /// Gets the command execution service.\n    /// </summary>\n    public IExecdCommands Commands { get; }\n\n    /// <summary>\n    /// Gets the filesystem service.\n    /// </summary>\n    public ISandboxFiles Files { get; }\n\n    /// <summary>\n    /// Gets the health check service.\n    /// </summary>\n    public IExecdHealth Health { get; }\n\n    /// <summary>\n    /// Gets the metrics service.\n    /// </summary>\n    public IExecdMetrics Metrics { get; }\n\n    private readonly IEgress _egress;\n\n    private readonly ISandboxes _sandboxes;\n    private readonly IAdapterFactory _adapterFactory;\n    private readonly string _lifecycleBaseUrl;\n    private readonly string _execdBaseUrl;\n    private readonly HttpClientProvider _httpClientProvider;\n    private readonly ILoggerFactory _loggerFactory;\n    private readonly ILogger _logger;\n    private bool _disposed;\n\n    internal HttpClientProvider SharedHttpClientProvider => _httpClientProvider;\n    internal ILoggerFactory SharedLoggerFactory => _loggerFactory;\n\n    private Sandbox(\n        string id,\n        ConnectionConfig connectionConfig,\n        IAdapterFactory adapterFactory,\n        string lifecycleBaseUrl,\n        string execdBaseUrl,\n        ILoggerFactory loggerFactory,\n        HttpClientProvider httpClientProvider,\n        ISandboxes sandboxes,\n        IExecdCommands commands,\n        ISandboxFiles files,\n        IExecdHealth health,\n        IExecdMetrics metrics,\n        IEgress egress)\n    {\n        Id = id;\n        ConnectionConfig = connectionConfig;\n        _adapterFactory = adapterFactory;\n        _lifecycleBaseUrl = lifecycleBaseUrl;\n        _execdBaseUrl = execdBaseUrl;\n        _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;\n        _httpClientProvider = httpClientProvider;\n        _logger = _loggerFactory.CreateLogger(\"OpenSandbox.Sandbox\");\n        _sandboxes = sandboxes;\n        Commands = commands;\n        Files = files;\n        Health = health;\n        Metrics = metrics;\n        _egress = egress;\n    }\n\n    /// <summary>\n    /// Creates a new sandbox.\n    /// </summary>\n    /// <param name=\"options\">The creation options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The created sandbox.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request options are invalid.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    /// <exception cref=\"SandboxReadyTimeoutException\">Thrown when readiness checks exceed timeout.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when sandbox creation fails.</exception>\n    public static async Task<Sandbox> CreateAsync(\n        SandboxCreateOptions options,\n        CancellationToken cancellationToken = default)\n    {\n        var connectionConfig = options.ConnectionConfig ?? new ConnectionConfig();\n        var loggerFactory = options.Diagnostics?.LoggerFactory ?? NullLoggerFactory.Instance;\n        var logger = loggerFactory.CreateLogger(\"OpenSandbox.Sandbox\");\n        var lifecycleBaseUrl = connectionConfig.GetBaseUrl();\n        var adapterFactory = options.AdapterFactory ?? DefaultAdapterFactory.Create();\n        var httpClientProvider = new HttpClientProvider(connectionConfig, loggerFactory);\n\n        ISandboxes sandboxes;\n        logger.LogInformation(\"Creating sandbox (image={Image}, useServerProxy={UseServerProxy})\", options.Image, connectionConfig.UseServerProxy);\n        try\n        {\n            var lifecycleStack = adapterFactory.CreateLifecycleStack(new CreateLifecycleStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                LifecycleBaseUrl = lifecycleBaseUrl,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n            sandboxes = lifecycleStack.Sandboxes;\n        }\n        catch\n        {\n            logger.LogError(\"Failed to initialize lifecycle adapters while creating sandbox\");\n            httpClientProvider.Dispose();\n            throw;\n        }\n\n        var request = new CreateSandboxRequest\n        {\n            Image = new ImageSpec\n            {\n                Uri = options.Image,\n                Auth = options.ImageAuth\n            },\n            Entrypoint = options.Entrypoint ?? Constants.DefaultEntrypoint,\n            Timeout = options.ManualCleanup ? null : options.TimeoutSeconds ?? Constants.DefaultTimeoutSeconds,\n            ResourceLimits = options.Resource ?? Constants.DefaultResourceLimits,\n            Env = options.Env,\n            Metadata = options.Metadata,\n            NetworkPolicy = options.NetworkPolicy != null\n                ? new NetworkPolicy\n                {\n                    DefaultAction = options.NetworkPolicy.DefaultAction ?? NetworkRuleAction.Deny,\n                    Egress = options.NetworkPolicy.Egress\n                }\n                : null,\n            Volumes = options.Volumes,\n            Extensions = options.Extensions?.ToDictionary(kv => kv.Key, kv => (object)kv.Value)\n        };\n\n        string? sandboxId = null;\n        try\n        {\n            var created = await sandboxes.CreateSandboxAsync(request, cancellationToken).ConfigureAwait(false);\n            sandboxId = created.Id;\n            logger.LogInformation(\"Sandbox created: {SandboxId}\", sandboxId);\n\n            var endpoint = await sandboxes.GetSandboxEndpointAsync(\n                sandboxId,\n                Constants.DefaultExecdPort,\n                connectionConfig.UseServerProxy,\n                cancellationToken).ConfigureAwait(false);\n            var protocol = connectionConfig.Protocol == ConnectionProtocol.Https ? \"https\" : \"http\";\n            var execdBaseUrl = $\"{protocol}://{endpoint.EndpointAddress}\";\n            var execdHeaders = MergeHeaders(connectionConfig.Headers, endpoint.Headers);\n            var egressEndpoint = await sandboxes.GetSandboxEndpointAsync(\n                sandboxId,\n                Constants.DefaultEgressPort,\n                connectionConfig.UseServerProxy,\n                cancellationToken).ConfigureAwait(false);\n            var egressBaseUrl = $\"{protocol}://{egressEndpoint.EndpointAddress}\";\n            var egressHeaders = MergeHeaders(connectionConfig.Headers, egressEndpoint.Headers);\n\n            var execdStack = adapterFactory.CreateExecdStack(new CreateExecdStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                ExecdBaseUrl = execdBaseUrl,\n                ExecdHeaders = execdHeaders,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n            var egressStack = adapterFactory.CreateEgressStack(new CreateEgressStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                EgressBaseUrl = egressBaseUrl,\n                EgressHeaders = egressHeaders,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n\n            var sandbox = new Sandbox(\n                sandboxId,\n                connectionConfig,\n                adapterFactory,\n                lifecycleBaseUrl,\n                execdBaseUrl,\n                loggerFactory,\n                httpClientProvider,\n                sandboxes,\n                execdStack.Commands,\n                execdStack.Files,\n                execdStack.Health,\n                execdStack.Metrics,\n                egressStack.Egress);\n\n            if (!options.SkipHealthCheck)\n            {\n                logger.LogDebug(\"Waiting for sandbox readiness: {SandboxId}\", sandboxId);\n                await sandbox.WaitUntilReadyAsync(new WaitUntilReadyOptions\n                {\n                    ReadyTimeoutSeconds = options.ReadyTimeoutSeconds ?? Constants.DefaultReadyTimeoutSeconds,\n                    PollingIntervalMillis = options.HealthCheckPollingInterval ?? Constants.DefaultHealthCheckPollingIntervalMillis,\n                    HealthCheck = options.HealthCheck\n                }, cancellationToken).ConfigureAwait(false);\n            }\n\n            return sandbox;\n        }\n        catch (Exception ex)\n        {\n            if (sandboxId != null)\n            {\n                try\n                {\n                    await sandboxes.DeleteSandboxAsync(sandboxId, CancellationToken.None).ConfigureAwait(false);\n                }\n                catch\n                {\n                    // Ignore cleanup failure; surface original error\n                }\n            }\n\n            httpClientProvider.Dispose();\n            logger.LogError(ex, \"Sandbox create flow failed\");\n            throw;\n        }\n    }\n\n    /// <summary>\n    /// Connects to an existing sandbox.\n    /// </summary>\n    /// <param name=\"options\">The connection options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The connected sandbox.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request options are invalid.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    /// <exception cref=\"SandboxReadyTimeoutException\">Thrown when readiness checks exceed timeout.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when sandbox connection fails.</exception>\n    public static async Task<Sandbox> ConnectAsync(\n        SandboxConnectOptions options,\n        CancellationToken cancellationToken = default)\n    {\n        var connectionConfig = options.ConnectionConfig ?? new ConnectionConfig();\n        var loggerFactory = options.Diagnostics?.LoggerFactory ?? NullLoggerFactory.Instance;\n        var logger = loggerFactory.CreateLogger(\"OpenSandbox.Sandbox\");\n        var lifecycleBaseUrl = connectionConfig.GetBaseUrl();\n        var adapterFactory = options.AdapterFactory ?? DefaultAdapterFactory.Create();\n        var httpClientProvider = new HttpClientProvider(connectionConfig, loggerFactory);\n        logger.LogInformation(\"Connecting to sandbox: {SandboxId}\", options.SandboxId);\n\n        ISandboxes sandboxes;\n        try\n        {\n            var lifecycleStack = adapterFactory.CreateLifecycleStack(new CreateLifecycleStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                LifecycleBaseUrl = lifecycleBaseUrl,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n            sandboxes = lifecycleStack.Sandboxes;\n        }\n        catch (Exception ex)\n        {\n            logger.LogError(ex, \"Failed to initialize lifecycle adapters while connecting sandbox\");\n            httpClientProvider.Dispose();\n            throw;\n        }\n\n        try\n        {\n            var endpoint = await sandboxes.GetSandboxEndpointAsync(\n                options.SandboxId,\n                Constants.DefaultExecdPort,\n                connectionConfig.UseServerProxy,\n                cancellationToken).ConfigureAwait(false);\n            var protocol = connectionConfig.Protocol == ConnectionProtocol.Https ? \"https\" : \"http\";\n            var execdBaseUrl = $\"{protocol}://{endpoint.EndpointAddress}\";\n            var execdHeaders = MergeHeaders(connectionConfig.Headers, endpoint.Headers);\n            var egressEndpoint = await sandboxes.GetSandboxEndpointAsync(\n                options.SandboxId,\n                Constants.DefaultEgressPort,\n                connectionConfig.UseServerProxy,\n                cancellationToken).ConfigureAwait(false);\n            var egressBaseUrl = $\"{protocol}://{egressEndpoint.EndpointAddress}\";\n            var egressHeaders = MergeHeaders(connectionConfig.Headers, egressEndpoint.Headers);\n\n            var execdStack = adapterFactory.CreateExecdStack(new CreateExecdStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                ExecdBaseUrl = execdBaseUrl,\n                ExecdHeaders = execdHeaders,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n            var egressStack = adapterFactory.CreateEgressStack(new CreateEgressStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                EgressBaseUrl = egressBaseUrl,\n                EgressHeaders = egressHeaders,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n\n            var sandbox = new Sandbox(\n                options.SandboxId,\n                connectionConfig,\n                adapterFactory,\n                lifecycleBaseUrl,\n                execdBaseUrl,\n                loggerFactory,\n                httpClientProvider,\n                sandboxes,\n                execdStack.Commands,\n                execdStack.Files,\n                execdStack.Health,\n                execdStack.Metrics,\n                egressStack.Egress);\n\n            if (!options.SkipHealthCheck)\n            {\n                await sandbox.WaitUntilReadyAsync(new WaitUntilReadyOptions\n                {\n                    ReadyTimeoutSeconds = options.ReadyTimeoutSeconds ?? Constants.DefaultReadyTimeoutSeconds,\n                    PollingIntervalMillis = options.HealthCheckPollingInterval ?? Constants.DefaultHealthCheckPollingIntervalMillis,\n                    HealthCheck = options.HealthCheck\n                }, cancellationToken).ConfigureAwait(false);\n            }\n\n            return sandbox;\n        }\n        catch (Exception ex)\n        {\n            logger.LogError(ex, \"Sandbox connect flow failed: {SandboxId}\", options.SandboxId);\n            httpClientProvider.Dispose();\n            throw;\n        }\n    }\n\n    /// <summary>\n    /// Resumes a paused sandbox by ID.\n    /// </summary>\n    /// <param name=\"options\">The connection options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The resumed sandbox.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request options are invalid.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    /// <exception cref=\"SandboxReadyTimeoutException\">Thrown when readiness checks exceed timeout.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when sandbox resume fails.</exception>\n    public static async Task<Sandbox> ResumeAsync(\n        SandboxConnectOptions options,\n        CancellationToken cancellationToken = default)\n    {\n        var connectionConfig = options.ConnectionConfig ?? new ConnectionConfig();\n        var loggerFactory = options.Diagnostics?.LoggerFactory ?? NullLoggerFactory.Instance;\n        var logger = loggerFactory.CreateLogger(\"OpenSandbox.Sandbox\");\n        var lifecycleBaseUrl = connectionConfig.GetBaseUrl();\n        var adapterFactory = options.AdapterFactory ?? DefaultAdapterFactory.Create();\n        var httpClientProvider = new HttpClientProvider(connectionConfig, loggerFactory);\n        logger.LogInformation(\"Resuming sandbox: {SandboxId}\", options.SandboxId);\n\n        try\n        {\n            var lifecycleStack = adapterFactory.CreateLifecycleStack(new CreateLifecycleStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                LifecycleBaseUrl = lifecycleBaseUrl,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n\n            await lifecycleStack.Sandboxes.ResumeSandboxAsync(options.SandboxId, cancellationToken).ConfigureAwait(false);\n            return await ConnectAsync(options, cancellationToken).ConfigureAwait(false);\n        }\n        finally\n        {\n            httpClientProvider.Dispose();\n        }\n    }\n\n    /// <summary>\n    /// Gets information about this sandbox.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The sandbox information.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<SandboxInfo> GetInfoAsync(CancellationToken cancellationToken = default)\n    {\n        return _sandboxes.GetSandboxAsync(Id, cancellationToken);\n    }\n\n    /// <summary>\n    /// Checks if the sandbox is healthy.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>True if healthy, false otherwise.</returns>\n    public async Task<bool> IsHealthyAsync(CancellationToken cancellationToken = default)\n    {\n        try\n        {\n            return await Health.PingAsync(cancellationToken).ConfigureAwait(false);\n        }\n        catch (Exception ex)\n        {\n            _logger.LogDebug(ex, \"Health check failed for sandbox {SandboxId}\", Id);\n            return false;\n        }\n    }\n\n    /// <summary>\n    /// Gets the current resource metrics.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The sandbox metrics.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<SandboxMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)\n    {\n        return Metrics.GetMetricsAsync(cancellationToken);\n    }\n\n    /// <summary>\n    /// Pauses the sandbox.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task PauseAsync(CancellationToken cancellationToken = default)\n    {\n        return _sandboxes.PauseSandboxAsync(Id, cancellationToken);\n    }\n\n    /// <summary>\n    /// Resumes this paused sandbox and returns a fresh, connected instance.\n    /// </summary>\n    /// <param name=\"options\">Optional resume options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>A new sandbox instance with refreshed connections.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    /// <exception cref=\"SandboxReadyTimeoutException\">Thrown when readiness checks exceed timeout.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when sandbox resume fails.</exception>\n    public async Task<Sandbox> ResumeAsync(\n        SandboxResumeOptions? options = null,\n        CancellationToken cancellationToken = default)\n    {\n        await _sandboxes.ResumeSandboxAsync(Id, cancellationToken).ConfigureAwait(false);\n\n        return await ConnectAsync(new SandboxConnectOptions\n        {\n            SandboxId = Id,\n            ConnectionConfig = ConnectionConfig,\n            Diagnostics = new SdkDiagnosticsOptions\n            {\n                LoggerFactory = _loggerFactory\n            },\n            AdapterFactory = _adapterFactory,\n            SkipHealthCheck = options?.SkipHealthCheck ?? false,\n            ReadyTimeoutSeconds = options?.ReadyTimeoutSeconds,\n            HealthCheckPollingInterval = options?.HealthCheckPollingInterval\n        }, cancellationToken).ConfigureAwait(false);\n    }\n\n    /// <summary>\n    /// Terminates the sandbox.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task KillAsync(CancellationToken cancellationToken = default)\n    {\n        return _sandboxes.DeleteSandboxAsync(Id, cancellationToken);\n    }\n\n    /// <summary>\n    /// Renews the sandbox expiration time.\n    /// </summary>\n    /// <param name=\"timeoutSeconds\">The new timeout in seconds from now.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The renewal response.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<RenewSandboxExpirationResponse> RenewAsync(\n        int timeoutSeconds,\n        CancellationToken cancellationToken = default)\n    {\n        var expiresAt = DateTime.UtcNow.AddSeconds(timeoutSeconds).ToString(\"O\");\n        return _sandboxes.RenewSandboxExpirationAsync(Id, new RenewSandboxExpirationRequest\n        {\n            ExpiresAt = expiresAt\n        }, cancellationToken);\n    }\n\n    /// <summary>\n    /// Gets current egress policy for this sandbox.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The current egress policy.</returns>\n    public async Task<NetworkPolicy> GetEgressPolicyAsync(CancellationToken cancellationToken = default)\n    {\n        return await _egress.GetPolicyAsync(cancellationToken).ConfigureAwait(false);\n    }\n\n    /// <summary>\n    /// Patches egress rules for this sandbox using sidecar merge semantics.\n    ///\n    /// Incoming rules take priority over existing rules with the same target.\n    /// Existing rules for other targets remain unchanged. Within one patch payload,\n    /// the first rule for a target wins. The current defaultAction is preserved.\n    /// </summary>\n    /// <param name=\"rules\">Patch egress rules payload.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    public async Task PatchEgressRulesAsync(\n        IReadOnlyList<NetworkRule> rules,\n        CancellationToken cancellationToken = default)\n    {\n        await _egress.PatchRulesAsync(rules, cancellationToken).ConfigureAwait(false);\n    }\n\n    /// <summary>\n    /// Gets the endpoint for a port.\n    /// </summary>\n    /// <param name=\"port\">The port number.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The endpoint information.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<Endpoint> GetEndpointAsync(int port, CancellationToken cancellationToken = default)\n    {\n        return _sandboxes.GetSandboxEndpointAsync(Id, port, ConnectionConfig.UseServerProxy, cancellationToken);\n    }\n\n    /// <summary>\n    /// Gets the endpoint URL for a port.\n    /// </summary>\n    /// <param name=\"port\">The port number.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The endpoint URL.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public async Task<string> GetEndpointUrlAsync(int port, CancellationToken cancellationToken = default)\n    {\n        var endpoint = await GetEndpointAsync(port, cancellationToken).ConfigureAwait(false);\n        var protocol = ConnectionConfig.Protocol == ConnectionProtocol.Https ? \"https\" : \"http\";\n        return $\"{protocol}://{endpoint.EndpointAddress}\";\n    }\n\n    /// <summary>\n    /// Waits until the sandbox is ready.\n    /// </summary>\n    /// <param name=\"options\">The wait options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"SandboxReadyTimeoutException\">Thrown when readiness checks exceed timeout.</exception>\n    /// <exception cref=\"OperationCanceledException\">Thrown when <paramref name=\"cancellationToken\"/> is canceled.</exception>\n    public async Task WaitUntilReadyAsync(\n        WaitUntilReadyOptions options,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogDebug(\"Start readiness check for sandbox {SandboxId} (timeoutSeconds={TimeoutSeconds})\", Id, options.ReadyTimeoutSeconds);\n        var deadline = DateTime.UtcNow.AddSeconds(options.ReadyTimeoutSeconds);\n        var attempt = 0;\n        var errorDetail = \"Health check returned false continuously.\";\n\n        while (true)\n        {\n            cancellationToken.ThrowIfCancellationRequested();\n\n            if (DateTime.UtcNow > deadline)\n            {\n                var context = $\"domain={ConnectionConfig.Domain}, useServerProxy={ConnectionConfig.UseServerProxy}\";\n                var suggestion = \"If this sandbox runs in Docker bridge or remote-network mode, consider enabling useServerProxy=true.\";\n                if (!ConnectionConfig.UseServerProxy)\n                {\n                    suggestion += \" You can also configure server-side [docker].host_ip for direct endpoint access.\";\n                }\n                throw new SandboxReadyTimeoutException(\n                    $\"Sandbox health check timed out after {options.ReadyTimeoutSeconds}s ({attempt} attempts). {errorDetail} Connection context: {context}. {suggestion}\");\n            }\n            attempt++;\n\n            try\n            {\n                bool isReady;\n                if (options.HealthCheck != null)\n                {\n                    isReady = await options.HealthCheck(this).ConfigureAwait(false);\n                }\n                else\n                {\n                    isReady = await Health.PingAsync(cancellationToken).ConfigureAwait(false);\n                }\n\n                if (isReady)\n                {\n                    _logger.LogInformation(\"Sandbox is ready: {SandboxId}\", Id);\n                    return;\n                }\n\n                errorDetail = \"Health check returned false continuously.\";\n            }\n            catch (Exception ex)\n            {\n                _logger.LogDebug(ex, \"Readiness probe failed for sandbox {SandboxId}\", Id);\n                errorDetail = $\"Last health check error: {ex.Message}\";\n            }\n\n            await Task.Delay(options.PollingIntervalMillis, cancellationToken).ConfigureAwait(false);\n        }\n    }\n\n    /// <summary>\n    /// Releases resources used by this sandbox instance.\n    /// </summary>\n    public ValueTask DisposeAsync()\n    {\n        if (_disposed)\n        {\n            return default;\n        }\n\n        _disposed = true;\n        _logger.LogDebug(\"Disposing sandbox resources: {SandboxId}\", Id);\n        _httpClientProvider.Dispose();\n        return default;\n    }\n\n    internal static IReadOnlyDictionary<string, string> MergeHeaders(\n        IReadOnlyDictionary<string, string> baseHeaders,\n        IReadOnlyDictionary<string, string>? overrideHeaders)\n    {\n        var merged = baseHeaders.ToDictionary(header => header.Key, header => header.Value);\n        if (overrideHeaders != null)\n        {\n            foreach (var header in overrideHeaders)\n            {\n                merged[header.Key] = header.Value;\n            }\n        }\n\n        return merged;\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/SandboxManager.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing OpenSandbox.Factory;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\nusing OpenSandbox.Core;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace OpenSandbox;\n\n/// <summary>\n/// Administrative interface for managing sandboxes.\n/// </summary>\n/// <remarks>\n/// This type is intended for administrative lifecycle operations (list, inspect, pause, resume, kill, renew).\n/// Dispose the manager when finished to release local SDK resources.\n/// </remarks>\npublic sealed class SandboxManager : IAsyncDisposable\n{\n    private readonly ISandboxes _sandboxes;\n    private readonly ConnectionConfig _connectionConfig;\n    private readonly HttpClientProvider _httpClientProvider;\n    private readonly ILogger _logger;\n    private bool _disposed;\n\n    private SandboxManager(\n        ISandboxes sandboxes,\n        ConnectionConfig connectionConfig,\n        HttpClientProvider httpClientProvider,\n        ILoggerFactory loggerFactory)\n    {\n        _sandboxes = sandboxes;\n        _connectionConfig = connectionConfig;\n        _httpClientProvider = httpClientProvider;\n        _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(\"OpenSandbox.SandboxManager\");\n    }\n\n    /// <summary>\n    /// Creates a new sandbox manager.\n    /// </summary>\n    /// <param name=\"options\">Optional configuration options.</param>\n    /// <returns>A new sandbox manager instance.</returns>\n    /// <exception cref=\"SandboxException\">Thrown when manager initialization fails.</exception>\n    public static SandboxManager Create(SandboxManagerOptions? options = null)\n    {\n        var connectionConfig = options?.ConnectionConfig ?? new ConnectionConfig();\n        var lifecycleBaseUrl = connectionConfig.GetBaseUrl();\n        var adapterFactory = options?.AdapterFactory ?? DefaultAdapterFactory.Create();\n        var loggerFactory = options?.Diagnostics?.LoggerFactory ?? NullLoggerFactory.Instance;\n        var httpClientProvider = new HttpClientProvider(connectionConfig, loggerFactory);\n        var logger = loggerFactory.CreateLogger(\"OpenSandbox.SandboxManager\");\n        logger.LogInformation(\"Creating sandbox manager\");\n\n        try\n        {\n            var lifecycleStack = adapterFactory.CreateLifecycleStack(new CreateLifecycleStackOptions\n            {\n                ConnectionConfig = connectionConfig,\n                LifecycleBaseUrl = lifecycleBaseUrl,\n                HttpClientProvider = httpClientProvider,\n                LoggerFactory = loggerFactory\n            });\n\n            return new SandboxManager(lifecycleStack.Sandboxes, connectionConfig, httpClientProvider, loggerFactory);\n        }\n        catch (Exception ex)\n        {\n            logger.LogError(ex, \"Failed to create sandbox manager\");\n            httpClientProvider.Dispose();\n            throw;\n        }\n    }\n\n    /// <summary>\n    /// Lists sandboxes with optional filtering.\n    /// </summary>\n    /// <param name=\"filter\">Optional filter criteria.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The list of sandboxes.</returns>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<ListSandboxesResponse> ListSandboxInfosAsync(\n        SandboxFilter? filter = null,\n        CancellationToken cancellationToken = default)\n    {\n        return _sandboxes.ListSandboxesAsync(new ListSandboxesParams\n        {\n            States = filter?.States,\n            Metadata = filter?.Metadata,\n            Page = filter?.Page,\n            PageSize = filter?.PageSize\n        }, cancellationToken);\n    }\n\n    /// <summary>\n    /// Gets information about a specific sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The sandbox information.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task<SandboxInfo> GetSandboxInfoAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogDebug(\"Fetching sandbox info: {SandboxId}\", sandboxId);\n        return _sandboxes.GetSandboxAsync(sandboxId, cancellationToken);\n    }\n\n    /// <summary>\n    /// Terminates a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task KillSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogInformation(\"Killing sandbox: {SandboxId}\", sandboxId);\n        return _sandboxes.DeleteSandboxAsync(sandboxId, cancellationToken);\n    }\n\n    /// <summary>\n    /// Pauses a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task PauseSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogInformation(\"Pausing sandbox: {SandboxId}\", sandboxId);\n        return _sandboxes.PauseSandboxAsync(sandboxId, cancellationToken);\n    }\n\n    /// <summary>\n    /// Resumes a paused sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public Task ResumeSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogInformation(\"Resuming sandbox: {SandboxId}\", sandboxId);\n        return _sandboxes.ResumeSandboxAsync(sandboxId, cancellationToken);\n    }\n\n    /// <summary>\n    /// Renews the expiration time of a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"timeoutSeconds\">The new timeout in seconds from now.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when arguments are invalid.</exception>\n    /// <exception cref=\"SandboxApiException\">Thrown when the sandbox API returns an error.</exception>\n    public async Task RenewSandboxAsync(\n        string sandboxId,\n        int timeoutSeconds,\n        CancellationToken cancellationToken = default)\n    {\n        _logger.LogInformation(\"Renewing sandbox expiration: {SandboxId} (timeoutSeconds={TimeoutSeconds})\", sandboxId, timeoutSeconds);\n        var expiresAt = DateTime.UtcNow.AddSeconds(timeoutSeconds).ToString(\"O\");\n        await _sandboxes.RenewSandboxExpirationAsync(sandboxId, new RenewSandboxExpirationRequest\n        {\n            ExpiresAt = expiresAt\n        }, cancellationToken).ConfigureAwait(false);\n    }\n\n    /// <summary>\n    /// Releases resources used by this manager.\n    /// </summary>\n    public ValueTask DisposeAsync()\n    {\n        if (_disposed)\n        {\n            return default;\n        }\n\n        _disposed = true;\n        _logger.LogDebug(\"Disposing sandbox manager resources\");\n        _httpClientProvider.Dispose();\n        return default;\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/IEgress.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for direct egress sidecar operations.\n/// </summary>\npublic interface IEgress\n{\n    Task<NetworkPolicy> GetPolicyAsync(CancellationToken cancellationToken = default);\n\n    Task PatchRulesAsync(\n        IReadOnlyList<NetworkRule> rules,\n        CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdCommands.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for executing commands in a sandbox.\n/// </summary>\npublic interface IExecdCommands\n{\n    /// <summary>\n    /// Runs a command and streams server events (SSE).\n    /// This is the lowest-level API for command execution.\n    /// </summary>\n    /// <param name=\"command\">The command to run.</param>\n    /// <param name=\"options\">Optional command options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>An async enumerable of server stream events.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    IAsyncEnumerable<ServerStreamEvent> RunStreamAsync(\n        string command,\n        RunCommandOptions? options = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Runs a command, consumes the stream, and builds a structured execution result.\n    /// </summary>\n    /// <param name=\"command\">The command to run.</param>\n    /// <param name=\"options\">Optional command options.</param>\n    /// <param name=\"handlers\">Optional event handlers for real-time processing.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The command execution result.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<Execution> RunAsync(\n        string command,\n        RunCommandOptions? options = null,\n        ExecutionHandlers? handlers = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Interrupts the current execution in the given session.\n    /// </summary>\n    /// <param name=\"sessionId\">The session ID to interrupt.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sessionId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task InterruptAsync(\n        string sessionId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Gets the current running status of a command.\n    /// </summary>\n    /// <param name=\"executionId\">The command execution ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The command status.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"executionId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<CommandStatus> GetCommandStatusAsync(\n        string executionId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Gets logs for a background command.\n    /// </summary>\n    /// <param name=\"executionId\">The command execution ID.</param>\n    /// <param name=\"cursor\">Optional line cursor for incremental reads.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>Background command logs and latest cursor.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"executionId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<CommandLogs> GetBackgroundCommandLogsAsync(\n        string executionId,\n        long? cursor = null,\n        CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdHealth.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for health checks on the execd service.\n/// </summary>\npublic interface IExecdHealth\n{\n    /// <summary>\n    /// Pings the execd service to check if it's healthy.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>True if the service is healthy, false otherwise.</returns>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<bool> PingAsync(CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdMetrics.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for retrieving metrics from the execd service.\n/// </summary>\npublic interface IExecdMetrics\n{\n    /// <summary>\n    /// Gets the current resource metrics from the sandbox.\n    /// </summary>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The sandbox metrics.</returns>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<SandboxMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for filesystem operations in a sandbox.\n/// </summary>\npublic interface ISandboxFiles\n{\n    /// <summary>\n    /// Gets information about files at the specified paths.\n    /// </summary>\n    /// <param name=\"paths\">The file paths to query.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>A dictionary mapping paths to file information.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<IReadOnlyDictionary<string, SandboxFileInfo>> GetFileInfoAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Searches for files matching the specified criteria.\n    /// </summary>\n    /// <param name=\"entry\">The search entry with path and pattern.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>A list of matching files.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<IReadOnlyList<SandboxFileInfo>> SearchAsync(\n        SearchEntry entry,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Creates directories at the specified paths.\n    /// </summary>\n    /// <param name=\"entries\">The directory entries to create.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task CreateDirectoriesAsync(\n        IEnumerable<CreateDirectoryEntry> entries,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Deletes directories at the specified paths.\n    /// </summary>\n    /// <param name=\"paths\">The directory paths to delete.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task DeleteDirectoriesAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Writes files to the sandbox.\n    /// </summary>\n    /// <param name=\"entries\">The file entries to write.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task WriteFilesAsync(\n        IEnumerable<WriteEntry> entries,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Reads a file as text.\n    /// </summary>\n    /// <param name=\"path\">The file path.</param>\n    /// <param name=\"options\">Optional read options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The file content as a string.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<string> ReadFileAsync(\n        string path,\n        ReadFileOptions? options = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Reads a file as bytes.\n    /// </summary>\n    /// <param name=\"path\">The file path.</param>\n    /// <param name=\"options\">Optional read options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The file content as a byte array.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task<byte[]> ReadBytesAsync(\n        string path,\n        ReadBytesOptions? options = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Reads a file as a stream of byte chunks.\n    /// </summary>\n    /// <param name=\"path\">The file path.</param>\n    /// <param name=\"options\">Optional read options.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>An async enumerable of byte chunks.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    IAsyncEnumerable<byte[]> ReadBytesStreamAsync(\n        string path,\n        ReadBytesOptions? options = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Deletes files at the specified paths.\n    /// </summary>\n    /// <param name=\"paths\">The file paths to delete.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task DeleteFilesAsync(\n        IEnumerable<string> paths,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Moves/renames files.\n    /// </summary>\n    /// <param name=\"entries\">The move entries with source and destination paths.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task MoveFilesAsync(\n        IEnumerable<MoveEntry> entries,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Replaces content in files.\n    /// </summary>\n    /// <param name=\"entries\">The content replace entries.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task ReplaceContentsAsync(\n        IEnumerable<ContentReplaceEntry> entries,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Sets permissions on files.\n    /// </summary>\n    /// <param name=\"entries\">The permission entries.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the execd service request fails.</exception>\n    Task SetPermissionsAsync(\n        IEnumerable<SetPermissionEntry> entries,\n        CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxes.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\nusing OpenSandbox.Core;\n\nnamespace OpenSandbox.Services;\n\n/// <summary>\n/// Service interface for sandbox lifecycle management.\n/// </summary>\npublic interface ISandboxes\n{\n    /// <summary>\n    /// Creates a new sandbox.\n    /// </summary>\n    /// <param name=\"request\">The create sandbox request.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The created sandbox response.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when request values are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<CreateSandboxResponse> CreateSandboxAsync(\n        CreateSandboxRequest request,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Gets information about a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The sandbox information.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<SandboxInfo> GetSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Lists sandboxes with optional filtering.\n    /// </summary>\n    /// <param name=\"params\">Optional filter parameters.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The list of sandboxes.</returns>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<ListSandboxesResponse> ListSandboxesAsync(\n        ListSandboxesParams? @params = null,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Deletes a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task DeleteSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Pauses a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task PauseSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Resumes a paused sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when <paramref name=\"sandboxId\"/> is null or empty.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task ResumeSandboxAsync(\n        string sandboxId,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Renews the expiration time of a sandbox.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"request\">The renewal request.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The renewal response.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when arguments are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<RenewSandboxExpirationResponse> RenewSandboxExpirationAsync(\n        string sandboxId,\n        RenewSandboxExpirationRequest request,\n        CancellationToken cancellationToken = default);\n\n    /// <summary>\n    /// Gets the endpoint for a sandbox port.\n    /// </summary>\n    /// <param name=\"sandboxId\">The sandbox ID.</param>\n    /// <param name=\"port\">The port number.</param>\n    /// <param name=\"useServerProxy\">Whether to return a server-proxied URL.</param>\n    /// <param name=\"cancellationToken\">Cancellation token.</param>\n    /// <returns>The endpoint information.</returns>\n    /// <exception cref=\"InvalidArgumentException\">Thrown when arguments are invalid.</exception>\n    /// <exception cref=\"SandboxException\">Thrown when the sandbox service request fails.</exception>\n    Task<Endpoint> GetSandboxEndpointAsync(\n        string sandboxId,\n        int port,\n        bool useServerProxy = false,\n        CancellationToken cancellationToken = default);\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Net;\nusing System.Text;\nusing System.Text.Json;\nusing FluentAssertions;\nusing OpenSandbox.Adapters;\nusing OpenSandbox.Core;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Abstractions;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class CommandsAdapterTests\n{\n    [Fact]\n    public async Task GetCommandStatusAsync_ShouldParseStatusResponse()\n    {\n        var httpHandler = new StubHttpMessageHandler((request, _) =>\n        {\n            var body = \"{\\\"id\\\":\\\"cmd-1\\\",\\\"content\\\":\\\"sleep 1\\\",\\\"running\\\":true,\\\"exit_code\\\":null}\";\n            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(body, Encoding.UTF8, \"application/json\")\n            });\n        });\n        var adapter = CreateAdapter(httpHandler);\n\n        var status = await adapter.GetCommandStatusAsync(\"cmd-1\");\n\n        status.Id.Should().Be(\"cmd-1\");\n        status.Content.Should().Be(\"sleep 1\");\n        status.Running.Should().BeTrue();\n        status.ExitCode.Should().BeNull();\n        httpHandler.RequestUris.Should().Contain(uri => uri.EndsWith(\"/command/status/cmd-1\", StringComparison.Ordinal));\n    }\n\n    [Fact]\n    public async Task GetBackgroundCommandLogsAsync_ShouldParseCursorHeader()\n    {\n        var httpHandler = new StubHttpMessageHandler((request, _) =>\n        {\n            var response = new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(\"line1\\nline2\\n\", Encoding.UTF8, \"text/plain\")\n            };\n            response.Headers.Add(\"EXECD-COMMANDS-TAIL-CURSOR\", \"42\");\n            return Task.FromResult(response);\n        });\n        var adapter = CreateAdapter(httpHandler);\n\n        var logs = await adapter.GetBackgroundCommandLogsAsync(\"cmd-2\", cursor: 10);\n\n        logs.Content.Should().Contain(\"line1\");\n        logs.Cursor.Should().Be(42);\n        httpHandler.RequestUris.Should().Contain(uri => uri.Contains(\"/command/cmd-2/logs?cursor=10\", StringComparison.Ordinal));\n    }\n\n    [Fact]\n    public async Task GetBackgroundCommandLogsAsync_ShouldReturnNullCursorWhenHeaderMissing()\n    {\n        var httpHandler = new StubHttpMessageHandler((request, _) =>\n        {\n            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(\"only-content\", Encoding.UTF8, \"text/plain\")\n            });\n        });\n        var adapter = CreateAdapter(httpHandler);\n\n        var logs = await adapter.GetBackgroundCommandLogsAsync(\"cmd-3\");\n\n        logs.Content.Should().Be(\"only-content\");\n        logs.Cursor.Should().BeNull();\n    }\n\n    [Fact]\n    public async Task RunStreamAsync_ShouldSendTimeoutInMilliseconds()\n    {\n        var handler = new StubHttpMessageHandler(async (request, _) =>\n        {\n            request.Content.Should().NotBeNull();\n            var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);\n            using var doc = JsonDocument.Parse(body);\n            doc.RootElement.GetProperty(\"timeout\").GetInt64().Should().Be(2000);\n\n            return new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(\"data: {\\\"type\\\":\\\"init\\\",\\\"text\\\":\\\"cmd-1\\\"}\\n\\n\", Encoding.UTF8, \"text/event-stream\")\n            };\n        });\n        var adapter = CreateAdapter(handler);\n\n        var options = new RunCommandOptions\n        {\n            TimeoutSeconds = 2\n        };\n\n        await foreach (var _ in adapter.RunStreamAsync(\"sleep 1\", options))\n        {\n            // Drain events.\n        }\n    }\n\n    [Fact]\n    public async Task RunStreamAsync_ShouldSendUidGidAndEnvs()\n    {\n        var handler = new StubHttpMessageHandler(async (request, _) =>\n        {\n            request.Content.Should().NotBeNull();\n            var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);\n            using var doc = JsonDocument.Parse(body);\n            doc.RootElement.GetProperty(\"uid\").GetInt32().Should().Be(1000);\n            doc.RootElement.GetProperty(\"gid\").GetInt32().Should().Be(1000);\n            var envs = doc.RootElement.GetProperty(\"envs\");\n            envs.GetProperty(\"APP_ENV\").GetString().Should().Be(\"test\");\n            envs.GetProperty(\"LOG_LEVEL\").GetString().Should().Be(\"debug\");\n\n            return new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(\"data: {\\\"type\\\":\\\"init\\\",\\\"text\\\":\\\"cmd-1\\\"}\\n\\n\", Encoding.UTF8, \"text/event-stream\")\n            };\n        });\n        var adapter = CreateAdapter(handler);\n\n        var options = new RunCommandOptions\n        {\n            Uid = 1000,\n            Gid = 1000,\n            Envs = new Dictionary<string, string>\n            {\n                [\"APP_ENV\"] = \"test\",\n                [\"LOG_LEVEL\"] = \"debug\"\n            }\n        };\n\n        await foreach (var _ in adapter.RunStreamAsync(\"id\", options))\n        {\n            // Drain events.\n        }\n    }\n\n    [Fact]\n    public async Task RunStreamAsync_ShouldRejectGidWithoutUid()\n    {\n        var handler = new StubHttpMessageHandler((_, _) =>\n        {\n            throw new InvalidOperationException(\"HTTP should not be called when options are invalid.\");\n        });\n        var adapter = CreateAdapter(handler);\n\n        var options = new RunCommandOptions\n        {\n            Gid = 1000\n        };\n\n        var act = async () =>\n        {\n            await foreach (var _ in adapter.RunStreamAsync(\"id\", options))\n            {\n                // Drain events.\n            }\n        };\n\n        await act.Should().ThrowAsync<InvalidArgumentException>()\n            .WithMessage(\"*uid is required when gid is provided*\");\n    }\n\n    private static CommandsAdapter CreateAdapter(HttpMessageHandler httpHandler)\n    {\n        var baseUrl = \"http://execd.local\";\n        var headers = new Dictionary<string, string> { [\"X-Test\"] = \"true\" };\n        var client = new HttpClientWrapper(new HttpClient(httpHandler), baseUrl, headers);\n        var sseHttpClient = new HttpClient(httpHandler);\n        var logger = NullLoggerFactory.Instance.CreateLogger(\"CommandsAdapterTests\");\n        return new CommandsAdapter(client, sseHttpClient, baseUrl, headers, logger);\n    }\n\n    private sealed class StubHttpMessageHandler : HttpMessageHandler\n    {\n        private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;\n\n        public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)\n        {\n            _handler = handler;\n        }\n\n        public List<string> RequestUris { get; } = new();\n\n        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n        {\n            RequestUris.Add(request.RequestUri?.ToString() ?? string.Empty);\n            return await _handler(request, cancellationToken).ConfigureAwait(false);\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/ConnectionConfigTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class ConnectionConfigTests\n{\n    [Fact]\n    public void Constructor_WithDefaultOptions_ShouldUseDefaults()\n    {\n        // Arrange & Act\n        var config = new ConnectionConfig();\n\n        // Assert\n        config.Protocol.Should().Be(ConnectionProtocol.Http);\n        config.Domain.Should().Be(\"localhost:8080\");\n        config.ApiKey.Should().BeNull();\n        config.RequestTimeoutSeconds.Should().Be(Constants.DefaultRequestTimeoutSeconds);\n        config.UseServerProxy.Should().BeFalse();\n        config.Headers.Should().BeEmpty();\n    }\n\n    [Fact]\n    public void Constructor_WithCustomOptions_ShouldApplyOptions()\n    {\n        // Arrange\n        var options = new ConnectionConfigOptions\n        {\n            Domain = \"api.example.com\",\n            Protocol = ConnectionProtocol.Https,\n            ApiKey = \"test-api-key\",\n            RequestTimeoutSeconds = 60,\n            UseServerProxy = true,\n            Headers = new Dictionary<string, string>\n            {\n                [\"X-Custom-Header\"] = \"custom-value\"\n            }\n        };\n\n        // Act\n        var config = new ConnectionConfig(options);\n\n        // Assert\n        config.Protocol.Should().Be(ConnectionProtocol.Https);\n        config.Domain.Should().Be(\"api.example.com\");\n        config.ApiKey.Should().Be(\"test-api-key\");\n        config.RequestTimeoutSeconds.Should().Be(60);\n        config.UseServerProxy.Should().BeTrue();\n        config.Headers.Should().ContainKey(\"X-Custom-Header\");\n        config.Headers[\"X-Custom-Header\"].Should().Be(\"custom-value\");\n    }\n\n    [Fact]\n    public void Constructor_WithApiKey_ShouldAddApiKeyHeader()\n    {\n        // Arrange\n        var options = new ConnectionConfigOptions\n        {\n            ApiKey = \"my-secret-key\"\n        };\n\n        // Act\n        var config = new ConnectionConfig(options);\n\n        // Assert\n        config.Headers.Should().ContainKey(Constants.ApiKeyHeader);\n        config.Headers[Constants.ApiKeyHeader].Should().Be(\"my-secret-key\");\n    }\n\n    [Fact]\n    public void GetBaseUrl_WithHttpProtocol_ShouldReturnHttpUrl()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"localhost:8080\",\n            Protocol = ConnectionProtocol.Http\n        });\n\n        // Act\n        var baseUrl = config.GetBaseUrl();\n\n        // Assert\n        baseUrl.Should().Be(\"http://localhost:8080/v1\");\n    }\n\n    [Fact]\n    public void GetBaseUrl_WithHttpsProtocol_ShouldReturnHttpsUrl()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"api.example.com\",\n            Protocol = ConnectionProtocol.Https\n        });\n\n        // Act\n        var baseUrl = config.GetBaseUrl();\n\n        // Assert\n        baseUrl.Should().Be(\"https://api.example.com/v1\");\n    }\n\n    [Fact]\n    public void GetBaseUrl_WithFullUrl_ShouldPreserveScheme()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"https://api.example.com\"\n        });\n\n        // Act\n        var baseUrl = config.GetBaseUrl();\n\n        // Assert\n        baseUrl.Should().Be(\"https://api.example.com/v1\");\n    }\n\n    [Fact]\n    public void GetBaseUrl_WithV1Suffix_ShouldNotDuplicate()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"https://api.example.com/v1\"\n        });\n\n        // Act\n        var baseUrl = config.GetBaseUrl();\n\n        // Assert\n        baseUrl.Should().Be(\"https://api.example.com/v1\");\n    }\n\n    [Fact]\n    public void GetBaseUrl_WithTrailingSlash_ShouldNormalize()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"api.example.com/\"\n        });\n\n        // Act\n        var baseUrl = config.GetBaseUrl();\n\n        // Assert\n        baseUrl.Should().Be(\"http://api.example.com/v1\");\n    }\n\n    [Fact]\n    public void CreateHttpClient_ShouldReturnConfiguredClient()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            RequestTimeoutSeconds = 45\n        });\n\n        // Act\n        var client = config.CreateHttpClient();\n\n        // Assert\n        client.Should().NotBeNull();\n        client.Timeout.Should().Be(TimeSpan.FromSeconds(45));\n    }\n\n    [Fact]\n    public void GetHttpClient_ShouldReturnSameInstance()\n    {\n        // Arrange\n        var config = new ConnectionConfig();\n\n        // Act\n        var client1 = config.GetHttpClient();\n        var client2 = config.GetHttpClient();\n\n        // Assert\n        client1.Should().BeSameAs(client2);\n    }\n\n    [Fact]\n    public void CreateSseHttpClient_ShouldHaveInfiniteTimeout()\n    {\n        // Arrange\n        var config = new ConnectionConfig();\n\n        // Act\n        var client = config.CreateSseHttpClient();\n\n        // Assert\n        client.Should().NotBeNull();\n        client.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/ConstantsTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Core;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class ConstantsTests\n{\n    [Fact]\n    public void DefaultExecdPort_ShouldBe44772()\n    {\n        Constants.DefaultExecdPort.Should().Be(44772);\n    }\n\n    [Fact]\n    public void DefaultEntrypoint_ShouldBeTailCommand()\n    {\n        Constants.DefaultEntrypoint.Should().BeEquivalentTo(new[] { \"tail\", \"-f\", \"/dev/null\" });\n    }\n\n    [Fact]\n    public void DefaultResourceLimits_ShouldContainCpuAndMemory()\n    {\n        Constants.DefaultResourceLimits.Should().ContainKey(\"cpu\");\n        Constants.DefaultResourceLimits.Should().ContainKey(\"memory\");\n        Constants.DefaultResourceLimits[\"cpu\"].Should().Be(\"1\");\n        Constants.DefaultResourceLimits[\"memory\"].Should().Be(\"2Gi\");\n    }\n\n    [Fact]\n    public void DefaultTimeoutSeconds_ShouldBe600()\n    {\n        Constants.DefaultTimeoutSeconds.Should().Be(600);\n    }\n\n    [Fact]\n    public void DefaultReadyTimeoutSeconds_ShouldBe30()\n    {\n        Constants.DefaultReadyTimeoutSeconds.Should().Be(30);\n    }\n\n    [Fact]\n    public void DefaultHealthCheckPollingIntervalMillis_ShouldBe200()\n    {\n        Constants.DefaultHealthCheckPollingIntervalMillis.Should().Be(200);\n    }\n\n    [Fact]\n    public void DefaultRequestTimeoutSeconds_ShouldBe30()\n    {\n        Constants.DefaultRequestTimeoutSeconds.Should().Be(30);\n    }\n\n    [Fact]\n    public void EnvDomain_ShouldBeCorrect()\n    {\n        Constants.EnvDomain.Should().Be(\"OPEN_SANDBOX_DOMAIN\");\n    }\n\n    [Fact]\n    public void EnvApiKey_ShouldBeCorrect()\n    {\n        Constants.EnvApiKey.Should().Be(\"OPEN_SANDBOX_API_KEY\");\n    }\n\n    [Fact]\n    public void ApiKeyHeader_ShouldBeCorrect()\n    {\n        Constants.ApiKeyHeader.Should().Be(\"OPEN-SANDBOX-API-KEY\");\n    }\n\n    [Fact]\n    public void RequestIdHeader_ShouldBeCorrect()\n    {\n        Constants.RequestIdHeader.Should().Be(\"x-request-id\");\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/ExceptionTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Core;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class ExceptionTests\n{\n    [Fact]\n    public void SandboxError_ShouldStoreCodeAndMessage()\n    {\n        // Arrange & Act\n        var error = new SandboxError(\"TEST_CODE\", \"Test message\");\n\n        // Assert\n        error.Code.Should().Be(\"TEST_CODE\");\n        error.Message.Should().Be(\"Test message\");\n    }\n\n    [Fact]\n    public void SandboxError_ToString_WithMessage_ShouldFormatCorrectly()\n    {\n        // Arrange\n        var error = new SandboxError(\"TEST_CODE\", \"Test message\");\n\n        // Act\n        var result = error.ToString();\n\n        // Assert\n        result.Should().Be(\"[TEST_CODE] Test message\");\n    }\n\n    [Fact]\n    public void SandboxError_ToString_WithoutMessage_ShouldFormatCorrectly()\n    {\n        // Arrange\n        var error = new SandboxError(\"TEST_CODE\");\n\n        // Act\n        var result = error.ToString();\n\n        // Assert\n        result.Should().Be(\"[TEST_CODE]\");\n    }\n\n    [Fact]\n    public void SandboxException_ShouldContainError()\n    {\n        // Arrange\n        var error = new SandboxError(\"TEST_CODE\", \"Test message\");\n\n        // Act\n        var exception = new SandboxException(\"Exception message\", error: error);\n\n        // Assert\n        exception.Message.Should().Be(\"Exception message\");\n        exception.Error.Should().Be(error);\n    }\n\n    [Fact]\n    public void SandboxException_ShouldContainRequestId()\n    {\n        // Arrange & Act\n        var exception = new SandboxException(\"Exception message\", requestId: \"req-base-123\");\n\n        // Assert\n        exception.RequestId.Should().Be(\"req-base-123\");\n    }\n\n    [Fact]\n    public void SandboxException_ShouldDeclareLegacyConstructor_ForBinaryCompatibility()\n    {\n        var constructor = typeof(SandboxException).GetConstructor(\n            new[]\n            {\n                typeof(string),\n                typeof(Exception),\n                typeof(SandboxError)\n            });\n\n        constructor.Should().NotBeNull();\n    }\n\n    [Fact]\n    public void SandboxException_WithoutError_ShouldCreateDefaultError()\n    {\n        // Arrange & Act\n        var exception = new SandboxException(\"Exception message\");\n\n        // Assert\n        exception.Error.Should().NotBeNull();\n        exception.Error.Code.Should().Be(SandboxErrorCodes.InternalUnknownError);\n    }\n\n    [Fact]\n    public void SandboxApiException_ShouldContainStatusCodeAndRequestId()\n    {\n        // Arrange & Act\n        var exception = new SandboxApiException(\n            message: \"API error\",\n            statusCode: 404,\n            requestId: \"req-123\",\n            rawBody: \"Not found\");\n\n        // Assert\n        exception.Message.Should().Be(\"API error\");\n        exception.StatusCode.Should().Be(404);\n        exception.RequestId.Should().Be(\"req-123\");\n        exception.RawBody.Should().Be(\"Not found\");\n        exception.Error.Code.Should().Be(SandboxErrorCodes.UnexpectedResponse);\n    }\n\n    [Fact]\n    public void SandboxApiException_ShouldDeclareRequestIdProperty_ForBinaryCompatibility()\n    {\n        var requestIdProperty = typeof(SandboxApiException).GetProperty(\n            \"RequestId\",\n            System.Reflection.BindingFlags.Public |\n            System.Reflection.BindingFlags.Instance |\n            System.Reflection.BindingFlags.DeclaredOnly);\n\n        requestIdProperty.Should().NotBeNull();\n    }\n\n    [Fact]\n    public void SandboxApiException_WithCustomError_ShouldUseProvidedError()\n    {\n        // Arrange\n        var error = new SandboxError(\"CUSTOM_CODE\", \"Custom message\");\n\n        // Act\n        var exception = new SandboxApiException(\n            message: \"API error\",\n            statusCode: 500,\n            error: error);\n\n        // Assert\n        exception.Error.Should().Be(error);\n        exception.Error.Code.Should().Be(\"CUSTOM_CODE\");\n    }\n\n    [Fact]\n    public void SandboxReadyTimeoutException_ShouldHaveCorrectErrorCode()\n    {\n        // Arrange & Act\n        var exception = new SandboxReadyTimeoutException(\"Timeout waiting for sandbox\");\n\n        // Assert\n        exception.Error.Code.Should().Be(SandboxErrorCodes.ReadyTimeout);\n        exception.Message.Should().Be(\"Timeout waiting for sandbox\");\n    }\n\n    [Fact]\n    public void SandboxUnhealthyException_ShouldHaveCorrectErrorCode()\n    {\n        // Arrange & Act\n        var exception = new SandboxUnhealthyException(\"Sandbox is unhealthy\");\n\n        // Assert\n        exception.Error.Code.Should().Be(SandboxErrorCodes.Unhealthy);\n        exception.Message.Should().Be(\"Sandbox is unhealthy\");\n    }\n\n    [Fact]\n    public void InvalidArgumentException_ShouldHaveCorrectErrorCode()\n    {\n        // Arrange & Act\n        var exception = new InvalidArgumentException(\"Invalid argument provided\");\n\n        // Assert\n        exception.Error.Code.Should().Be(SandboxErrorCodes.InvalidArgument);\n        exception.Message.Should().Be(\"Invalid argument provided\");\n    }\n\n    [Fact]\n    public void SandboxInternalException_ShouldHaveCorrectErrorCode()\n    {\n        // Arrange & Act\n        var exception = new SandboxInternalException(\"Internal error occurred\");\n\n        // Assert\n        exception.Error.Code.Should().Be(SandboxErrorCodes.InternalUnknownError);\n        exception.Message.Should().Be(\"Internal error occurred\");\n    }\n\n    [Fact]\n    public void SandboxException_WithInnerException_ShouldPreserveInnerException()\n    {\n        // Arrange\n        var innerException = new InvalidOperationException(\"Inner error\");\n\n        // Act\n        var exception = new SandboxException(\"Outer error\", innerException);\n\n        // Assert\n        exception.InnerException.Should().Be(innerException);\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class ModelsTests\n{\n    [Fact]\n    public void Execution_ShouldInitializeWithEmptyCollections()\n    {\n        // Arrange & Act\n        var execution = new Execution();\n\n        // Assert\n        execution.Id.Should().BeNull();\n        execution.ExecutionCount.Should().BeNull();\n        execution.Logs.Should().NotBeNull();\n        execution.Logs.Stdout.Should().BeEmpty();\n        execution.Logs.Stderr.Should().BeEmpty();\n        execution.Results.Should().BeEmpty();\n        execution.Error.Should().BeNull();\n        execution.Complete.Should().BeNull();\n    }\n\n    [Fact]\n    public void ExecutionLogs_ShouldAllowAddingMessages()\n    {\n        // Arrange\n        var logs = new ExecutionLogs();\n        var stdoutMsg = new OutputMessage { Text = \"stdout\", Timestamp = 1000, IsError = false };\n        var stderrMsg = new OutputMessage { Text = \"stderr\", Timestamp = 2000, IsError = true };\n\n        // Act\n        logs.Stdout.Add(stdoutMsg);\n        logs.Stderr.Add(stderrMsg);\n\n        // Assert\n        logs.Stdout.Should().HaveCount(1);\n        logs.Stdout[0].Text.Should().Be(\"stdout\");\n        logs.Stderr.Should().HaveCount(1);\n        logs.Stderr[0].Text.Should().Be(\"stderr\");\n        logs.Stderr[0].IsError.Should().BeTrue();\n    }\n\n    [Fact]\n    public void OutputMessage_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var msg = new OutputMessage\n        {\n            Text = \"Hello World\",\n            Timestamp = 1234567890,\n            IsError = false\n        };\n\n        // Assert\n        msg.Text.Should().Be(\"Hello World\");\n        msg.Timestamp.Should().Be(1234567890);\n        msg.IsError.Should().BeFalse();\n    }\n\n    [Fact]\n    public void ExecutionResult_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var result = new ExecutionResult\n        {\n            Text = \"Result text\",\n            Timestamp = 1234567890,\n            Raw = new Dictionary<string, object> { [\"text/plain\"] = \"Result text\" }\n        };\n\n        // Assert\n        result.Text.Should().Be(\"Result text\");\n        result.Timestamp.Should().Be(1234567890);\n        result.Raw.Should().ContainKey(\"text/plain\");\n    }\n\n    [Fact]\n    public void ExecutionError_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var error = new ExecutionError\n        {\n            Name = \"ValueError\",\n            Value = \"Invalid value\",\n            Timestamp = 1234567890,\n            Traceback = new List<string> { \"line 1\", \"line 2\" }\n        };\n\n        // Assert\n        error.Name.Should().Be(\"ValueError\");\n        error.Value.Should().Be(\"Invalid value\");\n        error.Timestamp.Should().Be(1234567890);\n        error.Traceback.Should().HaveCount(2);\n    }\n\n    [Fact]\n    public void ExecutionComplete_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var complete = new ExecutionComplete\n        {\n            Timestamp = 1234567890,\n            ExecutionTimeMs = 500\n        };\n\n        // Assert\n        complete.Timestamp.Should().Be(1234567890);\n        complete.ExecutionTimeMs.Should().Be(500);\n    }\n\n    [Fact]\n    public void SandboxInfo_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var info = new SandboxInfo\n        {\n            Id = \"sandbox-123\",\n            Image = new ImageSpec { Uri = \"ubuntu:latest\" },\n            Entrypoint = new List<string> { \"tail\", \"-f\", \"/dev/null\" },\n            Status = new SandboxStatus { State = \"Running\" },\n            CreatedAt = DateTime.UtcNow,\n            ExpiresAt = DateTime.UtcNow.AddMinutes(10),\n            Metadata = new Dictionary<string, string> { [\"key\"] = \"value\" }\n        };\n\n        // Assert\n        info.Id.Should().Be(\"sandbox-123\");\n        info.Image.Uri.Should().Be(\"ubuntu:latest\");\n        info.Entrypoint.Should().HaveCount(3);\n        info.Status.State.Should().Be(\"Running\");\n        info.Metadata.Should().ContainKey(\"key\");\n    }\n\n    [Fact]\n    public void SandboxStatus_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var status = new SandboxStatus\n        {\n            State = \"Error\",\n            Reason = \"ImagePullFailed\",\n            Message = \"Failed to pull image\"\n        };\n\n        // Assert\n        status.State.Should().Be(\"Error\");\n        status.Reason.Should().Be(\"ImagePullFailed\");\n        status.Message.Should().Be(\"Failed to pull image\");\n    }\n\n    [Fact]\n    public void ImageSpec_WithAuth_ShouldStoreCredentials()\n    {\n        // Arrange & Act\n        var image = new ImageSpec\n        {\n            Uri = \"private-registry.com/image:tag\",\n            Auth = new ImageAuth\n            {\n                Username = \"user\",\n                Password = \"pass\"\n            }\n        };\n\n        // Assert\n        image.Uri.Should().Be(\"private-registry.com/image:tag\");\n        image.Auth.Should().NotBeNull();\n        image.Auth!.Username.Should().Be(\"user\");\n        image.Auth.Password.Should().Be(\"pass\");\n    }\n\n    [Fact]\n    public void NetworkPolicy_ShouldStoreRules()\n    {\n        // Arrange & Act\n        var policy = new NetworkPolicy\n        {\n            DefaultAction = NetworkRuleAction.Deny,\n            Egress = new List<NetworkRule>\n            {\n                new() { Action = NetworkRuleAction.Allow, Target = \"example.com\" },\n                new() { Action = NetworkRuleAction.Allow, Target = \"*.trusted.com\" }\n            }\n        };\n\n        // Assert\n        policy.DefaultAction.Should().Be(NetworkRuleAction.Deny);\n        policy.Egress.Should().HaveCount(2);\n        policy.Egress![0].Action.Should().Be(NetworkRuleAction.Allow);\n        policy.Egress[0].Target.Should().Be(\"example.com\");\n    }\n\n    [Fact]\n    public void SandboxMetrics_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var metrics = new SandboxMetrics\n        {\n            CpuCount = 4,\n            CpuUsedPercentage = 25.5,\n            MemoryTotalMiB = 8192,\n            MemoryUsedMiB = 2048,\n            Timestamp = 1234567890\n        };\n\n        // Assert\n        metrics.CpuCount.Should().Be(4);\n        metrics.CpuUsedPercentage.Should().Be(25.5);\n        metrics.MemoryTotalMiB.Should().Be(8192);\n        metrics.MemoryUsedMiB.Should().Be(2048);\n        metrics.Timestamp.Should().Be(1234567890);\n    }\n\n    [Fact]\n    public void SandboxFileInfo_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var fileInfo = new SandboxFileInfo\n        {\n            Path = \"/tmp/test.txt\",\n            Size = 1024,\n            Mode = 644,\n            Owner = \"root\",\n            Group = \"root\",\n            CreatedAt = DateTime.UtcNow,\n            ModifiedAt = DateTime.UtcNow\n        };\n\n        // Assert\n        fileInfo.Path.Should().Be(\"/tmp/test.txt\");\n        fileInfo.Size.Should().Be(1024);\n        fileInfo.Mode.Should().Be(644);\n        fileInfo.Owner.Should().Be(\"root\");\n    }\n\n    [Fact]\n    public void WriteEntry_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var entry = new WriteEntry\n        {\n            Path = \"/tmp/file.txt\",\n            Data = \"Hello World\",\n            Mode = 644,\n            Owner = \"user\",\n            Group = \"group\"\n        };\n\n        // Assert\n        entry.Path.Should().Be(\"/tmp/file.txt\");\n        entry.Data.Should().Be(\"Hello World\");\n        entry.Mode.Should().Be(644);\n    }\n\n    [Fact]\n    public void SearchEntry_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var entry = new SearchEntry\n        {\n            Path = \"/tmp\",\n            Pattern = \"*.txt\"\n        };\n\n        // Assert\n        entry.Path.Should().Be(\"/tmp\");\n        entry.Pattern.Should().Be(\"*.txt\");\n    }\n\n    [Fact]\n    public void MoveEntry_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var entry = new MoveEntry\n        {\n            Src = \"/tmp/old.txt\",\n            Dest = \"/tmp/new.txt\"\n        };\n\n        // Assert\n        entry.Src.Should().Be(\"/tmp/old.txt\");\n        entry.Dest.Should().Be(\"/tmp/new.txt\");\n    }\n\n    [Fact]\n    public void RunCommandOptions_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var options = new RunCommandOptions\n        {\n            WorkingDirectory = \"/home/user\",\n            Background = true,\n            TimeoutSeconds = 30,\n            Uid = 1000,\n            Gid = 1000,\n            Envs = new Dictionary<string, string>\n            {\n                [\"APP_ENV\"] = \"test\"\n            }\n        };\n\n        // Assert\n        options.WorkingDirectory.Should().Be(\"/home/user\");\n        options.Background.Should().BeTrue();\n        options.TimeoutSeconds.Should().Be(30);\n        options.Uid.Should().Be(1000);\n        options.Gid.Should().Be(1000);\n        options.Envs.Should().ContainKey(\"APP_ENV\");\n    }\n\n    [Fact]\n    public void ServerStreamEvent_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var ev = new ServerStreamEvent\n        {\n            Type = \"stdout\",\n            Text = \"output text\",\n            Timestamp = 1234567890,\n            ExecutionCount = 1,\n            ExecutionTime = 100\n        };\n\n        // Assert\n        ev.Type.Should().Be(\"stdout\");\n        ev.Text.Should().Be(\"output text\");\n        ev.Timestamp.Should().Be(1234567890);\n        ev.ExecutionCount.Should().Be(1);\n        ev.ExecutionTime.Should().Be(100);\n    }\n\n    [Fact]\n    public void CommandStatus_ShouldStoreProperties()\n    {\n        var startedAt = DateTime.UtcNow.AddSeconds(-5);\n        var finishedAt = DateTime.UtcNow;\n        var status = new CommandStatus\n        {\n            Id = \"cmd-1\",\n            Content = \"echo hello\",\n            Running = false,\n            ExitCode = 0,\n            Error = null,\n            StartedAt = startedAt,\n            FinishedAt = finishedAt\n        };\n\n        status.Id.Should().Be(\"cmd-1\");\n        status.Content.Should().Be(\"echo hello\");\n        status.Running.Should().BeFalse();\n        status.ExitCode.Should().Be(0);\n        status.StartedAt.Should().Be(startedAt);\n        status.FinishedAt.Should().Be(finishedAt);\n    }\n\n    [Fact]\n    public void CommandLogs_ShouldStoreProperties()\n    {\n        var logs = new CommandLogs\n        {\n            Content = \"line1\\nline2\\n\",\n            Cursor = 12\n        };\n\n        logs.Content.Should().Contain(\"line1\");\n        logs.Cursor.Should().Be(12);\n    }\n\n    [Fact]\n    public void PaginationInfo_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var pagination = new PaginationInfo\n        {\n            Page = 1,\n            PageSize = 10,\n            TotalItems = 100,\n            TotalPages = 10,\n            HasNextPage = true\n        };\n\n        // Assert\n        pagination.Page.Should().Be(1);\n        pagination.PageSize.Should().Be(10);\n        pagination.TotalItems.Should().Be(100);\n        pagination.TotalPages.Should().Be(10);\n        pagination.HasNextPage.Should().BeTrue();\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/OpenSandbox.Tests.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <LangVersion>12.0</LangVersion>\n    <Nullable>enable</Nullable>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <IsPackable>false</IsPackable>\n    <IsTestProject>true</IsTestProject>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.NET.Test.Sdk\" Version=\"17.11.1\" />\n    <PackageReference Include=\"xunit\" Version=\"2.9.2\" />\n    <PackageReference Include=\"xunit.runner.visualstudio\" Version=\"2.8.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n    <PackageReference Include=\"coverlet.collector\" Version=\"6.0.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n    <PackageReference Include=\"Moq\" Version=\"4.20.72\" />\n    <PackageReference Include=\"FluentAssertions\" Version=\"6.12.2\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <ProjectReference Include=\"..\\..\\src\\OpenSandbox\\OpenSandbox.csproj\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/OptionsTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class OptionsTests\n{\n    [Fact]\n    public void SandboxCreateOptions_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var options = new SandboxCreateOptions\n        {\n            Image = \"ubuntu:latest\",\n            TimeoutSeconds = 600,\n            Entrypoint = new[] { \"bash\" },\n            Env = new Dictionary<string, string> { [\"KEY\"] = \"VALUE\" },\n            Metadata = new Dictionary<string, string> { [\"tag\"] = \"test\" },\n            Resource = new Dictionary<string, string> { [\"cpu\"] = \"2\" },\n            SkipHealthCheck = true,\n            ReadyTimeoutSeconds = 60,\n            HealthCheckPollingInterval = 500\n        };\n\n        // Assert\n        options.Image.Should().Be(\"ubuntu:latest\");\n        options.TimeoutSeconds.Should().Be(600);\n        options.Entrypoint.Should().Contain(\"bash\");\n        options.Env.Should().ContainKey(\"KEY\");\n        options.Metadata.Should().ContainKey(\"tag\");\n        options.Resource.Should().ContainKey(\"cpu\");\n        options.SkipHealthCheck.Should().BeTrue();\n        options.ReadyTimeoutSeconds.Should().Be(60);\n        options.HealthCheckPollingInterval.Should().Be(500);\n    }\n\n    [Fact]\n    public void SandboxCreateOptions_WithNetworkPolicy_ShouldStorePolicy()\n    {\n        // Arrange & Act\n        var options = new SandboxCreateOptions\n        {\n            Image = \"python:3.11\",\n            NetworkPolicy = new NetworkPolicy\n            {\n                DefaultAction = NetworkRuleAction.Deny,\n                Egress = new List<NetworkRule>\n                {\n                    new() { Action = NetworkRuleAction.Allow, Target = \"pypi.org\" }\n                }\n            }\n        };\n\n        // Assert\n        options.NetworkPolicy.Should().NotBeNull();\n        options.NetworkPolicy!.DefaultAction.Should().Be(NetworkRuleAction.Deny);\n        options.NetworkPolicy.Egress.Should().HaveCount(1);\n    }\n\n    [Fact]\n    public void SandboxCreateOptions_WithImageAuth_ShouldStoreAuth()\n    {\n        // Arrange & Act\n        var options = new SandboxCreateOptions\n        {\n            Image = \"private-registry.com/image:tag\",\n            ImageAuth = new ImageAuth\n            {\n                Username = \"user\",\n                Password = \"pass\"\n            }\n        };\n\n        // Assert\n        options.ImageAuth.Should().NotBeNull();\n        options.ImageAuth!.Username.Should().Be(\"user\");\n        options.ImageAuth.Password.Should().Be(\"pass\");\n    }\n\n    [Fact]\n    public void SandboxConnectOptions_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var options = new SandboxConnectOptions\n        {\n            SandboxId = \"sandbox-123\",\n            SkipHealthCheck = false,\n            ReadyTimeoutSeconds = 30,\n            HealthCheckPollingInterval = 200\n        };\n\n        // Assert\n        options.SandboxId.Should().Be(\"sandbox-123\");\n        options.SkipHealthCheck.Should().BeFalse();\n        options.ReadyTimeoutSeconds.Should().Be(30);\n        options.HealthCheckPollingInterval.Should().Be(200);\n    }\n\n    [Fact]\n    public void SandboxResumeOptions_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var options = new SandboxResumeOptions\n        {\n            SkipHealthCheck = true,\n            ReadyTimeoutSeconds = 45,\n            HealthCheckPollingInterval = 300\n        };\n\n        // Assert\n        options.SkipHealthCheck.Should().BeTrue();\n        options.ReadyTimeoutSeconds.Should().Be(45);\n        options.HealthCheckPollingInterval.Should().Be(300);\n    }\n\n    [Fact]\n    public void WaitUntilReadyOptions_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var options = new WaitUntilReadyOptions\n        {\n            ReadyTimeoutSeconds = 60,\n            PollingIntervalMillis = 500\n        };\n\n        // Assert\n        options.ReadyTimeoutSeconds.Should().Be(60);\n        options.PollingIntervalMillis.Should().Be(500);\n    }\n\n    [Fact]\n    public void WaitUntilReadyOptions_WithCustomHealthCheck_ShouldStoreFunction()\n    {\n        // Arrange\n        Func<Sandbox, Task<bool>> healthCheck = async (sbx) =>\n        {\n            await Task.Delay(1);\n            return true;\n        };\n\n        // Act\n        var options = new WaitUntilReadyOptions\n        {\n            ReadyTimeoutSeconds = 30,\n            PollingIntervalMillis = 200,\n            HealthCheck = healthCheck\n        };\n\n        // Assert\n        options.HealthCheck.Should().NotBeNull();\n        options.HealthCheck.Should().BeSameAs(healthCheck);\n    }\n\n    [Fact]\n    public void SandboxManagerOptions_ShouldStoreProperties()\n    {\n        // Arrange\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = \"api.example.com\"\n        });\n\n        // Act\n        var options = new SandboxManagerOptions\n        {\n            ConnectionConfig = config\n        };\n\n        // Assert\n        options.ConnectionConfig.Should().BeSameAs(config);\n    }\n\n    [Fact]\n    public void SandboxFilter_ShouldStoreProperties()\n    {\n        // Arrange & Act\n        var filter = new SandboxFilter\n        {\n            States = new[] { \"Running\", \"Paused\" },\n            Metadata = new Dictionary<string, string> { [\"env\"] = \"test\" },\n            Page = 1,\n            PageSize = 20\n        };\n\n        // Assert\n        filter.States.Should().HaveCount(2);\n        filter.States.Should().Contain(\"Running\");\n        filter.States.Should().Contain(\"Paused\");\n        filter.Metadata.Should().ContainKey(\"env\");\n        filter.Page.Should().Be(1);\n        filter.PageSize.Should().Be(20);\n    }\n\n    [Fact]\n    public void SandboxFilter_WithNullValues_ShouldAllowNulls()\n    {\n        // Arrange & Act\n        var filter = new SandboxFilter();\n\n        // Assert\n        filter.States.Should().BeNull();\n        filter.Metadata.Should().BeNull();\n        filter.Page.Should().BeNull();\n        filter.PageSize.Should().BeNull();\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Factory;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\nusing Microsoft.Extensions.Logging.Abstractions;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class SandboxEgressLifecycleTests\n{\n    [Fact]\n    public async Task CreateAsync_ShouldBuildEgressStackOnce_AndReuseItForOperations()\n    {\n        var sandboxes = new StubSandboxes();\n        var egress = new StubEgress();\n        var adapterFactory = new StubAdapterFactory(sandboxes, egress);\n\n        var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            Image = \"python:3.12\",\n            ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions\n            {\n                Domain = \"127.0.0.1:8080\",\n                Protocol = ConnectionProtocol.Http\n            }),\n            AdapterFactory = adapterFactory,\n            SkipHealthCheck = true,\n            Diagnostics = new SdkDiagnosticsOptions\n            {\n                LoggerFactory = NullLoggerFactory.Instance\n            }\n        });\n\n        await sandbox.GetEgressPolicyAsync();\n        await sandbox.PatchEgressRulesAsync([new NetworkRule\n        {\n            Action = NetworkRuleAction.Allow,\n            Target = \"www.github.com\"\n        }]);\n\n        sandboxes.EndpointCalls.Should().Equal(Constants.DefaultExecdPort, Constants.DefaultEgressPort);\n        adapterFactory.EgressStackCallCount.Should().Be(1);\n        adapterFactory.LastEgressBaseUrl.Should().Be($\"http://127.0.0.1:{Constants.DefaultEgressPort}\");\n        egress.GetPolicyCallCount.Should().Be(1);\n        egress.PatchRulesCallCount.Should().Be(1);\n    }\n\n    private sealed class StubAdapterFactory : IAdapterFactory\n    {\n        private readonly ISandboxes _sandboxes;\n        private readonly IEgress _egress;\n\n        public StubAdapterFactory(ISandboxes sandboxes, IEgress egress)\n        {\n            _sandboxes = sandboxes;\n            _egress = egress;\n        }\n\n        public int EgressStackCallCount { get; private set; }\n\n        public string? LastEgressBaseUrl { get; private set; }\n\n        public LifecycleStack CreateLifecycleStack(CreateLifecycleStackOptions options)\n        {\n            return new LifecycleStack\n            {\n                Sandboxes = _sandboxes\n            };\n        }\n\n        public ExecdStack CreateExecdStack(CreateExecdStackOptions options)\n        {\n            return new ExecdStack\n            {\n                Commands = new StubCommands(),\n                Files = new StubFiles(),\n                Health = new StubHealth(),\n                Metrics = new StubMetrics()\n            };\n        }\n\n        public EgressStack CreateEgressStack(CreateEgressStackOptions options)\n        {\n            EgressStackCallCount++;\n            LastEgressBaseUrl = options.EgressBaseUrl;\n            return new EgressStack\n            {\n                Egress = _egress\n            };\n        }\n    }\n\n    private sealed class StubSandboxes : ISandboxes\n    {\n        public List<int> EndpointCalls { get; } = new();\n\n        public Task<CreateSandboxResponse> CreateSandboxAsync(CreateSandboxRequest request, CancellationToken cancellationToken = default)\n        {\n            return Task.FromResult(new CreateSandboxResponse\n            {\n                Id = \"sandbox-test-id\",\n                Status = new SandboxStatus\n                {\n                    State = \"Running\"\n                },\n                CreatedAt = DateTime.UtcNow,\n                Entrypoint = [\"/bin/sh\"]\n            });\n        }\n\n        public Task<SandboxInfo> GetSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<ListSandboxesResponse> ListSandboxesAsync(ListSandboxesParams? @params = null, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task DeleteSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>\n            Task.CompletedTask;\n\n        public Task PauseSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task ResumeSandboxAsync(string sandboxId, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<RenewSandboxExpirationResponse> RenewSandboxExpirationAsync(string sandboxId, RenewSandboxExpirationRequest request, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<Endpoint> GetSandboxEndpointAsync(string sandboxId, int port, bool useServerProxy = false, CancellationToken cancellationToken = default)\n        {\n            EndpointCalls.Add(port);\n            return Task.FromResult(new Endpoint\n            {\n                EndpointAddress = $\"127.0.0.1:{port}\",\n                Headers = new Dictionary<string, string>\n                {\n                    [\"X-Port\"] = port.ToString()\n                }\n            });\n        }\n    }\n\n    private sealed class StubEgress : IEgress\n    {\n        public int GetPolicyCallCount { get; private set; }\n\n        public int PatchRulesCallCount { get; private set; }\n\n        public Task<NetworkPolicy> GetPolicyAsync(CancellationToken cancellationToken = default)\n        {\n            GetPolicyCallCount++;\n            return Task.FromResult(new NetworkPolicy\n            {\n                DefaultAction = NetworkRuleAction.Deny,\n                Egress = [new NetworkRule\n                {\n                    Action = NetworkRuleAction.Allow,\n                    Target = \"pypi.org\"\n                }]\n            });\n        }\n\n        public Task PatchRulesAsync(IReadOnlyList<NetworkRule> rules, CancellationToken cancellationToken = default)\n        {\n            PatchRulesCallCount++;\n            return Task.CompletedTask;\n        }\n    }\n\n    private sealed class StubCommands : IExecdCommands\n    {\n        public IAsyncEnumerable<ServerStreamEvent> RunStreamAsync(string command, RunCommandOptions? options = null, CancellationToken cancellationToken = default) =>\n            AsyncEnumerable.Empty<ServerStreamEvent>();\n\n        public Task<Execution> RunAsync(string command, RunCommandOptions? options = null, ExecutionHandlers? handlers = null, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<CommandStatus> GetCommandStatusAsync(string executionId, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<CommandLogs> GetBackgroundCommandLogsAsync(string executionId, long? cursor = null, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task InterruptAsync(string executionId, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n    }\n\n    private sealed class StubFiles : ISandboxFiles\n    {\n        public Task<IReadOnlyDictionary<string, SandboxFileInfo>> GetFileInfoAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<IReadOnlyList<SandboxFileInfo>> SearchAsync(SearchEntry entry, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task CreateDirectoriesAsync(IEnumerable<CreateDirectoryEntry> entries, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task DeleteDirectoriesAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task WriteFilesAsync(IEnumerable<WriteEntry> entries, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<string> ReadFileAsync(string path, ReadFileOptions? options = null, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task<byte[]> ReadBytesAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public IAsyncEnumerable<byte[]> ReadBytesStreamAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) =>\n            AsyncEnumerable.Empty<byte[]>();\n\n        public Task DeleteFilesAsync(IEnumerable<string> paths, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task MoveFilesAsync(IEnumerable<MoveEntry> entries, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task ReplaceContentsAsync(IEnumerable<ContentReplaceEntry> entries, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n\n        public Task SetPermissionsAsync(IEnumerable<SetPermissionEntry> entries, CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n    }\n\n    private sealed class StubHealth : IExecdHealth\n    {\n        public Task<bool> PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);\n    }\n\n    private sealed class StubMetrics : IExecdMetrics\n    {\n        public Task<SandboxMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) =>\n            throw new NotImplementedException();\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing FluentAssertions;\nusing Moq;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Factory;\nusing OpenSandbox.Models;\nusing OpenSandbox.Services;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class SandboxReadinessDiagnosticsTests\n{\n    [Fact]\n    public async Task WaitUntilReadyAsync_WhenHealthCheckThrows_IncludesLastErrorAndConnectionContext()\n    {\n        // Arrange\n        var healthMock = new Mock<IExecdHealth>();\n        healthMock\n            .Setup(x => x.PingAsync(It.IsAny<CancellationToken>()))\n            .ThrowsAsync(new Exception(\"connect ECONNREFUSED 127.0.0.1:8080\"));\n\n        var sandbox = await CreateSandboxForReadinessTestAsync(healthMock, useServerProxy: false);\n\n        // Act\n        Func<Task> action = async () =>\n            await sandbox.WaitUntilReadyAsync(new WaitUntilReadyOptions\n            {\n                ReadyTimeoutSeconds = 1,\n                PollingIntervalMillis = 1\n            });\n\n        // Assert\n        try\n        {\n            var ex = await action.Should().ThrowAsync<SandboxReadyTimeoutException>();\n            ex.Which.Message.Should().Contain(\"Sandbox health check timed out\");\n            ex.Which.Message.Should().Contain(\"Last health check error\");\n            ex.Which.Message.Should().Contain(\"domain=localhost:8080\");\n            ex.Which.Message.Should().Contain(\"useServerProxy=False\");\n            ex.Which.Message.Should().Contain(\"useServerProxy=true\");\n            ex.Which.Message.Should().Contain(\"[docker].host_ip\");\n        }\n        finally\n        {\n            await sandbox.DisposeAsync();\n        }\n    }\n\n    [Fact]\n    public async Task WaitUntilReadyAsync_WhenHealthCheckReturnsFalse_UsesFalseContinuouslyHint()\n    {\n        // Arrange\n        var healthMock = new Mock<IExecdHealth>();\n        healthMock\n            .Setup(x => x.PingAsync(It.IsAny<CancellationToken>()))\n            .ReturnsAsync(false);\n\n        var sandbox = await CreateSandboxForReadinessTestAsync(healthMock, useServerProxy: true);\n\n        // Act\n        Func<Task> action = async () =>\n            await sandbox.WaitUntilReadyAsync(new WaitUntilReadyOptions\n            {\n                ReadyTimeoutSeconds = 1,\n                PollingIntervalMillis = 1\n            });\n\n        // Assert\n        try\n        {\n            var ex = await action.Should().ThrowAsync<SandboxReadyTimeoutException>();\n            ex.Which.Message.Should().Contain(\"Health check returned false continuously.\");\n            ex.Which.Message.Should().Contain(\"useServerProxy=True\");\n            ex.Which.Message.Should().NotContain(\"[docker].host_ip\");\n        }\n        finally\n        {\n            await sandbox.DisposeAsync();\n        }\n    }\n\n    private static async Task<Sandbox> CreateSandboxForReadinessTestAsync(\n        Mock<IExecdHealth> healthMock,\n        bool useServerProxy)\n    {\n        var sandboxesMock = new Mock<ISandboxes>();\n        sandboxesMock\n            .Setup(x => x.GetSandboxEndpointAsync(\n                It.IsAny<string>(),\n                It.IsAny<int>(),\n                useServerProxy,\n                It.IsAny<CancellationToken>()))\n            .ReturnsAsync(new Endpoint\n            {\n                EndpointAddress = \"127.0.0.1:44772\",\n                Headers = new Dictionary<string, string>()\n            });\n\n        var adapterFactoryMock = new Mock<IAdapterFactory>();\n        adapterFactoryMock\n            .Setup(x => x.CreateLifecycleStack(It.IsAny<CreateLifecycleStackOptions>()))\n            .Returns(new LifecycleStack\n            {\n                Sandboxes = sandboxesMock.Object\n            });\n\n        adapterFactoryMock\n            .Setup(x => x.CreateExecdStack(It.IsAny<CreateExecdStackOptions>()))\n            .Returns(new ExecdStack\n            {\n                Commands = Mock.Of<IExecdCommands>(),\n                Files = Mock.Of<ISandboxFiles>(),\n                Health = healthMock.Object,\n                Metrics = Mock.Of<IExecdMetrics>()\n            });\n\n        adapterFactoryMock\n            .Setup(x => x.CreateEgressStack(It.IsAny<CreateEgressStackOptions>()))\n            .Returns(new EgressStack\n            {\n                Egress = Mock.Of<IEgress>()\n            });\n\n        return await Sandbox.ConnectAsync(new SandboxConnectOptions\n        {\n            SandboxId = \"sbx-ready-diagnostics\",\n            ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions\n            {\n                Domain = \"localhost:8080\",\n                UseServerProxy = useServerProxy\n            }),\n            AdapterFactory = adapterFactoryMock.Object,\n            SkipHealthCheck = true\n        });\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxesAdapterTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Net;\nusing System.Text;\nusing FluentAssertions;\nusing OpenSandbox.Adapters;\nusing OpenSandbox.Internal;\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class SandboxesAdapterTests\n{\n    [Fact]\n    public async Task GetSandboxEndpointAsync_ShouldIncludeUseServerProxyQueryParam()\n    {\n        // Arrange\n        var handler = new CaptureHandler();\n        var client = new HttpClient(handler);\n        var wrapper = new HttpClientWrapper(client, \"http://localhost:8080/v1\");\n        var adapter = new SandboxesAdapter(wrapper);\n\n        // Act\n        _ = await adapter.GetSandboxEndpointAsync(\"sbx-1\", 44772, useServerProxy: true);\n\n        // Assert\n        handler.LastRequestUri.Should().NotBeNull();\n        handler.LastRequestUri!.PathAndQuery.Should().Contain(\"/sandboxes/sbx-1/endpoints/44772\");\n        handler.LastRequestUri!.Query.Should().Contain(\"use_server_proxy=true\");\n    }\n\n    [Fact]\n    public async Task GetSandboxEndpointAsync_ShouldDefaultUseServerProxyToFalse()\n    {\n        // Arrange\n        var handler = new CaptureHandler();\n        var client = new HttpClient(handler);\n        var wrapper = new HttpClientWrapper(client, \"http://localhost:8080/v1\");\n        var adapter = new SandboxesAdapter(wrapper);\n\n        // Act\n        _ = await adapter.GetSandboxEndpointAsync(\"sbx-2\", 44772);\n\n        // Assert\n        handler.LastRequestUri.Should().NotBeNull();\n        handler.LastRequestUri!.Query.Should().Contain(\"use_server_proxy=false\");\n    }\n\n    [Fact]\n    public async Task GetSandboxAsync_ShouldTreatMissingExpiresAtAsNull()\n    {\n        var payload = \"\"\"\n        {\n          \"id\": \"sbx-1\",\n          \"image\": { \"uri\": \"python:3.11\" },\n          \"entrypoint\": [\"python\"],\n          \"status\": { \"state\": \"Running\" },\n          \"createdAt\": \"2026-03-14T12:00:00Z\"\n        }\n        \"\"\";\n        var adapter = CreateAdapterWithJsonResponse(payload);\n\n        SandboxInfo sandbox = await adapter.GetSandboxAsync(\"sbx-1\");\n\n        sandbox.ExpiresAt.Should().BeNull();\n    }\n\n    [Fact]\n    public async Task CreateSandboxAsync_ShouldTreatMissingExpiresAtAsNull()\n    {\n        var payload = \"\"\"\n        {\n          \"id\": \"sbx-2\",\n          \"status\": { \"state\": \"Pending\" },\n          \"createdAt\": \"2026-03-14T12:00:00Z\",\n          \"entrypoint\": [\"python\"]\n        }\n        \"\"\";\n        var adapter = CreateAdapterWithJsonResponse(payload);\n\n        CreateSandboxResponse response = await adapter.CreateSandboxAsync(new CreateSandboxRequest\n        {\n            Image = new ImageSpec { Uri = \"python:3.11\" },\n            ResourceLimits = new Dictionary<string, string>(),\n            Entrypoint = new List<string> { \"python\" }\n        });\n\n        response.ExpiresAt.Should().BeNull();\n    }\n\n    private static SandboxesAdapter CreateAdapterWithJsonResponse(string payload)\n    {\n        var handler = new StaticJsonHandler(payload);\n        var client = new HttpClient(handler);\n        var wrapper = new HttpClientWrapper(client, \"http://localhost:8080/v1\");\n        return new SandboxesAdapter(wrapper);\n    }\n\n    private sealed class CaptureHandler : HttpMessageHandler\n    {\n        public Uri? LastRequestUri { get; private set; }\n\n        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n        {\n            LastRequestUri = request.RequestUri;\n            var payload = \"{\\\"endpoint\\\":\\\"example.internal:44772\\\",\\\"headers\\\":{}}\";\n            var response = new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(payload, Encoding.UTF8, \"application/json\")\n            };\n            return Task.FromResult(response);\n        }\n    }\n\n    private sealed class StaticJsonHandler(string payload) : HttpMessageHandler\n    {\n        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)\n        {\n            var response = new HttpResponseMessage(HttpStatusCode.OK)\n            {\n                Content = new StringContent(payload, Encoding.UTF8, \"application/json\")\n            };\n            return Task.FromResult(response);\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/csharp/tests/OpenSandbox.Tests/SseParserTests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Net;\nusing System.Text;\nusing FluentAssertions;\nusing OpenSandbox.Adapters;\nusing OpenSandbox.Core;\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.Tests;\n\npublic class SseParserTests\n{\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithSseFormat_ShouldParseEvents()\n    {\n        // Arrange\n        var sseContent = @\"data: {\"\"type\"\":\"\"init\"\",\"\"text\"\":\"\"session-123\"\"}\n\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"Hello World\"\"}\n\ndata: {\"\"type\"\":\"\"execution_complete\"\",\"\"execution_time\"\":100}\n\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(3);\n        events[0].Type.Should().Be(\"init\");\n        events[0].Text.Should().Be(\"session-123\");\n        events[1].Type.Should().Be(\"stdout\");\n        events[1].Text.Should().Be(\"Hello World\");\n        events[2].Type.Should().Be(\"execution_complete\");\n        events[2].ExecutionTime.Should().Be(100);\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithNdjsonFormat_ShouldParseEvents()\n    {\n        // Arrange\n        var ndjsonContent = @\"{\"\"type\"\":\"\"init\"\",\"\"text\"\":\"\"session-456\"\"}\n{\"\"type\"\":\"\"stderr\"\",\"\"text\"\":\"\"Error message\"\"}\n{\"\"type\"\":\"\"execution_complete\"\",\"\"execution_time\"\":50}\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, ndjsonContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(3);\n        events[0].Type.Should().Be(\"init\");\n        events[1].Type.Should().Be(\"stderr\");\n        events[1].Text.Should().Be(\"Error message\");\n        events[2].Type.Should().Be(\"execution_complete\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithSseComments_ShouldSkipComments()\n    {\n        // Arrange\n        var sseContent = @\": this is a comment\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"output\"\"}\n: another comment\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(1);\n        events[0].Type.Should().Be(\"stdout\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithSseMetadata_ShouldSkipMetadata()\n    {\n        // Arrange\n        var sseContent = @\"event: message\nid: 123\nretry: 5000\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"output\"\"}\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(1);\n        events[0].Type.Should().Be(\"stdout\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithEmptyLines_ShouldSkipEmptyLines()\n    {\n        // Arrange\n        var sseContent = @\"\n\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"output\"\"}\n\n\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(1);\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithInvalidJson_ShouldSkipInvalidLines()\n    {\n        // Arrange\n        var sseContent = @\"data: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"valid\"\"}\ndata: not valid json\ndata: {\"\"type\"\":\"\"stderr\"\",\"\"text\"\":\"\"also valid\"\"}\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n        {\n            events.Add(ev);\n        }\n\n        // Assert\n        events.Should().HaveCount(2);\n        events[0].Type.Should().Be(\"stdout\");\n        events[1].Type.Should().Be(\"stderr\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithErrorResponse_ShouldThrowSandboxApiException()\n    {\n        // Arrange\n        var errorContent = @\"{\"\"message\"\":\"\"Not found\"\",\"\"code\"\":\"\"NOT_FOUND\"\"}\";\n        var response = CreateMockResponse(HttpStatusCode.NotFound, errorContent);\n\n        // Act & Assert\n        var exception = await Assert.ThrowsAsync<SandboxApiException>(async () =>\n        {\n            await foreach (var _ in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response))\n            {\n                // Should not reach here\n            }\n        });\n\n        exception.StatusCode.Should().Be(404);\n        exception.Message.Should().Be(\"Not found\");\n        exception.Error.Code.Should().Be(\"NOT_FOUND\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithErrorResponseNoJson_ShouldUseFallbackMessage()\n    {\n        // Arrange\n        var response = CreateMockResponse(HttpStatusCode.InternalServerError, \"Internal Server Error\");\n\n        // Act & Assert\n        var exception = await Assert.ThrowsAsync<SandboxApiException>(async () =>\n        {\n            await foreach (var _ in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response, \"Custom fallback\"))\n            {\n                // Should not reach here\n            }\n        });\n\n        exception.StatusCode.Should().Be(500);\n        exception.Message.Should().Be(\"Custom fallback\");\n    }\n\n    [Fact]\n    public async Task ParseJsonEventStreamAsync_WithCancellation_ShouldStopParsing()\n    {\n        // Arrange\n        var sseContent = @\"data: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"line1\"\"}\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"line2\"\"}\ndata: {\"\"type\"\":\"\"stdout\"\",\"\"text\"\":\"\"line3\"\"}\n\";\n        var response = CreateMockResponse(HttpStatusCode.OK, sseContent);\n        var cts = new CancellationTokenSource();\n\n        // Act\n        var events = new List<ServerStreamEvent>();\n        await Assert.ThrowsAsync<OperationCanceledException>(async () =>\n        {\n            await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response, cancellationToken: cts.Token))\n            {\n                events.Add(ev);\n                if (events.Count == 1)\n                {\n                    cts.Cancel();\n                }\n            }\n        });\n\n        // Assert\n        events.Should().HaveCount(1);\n    }\n\n    private static HttpResponseMessage CreateMockResponse(HttpStatusCode statusCode, string content)\n    {\n        return new HttpResponseMessage(statusCode)\n        {\n            Content = new StringContent(content, Encoding.UTF8, \"text/event-stream\")\n        };\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/.nvmrc",
    "content": "20\n\n\n"
  },
  {
    "path": "sdks/sandbox/javascript/README.md",
    "content": "# Alibaba Sandbox SDK for JavaScript/TypeScript\n\nEnglish | [中文](README_zh.md)\n\nA TypeScript/JavaScript SDK for low-level interaction with OpenSandbox. It provides the ability to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and reading resource metrics.\n\n## Installation\n\n### npm\n\n```bash\nnpm install @alibaba-group/opensandbox\n```\n\n### pnpm\n\n```bash\npnpm add @alibaba-group/opensandbox\n```\n\n### yarn\n\n```bash\nyarn add @alibaba-group/opensandbox\n```\n\n## Quick Start\n\nThe following example shows how to create a sandbox and execute a shell command.\n\n> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions.\n\n```ts\nimport { ConnectionConfig, Sandbox, SandboxException } from \"@alibaba-group/opensandbox\";\n\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-api-key\",\n  // protocol: \"https\",\n  // requestTimeoutSeconds: 60,\n});\n\ntry {\n  const sandbox = await Sandbox.create({\n    connectionConfig: config,\n    image: \"ubuntu\",\n    timeoutSeconds: 10 * 60,\n  });\n\n  const execution = await sandbox.commands.run(\"echo 'Hello Sandbox!'\");\n  console.log(execution.logs.stdout[0]?.text);\n\n  // Optional but recommended: terminate the remote instance when you are done.\n  await sandbox.kill();\n  await sandbox.close();\n} catch (err) {\n  if (err instanceof SandboxException) {\n    console.error(\n      `Sandbox Error: [${err.error.code}] ${err.error.message ?? \"\"}`,\n    );\n    console.error(`Request ID: ${err.requestId ?? \"N/A\"}`);\n  } else {\n    console.error(err);\n  }\n}\n```\n\n## Usage Examples\n\n### 1. Lifecycle Management\n\nManage the sandbox lifecycle, including renewal, pausing, and resuming.\n\n```ts\nconst info = await sandbox.getInfo();\nconsole.log(\"State:\", info.status.state);\nconsole.log(\"Created:\", info.createdAt);\nconsole.log(\"Expires:\", info.expiresAt); // null when manual cleanup mode is used\n\nawait sandbox.pause();\n\n// Resume returns a fresh, connected Sandbox instance.\nconst resumed = await sandbox.resume();\n\n// Renew: expiresAt = now + timeoutSeconds\nawait resumed.renew(30 * 60);\n```\n\nCreate a non-expiring sandbox by passing `timeoutSeconds: null`:\n\n```ts\nconst manual = await Sandbox.create({\n  connectionConfig: config,\n  image: \"ubuntu\",\n  timeoutSeconds: null,\n});\n```\n\n### 2. Custom Health Check\n\nDefine custom logic to determine whether the sandbox is ready/healthy. This overrides the default ping check used during readiness checks.\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"nginx:latest\",\n  healthCheck: async (sbx) => {\n    // Example: consider the sandbox healthy when port 80 endpoint becomes available\n    const ep = await sbx.getEndpoint(80);\n    return !!ep.endpoint;\n  },\n});\n```\n\n### 3. Command Execution & Streaming\n\nExecute commands and handle output streams in real-time.\n\n```ts\nimport type { ExecutionHandlers } from \"@alibaba-group/opensandbox\";\n\nconst handlers: ExecutionHandlers = {\n  onStdout: (m) => console.log(\"STDOUT:\", m.text),\n  onStderr: (m) => console.error(\"STDERR:\", m.text),\n  onExecutionComplete: (c) =>\n    console.log(\"Finished in\", c.executionTimeMs, \"ms\"),\n};\n\nawait sandbox.commands.run(\n  'for i in 1 2 3; do echo \"Count $i\"; sleep 0.2; done',\n  undefined,\n  handlers,\n);\n```\n\n### 4. Comprehensive File Operations\n\nManage files and directories, including read, write, list/search, and delete.\n\n```ts\nawait sandbox.files.createDirectories([{ path: \"/tmp/demo\", mode: 755 }]);\n\nawait sandbox.files.writeFiles([\n  { path: \"/tmp/demo/hello.txt\", data: \"Hello World\", mode: 644 },\n]);\n\nconst content = await sandbox.files.readFile(\"/tmp/demo/hello.txt\");\nconsole.log(\"Content:\", content);\n\nconst files = await sandbox.files.search({\n  path: \"/tmp/demo\",\n  pattern: \"*.txt\",\n});\nconsole.log(files.map((f) => f.path));\n\nawait sandbox.files.deleteDirectories([\"/tmp/demo\"]);\n```\n\n### 5. Endpoints\n\n`getEndpoint()` returns an endpoint **without a scheme** (for example `\"localhost:44772\"`). Use `getEndpointUrl()` if you want a ready-to-use absolute URL (for example `\"http://localhost:44772\"`).\n\n```ts\nconst { endpoint } = await sandbox.getEndpoint(44772);\nconst url = await sandbox.getEndpointUrl(44772);\n```\n\n### 6. Sandbox Management (Admin)\n\nUse `SandboxManager` for administrative tasks and finding existing sandboxes.\n\n```ts\nimport { SandboxManager } from \"@alibaba-group/opensandbox\";\n\nconst manager = SandboxManager.create({ connectionConfig: config });\nconst list = await manager.listSandboxInfos({\n  states: [\"Running\"],\n  pageSize: 10,\n});\nconsole.log(list.items.map((s) => s.id));\nawait manager.close();\n```\n\n## Configuration\n\n### 1. Connection Configuration\n\nThe `ConnectionConfig` class manages API server connection settings.\n\nRuntime notes:\n\n- In browsers, the SDK uses the global `fetch` implementation.\n- In Node.js, every `Sandbox` and `SandboxManager` clones the base `ConnectionConfig` via `withTransportIfMissing()`, so each instance gets an isolated `undici` keep-alive pool. Call `sandbox.close()` or `manager.close()` when you are done so the SDK can release the associated agent.\n\n| Parameter               | Description                                                                                                  | Default          | Environment Variable   |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------- | ---------------------- |\n| `apiKey`                | API key for authentication                                                                                   | Optional         | `OPEN_SANDBOX_API_KEY` |\n| `domain`                | Sandbox service domain (`host[:port]`)                                                                       | `localhost:8080` | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`              | HTTP protocol (`http`/`https`)                                                                               | `http`           | -                      |\n| `requestTimeoutSeconds` | Request timeout applied to SDK HTTP calls                                                                    | `30`             | -                      |\n| `debug`                 | Enable basic HTTP debug logging                                                                              | `false`          | -                      |\n| `headers`               | Extra headers applied to every request                                                                       | `{}`             | -                      |\n| `useServerProxy`        | Use sandbox server as proxy for execd/endpoint requests (e.g. when client cannot reach the sandbox directly) | `false`          | -                      |\n\n```ts\nimport { ConnectionConfig } from \"@alibaba-group/opensandbox\";\n\n// 1. Basic configuration\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-key\",\n  requestTimeoutSeconds: 60,\n});\n\n// 2. Advanced: custom headers\nconst config2 = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-key\",\n  headers: { \"X-Custom-Header\": \"value\" },\n});\n```\n\n### 2. Sandbox Creation Configuration\n\n`Sandbox.create()` allows configuring the sandbox environment.\n\n| Parameter                    | Description                                      | Default                      |\n| ---------------------------- | ------------------------------------------------ | ---------------------------- |\n| `image`                      | Docker image to use                              | Required                     |\n| `timeoutSeconds`             | Automatic termination timeout (server-side TTL)  | 10 minutes                   |\n| `entrypoint`                 | Container entrypoint command                     | `[\"tail\",\"-f\",\"/dev/null\"]`  |\n| `resource`                   | CPU and memory limits (string map)               | `{\"cpu\":\"1\",\"memory\":\"2Gi\"}` |\n| `env`                        | Environment variables                            | `{}`                         |\n| `metadata`                   | Custom metadata tags                             | `{}`                         |\n| `networkPolicy`              | Optional outbound network policy (egress)        | -                            |\n| `extensions`                 | Extra server-defined fields                      | `{}`                         |\n| `skipHealthCheck`            | Skip readiness checks (`Running` + health check) | `false`                      |\n| `healthCheck`                | Custom readiness check                           | -                            |\n| `readyTimeoutSeconds`        | Max time to wait for readiness                   | 30 seconds                   |\n| `healthCheckPollingInterval` | Poll interval while waiting (milliseconds)       | 200 ms                       |\n\nNote: metadata keys under `opensandbox.io/` are reserved for system-managed\nlabels and will be rejected by the server.\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"python:3.11\",\n  networkPolicy: {\n    defaultAction: \"deny\",\n    egress: [{ action: \"allow\", target: \"pypi.org\" }],\n  },\n});\n```\n\n### 3. Runtime Egress Policy Updates\n\nRuntime egress reads and patches go directly to the sandbox egress sidecar.\nThe SDK first resolves the sandbox endpoint on port `18080`, then calls the sidecar `/policy` API.\n\nPatch uses merge semantics:\n- Incoming rules take priority over existing rules with the same `target`.\n- Existing rules for other targets remain unchanged.\n- Within a single patch payload, the first rule for a `target` wins.\n- The current `defaultAction` is preserved.\n\n```ts\nconst policy = await sandbox.getEgressPolicy();\n\nawait sandbox.patchEgressRules([\n  { action: \"allow\", target: \"www.github.com\" },\n  { action: \"deny\", target: \"pypi.org\" },\n]);\n```\n\n### 4. Resource cleanup\n\nBoth `Sandbox` and `SandboxManager` own a scoped HTTP agent when running on Node.js\nso you can safely reuse the same `ConnectionConfig`. Once you are finished interacting\nwith the sandbox or administration APIs, call `sandbox.close()` / `manager.close()` to\nrelease the underlying agent.\n\n## Browser Notes\n\n- The SDK can run in browsers, but **streaming file uploads are Node-only**.\n- If you pass `ReadableStream` or `AsyncIterable` for `writeFiles`, the browser will fall back to **buffering in memory** before upload.\n- Reason: browsers do not support streaming `multipart/form-data` bodies with custom boundaries (required by the execd upload API).\n"
  },
  {
    "path": "sdks/sandbox/javascript/README_zh.md",
    "content": "# Alibaba Sandbox JavaScript/TypeScript SDK\n\n中文 | [English](README.md)\n\n用于与 OpenSandbox 进行底层交互的 TypeScript/JavaScript SDK。它提供了创建、管理和与安全沙箱环境交互的能力，包括执行 Shell 命令、管理文件以及读取资源指标等。\n\n## 安装指南\n\n### npm\n\n```bash\nnpm install @alibaba-group/opensandbox\n```\n\n### pnpm\n\n```bash\npnpm add @alibaba-group/opensandbox\n```\n\n### yarn\n\n```bash\nyarn add @alibaba-group/opensandbox\n```\n\n## 快速开始\n\n以下示例展示了如何创建一个沙箱并执行 Shell 命令。\n\n> **注意**: 在运行此示例之前，请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。\n\n```ts\nimport { ConnectionConfig, Sandbox, SandboxException } from \"@alibaba-group/opensandbox\";\n\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-api-key\",\n  // protocol: \"https\",\n  // requestTimeoutSeconds: 60,\n});\n\ntry {\n  const sandbox = await Sandbox.create({\n    connectionConfig: config,\n    image: \"ubuntu\",\n    timeoutSeconds: 10 * 60,\n  });\n\n  const execution = await sandbox.commands.run(\"echo 'Hello Sandbox!'\");\n  console.log(execution.logs.stdout[0]?.text);\n\n  await sandbox.kill();\n  await sandbox.close();\n} catch (err) {\n  if (err instanceof SandboxException) {\n    console.error(`沙箱错误: [${err.error.code}] ${err.error.message ?? \"\"}`);\n    console.error(`Request ID: ${err.requestId ?? \"N/A\"}`);\n  } else {\n    console.error(err);\n  }\n}\n```\n\n## 核心功能示例\n\n### 1. 生命周期管理\n\n管理沙箱的生命周期，包括续期、暂停、恢复和状态查询。\n\n```ts\nconst info = await sandbox.getInfo();\nconsole.log(\"状态:\", info.status.state);\nconsole.log(\"创建时间:\", info.createdAt);\nconsole.log(\"过期时间:\", info.expiresAt);\n\nawait sandbox.pause();\n\n// resume 会返回新的、已连接的 Sandbox 实例\nconst resumed = await sandbox.resume();\n\n// renew：expiresAt = now + timeoutSeconds\nawait resumed.renew(30 * 60);\n\n// 获取当前状态\nconst info = await resumed.getInfo();\nconsole.log(\"状态:\", info.status.state);\nconsole.log(\"过期时间:\", info.expiresAt); // 使用手动清理模式时为 null\n```\n\n通过传入 `timeoutSeconds: null` 创建一个不会自动过期的沙箱：\n\n```ts\nconst manual = await Sandbox.create({\n  connectionConfig: config,\n  image: \"ubuntu\",\n  timeoutSeconds: null,\n});\n```\n\n### 2. 自定义健康检查\n\n定义自定义逻辑来判断沙箱是否就绪/健康。这会覆盖“就绪检测”默认使用的 ping 检查逻辑。\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"nginx:latest\",\n  healthCheck: async (sbx) => {\n    // 示例：当 80 端口 endpoint 可获取时认为沙箱可用\n    const ep = await sbx.getEndpoint(80);\n    return !!ep.endpoint;\n  },\n});\n```\n\n### 3. 命令执行与流式响应\n\n执行命令并实时处理输出流。\n\n```ts\nimport type { ExecutionHandlers } from \"@alibaba-group/opensandbox\";\n\nconst handlers: ExecutionHandlers = {\n  onStdout: (m) => console.log(\"STDOUT:\", m.text),\n  onStderr: (m) => console.error(\"STDERR:\", m.text),\n  onExecutionComplete: (c) => console.log(\"耗时(ms):\", c.executionTimeMs),\n};\n\nawait sandbox.commands.run(\n  'for i in 1 2 3; do echo \"Count $i\"; sleep 0.2; done',\n  undefined,\n  handlers,\n);\n```\n\n### 4. 全面的文件操作\n\n管理文件和目录，包括读写、列表/搜索与删除。\n\n```ts\nawait sandbox.files.createDirectories([{ path: \"/tmp/demo\", mode: 755 }]);\n\nawait sandbox.files.writeFiles([\n  { path: \"/tmp/demo/hello.txt\", data: \"Hello World\", mode: 644 },\n]);\n\nconst content = await sandbox.files.readFile(\"/tmp/demo/hello.txt\");\nconsole.log(\"文件内容:\", content);\n\nconst files = await sandbox.files.search({\n  path: \"/tmp/demo\",\n  pattern: \"*.txt\",\n});\nconsole.log(files.map((f) => f.path));\n\nawait sandbox.files.deleteDirectories([\"/tmp/demo\"]);\n```\n\n### 5. Endpoint\n\n`getEndpoint()` 返回 **不带 scheme** 的 endpoint（例如 `\"localhost:44772\"`）。如果你希望直接得到可用的绝对 URL（例如 `\"http://localhost:44772\"`），请使用 `getEndpointUrl()`。\n\n```ts\nconst { endpoint } = await sandbox.getEndpoint(44772);\nconst url = await sandbox.getEndpointUrl(44772);\n```\n\n### 6. 沙箱管理（Admin）\n\n使用 `SandboxManager` 进行管理操作，如查询现有沙箱列表。\n\n```ts\nimport { SandboxManager } from \"@alibaba-group/opensandbox\";\n\nconst manager = SandboxManager.create({ connectionConfig: config });\nconst list = await manager.listSandboxInfos({\n  states: [\"Running\"],\n  pageSize: 10,\n});\nconsole.log(list.items.map((s) => s.id));\n```\n\n## 配置说明\n\n### 1. 连接配置 (Connection Configuration)\n\n`ConnectionConfig` 类管理与 API 服务器的连接设置。\n\n运行环境说明：\n\n- 浏览器环境下，SDK 使用全局 `fetch`。\n- Node.js 环境下，每个 `Sandbox` 和 `SandboxManager` 都会通过 `ConnectionConfig.withTransportIfMissing()` 创建独立的 keep-alive 池（基于 `undici`）。完成交互后请调用 `sandbox.close()` 或 `manager.close()` 来释放对应的 agent，以避免遗留连接，这与 Python SDK 的 transport 生命周期一致。\n\n| 参数                    | 描述                                                                      | 默认值           | 环境变量               |\n| ----------------------- | ------------------------------------------------------------------------- | ---------------- | ---------------------- |\n| `apiKey`                | 用于认证的 API Key                                                        | 可选             | `OPEN_SANDBOX_API_KEY` |\n| `domain`                | 沙箱服务域名（`host[:port]`）                                             | `localhost:8080` | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`              | HTTP 协议（`http`/`https`）                                               | `http`           | -                      |\n| `requestTimeoutSeconds` | SDK HTTP 请求超时（秒）                                                   | `30`             | -                      |\n| `debug`                 | 是否开启基础 HTTP 调试日志                                                | `false`          | -                      |\n| `headers`               | 每次请求附加的 Header                                                     | `{}`             | -                      |\n| `useServerProxy`        | 是否通过沙箱服务代理访问 execd/endpoint（适用于客户端无法直连沙箱的场景） | `false`          | -                      |\n\n```ts\nimport { ConnectionConfig } from \"@alibaba-group/opensandbox\";\n\n// 1. 基础配置\nconst config = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-key\",\n  requestTimeoutSeconds: 60,\n});\n\n// 2. 进阶配置：自定义 headers\nconst config2 = new ConnectionConfig({\n  domain: \"api.opensandbox.io\",\n  apiKey: \"your-key\",\n  headers: { \"X-Custom-Header\": \"value\" },\n});\n```\n\n### 2. 沙箱创建配置 (Sandbox Creation Configuration)\n\n`Sandbox.create()` 用于配置沙箱环境。\n\n| 参数                         | 描述                                 | 默认值                       |\n| ---------------------------- | ------------------------------------ | ---------------------------- |\n| `image`                      | 使用的 Docker 镜像                   | 必填                         |\n| `timeoutSeconds`             | 自动终止超时时间（服务端 TTL）       | 10 分钟                      |\n| `entrypoint`                 | 容器启动入口命令                     | `[\"tail\",\"-f\",\"/dev/null\"]`  |\n| `resource`                   | CPU/内存限制（字符串 map）           | `{\"cpu\":\"1\",\"memory\":\"2Gi\"}` |\n| `env`                        | 环境变量                             | `{}`                         |\n| `metadata`                   | 自定义元数据标签                     | `{}`                         |\n| `networkPolicy`              | 可选的出站网络策略（egress）         | -                            |\n| `extensions`                 | 额外的服务端扩展字段                 | `{}`                         |\n| `skipHealthCheck`            | 跳过就绪检测（`Running` + 健康检查） | `false`                      |\n| `healthCheck`                | 自定义就绪检查                       | -                            |\n| `readyTimeoutSeconds`        | 等待就绪最大时间                     | 30 秒                        |\n| `healthCheckPollingInterval` | 就绪轮询间隔（毫秒）                 | 200 ms                       |\n\n注意：`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签，服务端会拒绝用户传入。\n\n```ts\nconst sandbox = await Sandbox.create({\n  connectionConfig: config,\n  image: \"python:3.11\",\n  networkPolicy: {\n    defaultAction: \"deny\",\n    egress: [{ action: \"allow\", target: \"pypi.org\" }],\n  },\n});\n```\n\n### 3. 运行时 Egress 策略更新\n\n运行时的 egress 查询和 patch 会直接访问沙箱内的 egress sidecar。\nSDK 会先解析 `18080` 端口对应的 sandbox endpoint，再调用 sidecar 的 `/policy` API。\n\n```ts\nconst policy = await sandbox.getEgressPolicy();\n\nawait sandbox.patchEgressRules([\n  { action: \"allow\", target: \"www.github.com\" },\n  { action: \"deny\", target: \"pypi.org\" },\n]);\n```\n\n### 4. 资源清理\n\n在 Node.js 环境下，`Sandbox` 和 `SandboxManager` 会拥有各自的 HTTP agent，因此即使多个实例共享同一个 `ConnectionConfig` 也不会互相影响。SDK 会借助 `ConnectionConfig.withTransportIfMissing()` 复刻每个实例的 transport。完成使用后调用 `sandbox.close()` / `manager.close()` 来释放底层连接池；\n\n## 浏览器注意事项\n\n- SDK 可在浏览器运行，但**流式文件上传仅支持 Node**。\n- 如果 `writeFiles` 传入 `ReadableStream` 或 `AsyncIterable`，浏览器会回退为**先缓存在内存，再上传**。\n- 原因：浏览器不支持以自定义 boundary 的 `multipart/form-data` 流式请求体（execd 上传接口需要此能力）。\n"
  },
  {
    "path": "sdks/sandbox/javascript/eslint.config.mjs",
    "content": "import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createBaseConfig } from \"../../eslint.base.mjs\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nexport default createBaseConfig({\n  tsconfigRootDir: __dirname,\n  tsconfigPath: \"./tsconfig.json\",\n  extraIgnores: [\"src/api/**\", \"src/**/*.d.ts\", \"src/**/*.js\"],\n  includeScripts: true,\n});"
  },
  {
    "path": "sdks/sandbox/javascript/package.json",
    "content": "{\n  \"name\": \"@alibaba-group/opensandbox\",\n  \"version\": \"0.1.5\",\n  \"description\": \"OpenSandbox TypeScript/JavaScript SDK (sandbox lifecycle + execd APIs)\",\n  \"license\": \"Apache-2.0\",\n  \"type\": \"module\",\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/cjs/index.cjs\",\n      \"default\": \"./dist/index.js\"\n    },\n    \"./internal\": {\n      \"types\": \"./dist/internal.d.ts\",\n      \"import\": \"./dist/internal.js\",\n      \"require\": \"./dist/cjs/internal.cjs\",\n      \"default\": \"./dist/internal.js\"\n    }\n  },\n  \"browser\": \"./dist/index.js\",\n  \"sideEffects\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/alibaba/OpenSandbox.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/alibaba/OpenSandbox/issues\"\n  },\n  \"homepage\": \"https://open-sandbox.ai\",\n  \"files\": [\n    \"dist\",\n    \"src\"\n  ],\n  \"engines\": {\n    \"node\": \">=20\"\n  },\n  \"packageManager\": \"pnpm@9.15.0\",\n  \"scripts\": {\n    \"gen:api\": \"node ./scripts/generate-api.mjs\",\n    \"build\": \"pnpm run gen:api && tsup\",\n    \"test\": \"pnpm run build && node --test tests/*.test.mjs\",\n    \"lint\": \"eslint src scripts --max-warnings 0\",\n    \"clean\": \"rm -rf dist\"\n  },\n  \"dependencies\": {\n    \"openapi-fetch\": \"^0.14.1\",\n    \"undici\": \"^7.18.2\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"eslint\": \"^9.39.2\",\n    \"globals\": \"^17.0.0\",\n    \"openapi-typescript\": \"^7.9.1\",\n    \"tsup\": \"^8.5.0\",\n    \"typescript\": \"^5.7.2\",\n    \"typescript-eslint\": \"^8.52.0\"\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/scripts/generate-api.mjs",
    "content": "#!/usr/bin/env node\n\n// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { fileURLToPath } from \"node:url\";\n\nconst LICENSE_OWNER = \"Alibaba Group Holding Ltd.\";\nconst LICENSE_MARKER_REGEX = new RegExp(`Copyright [0-9]{4} ${LICENSE_OWNER}`);\n\nfunction buildLicenseText() {\n  const year = new Date().getFullYear();\n  return `Copyright ${year} ${LICENSE_OWNER}.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.`;\n}\n\nfunction asLineCommentHeader(text) {\n  return text\n    .split(\"\\n\")\n    .map((line) => `// ${line}`)\n    .join(\"\\n\");\n}\n\nfunction ensureLicenseHeader(filePath) {\n  const body = readFileSync(filePath, \"utf8\");\n  const head = body.split(\"\\n\").slice(0, 40).join(\"\\n\");\n  if (LICENSE_MARKER_REGEX.test(head)) {\n    return;\n  }\n  const header = asLineCommentHeader(buildLicenseText());\n  writeFileSync(filePath, `${header}\\n\\n${body}`, \"utf8\");\n}\n\nfunction fail(message) {\n  console.error(`❌ ${message}`);\n  process.exit(1);\n}\n\nfunction run(cmd, args, cwd) {\n  const pretty = [cmd, ...args].join(\" \");\n  console.log(`\\n▶ ${pretty}`);\n  const res = spawnSync(cmd, args, { cwd, stdio: \"inherit\" });\n  if (res.status !== 0) {\n    fail(`Command failed (exit=${res.status}): ${pretty}`);\n  }\n}\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// scripts/ -> package root\nconst packageRoot = path.resolve(__dirname, \"..\");\n// scripts/ -> repo root (OpenSandbox/)\nconst repoRoot = path.resolve(__dirname, \"../../../../\");\n\nconst specs = {\n  execd: path.join(repoRoot, \"specs\", \"execd-api.yaml\"),\n  egress: path.join(repoRoot, \"specs\", \"egress-api.yaml\"),\n  lifecycle: path.join(repoRoot, \"specs\", \"sandbox-lifecycle.yml\"),\n};\n\nfor (const [name, p] of Object.entries(specs)) {\n  if (!existsSync(p)) {\n    fail(`OpenAPI spec not found for '${name}': ${p}`);\n  }\n}\n\nconst outDir = path.join(packageRoot, \"src\", \"api\");\nmkdirSync(outDir, { recursive: true });\n\nconst outFiles = {\n  execd: path.join(outDir, \"execd.ts\"),\n  egress: path.join(outDir, \"egress.ts\"),\n  lifecycle: path.join(outDir, \"lifecycle.ts\"),\n};\n\nconsole.log(\"🚀 OpenSandbox TypeScript SDK API Generator\");\nconsole.log(`- repoRoot: ${repoRoot}`);\nconsole.log(`- outDir:   ${outDir}`);\n\n// Use pnpm as requested by the project rules.\nrun(\"pnpm\", [\"exec\", \"openapi-typescript\", specs.execd, \"-o\", outFiles.execd], packageRoot);\nrun(\"pnpm\", [\"exec\", \"openapi-typescript\", specs.egress, \"-o\", outFiles.egress], packageRoot);\nrun(\n  \"pnpm\",\n  [\"exec\", \"openapi-typescript\", specs.lifecycle, \"-o\", outFiles.lifecycle],\n  packageRoot,\n);\n\n// The generator may overwrite outputs; re-apply unified license headers after generation.\nensureLicenseHeader(outFiles.execd);\nensureLicenseHeader(outFiles.egress);\nensureLicenseHeader(outFiles.lifecycle);\n\nconsole.log(\"\\n✅ API type generation completed:\");\nconsole.log(`- ${path.relative(packageRoot, outFiles.execd)}`);\nconsole.log(`- ${path.relative(packageRoot, outFiles.egress)}`);\nconsole.log(`- ${path.relative(packageRoot, outFiles.lifecycle)}`);\n\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/commandsAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecdClient } from \"../openapi/execdClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport { parseJsonEventStream } from \"./sse.js\";\nimport type { paths as ExecdPaths } from \"../api/execd.js\";\nimport type {\n  CommandExecution,\n  CommandLogs,\n  CommandStatus,\n  RunCommandOpts,\n  ServerStreamEvent,\n} from \"../models/execd.js\";\nimport type { ExecdCommands } from \"../services/execdCommands.js\";\nimport type { ExecutionHandlers } from \"../models/execution.js\";\nimport { ExecutionEventDispatcher } from \"../models/executionEventDispatcher.js\";\n\nfunction joinUrl(baseUrl: string, pathname: string): string {\n  const base = baseUrl.endsWith(\"/\") ? baseUrl.slice(0, -1) : baseUrl;\n  const path = pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n  return `${base}${path}`;\n}\n\n/** Request body for POST /command (from generated spec; includes uid, gid, envs). */\ntype ApiRunCommandRequest =\n  ExecdPaths[\"/command\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"];\ntype ApiCommandStatusOk =\n  ExecdPaths[\"/command/status/{id}\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiCommandLogsOk =\n  ExecdPaths[\"/command/{id}/logs\"][\"get\"][\"responses\"][200][\"content\"][\"text/plain\"];\n\nfunction toRunCommandRequest(command: string, opts?: RunCommandOpts): ApiRunCommandRequest {\n  if (opts?.gid != null && opts.uid == null) {\n    throw new Error(\"uid is required when gid is provided\");\n  }\n\n  const body: ApiRunCommandRequest = {\n    command,\n    cwd: opts?.workingDirectory,\n    background: !!opts?.background,\n  };\n  if (opts?.timeoutSeconds != null) {\n    body.timeout = Math.round(opts.timeoutSeconds * 1000);\n  }\n  if (opts?.uid != null) {\n    body.uid = opts.uid;\n  }\n  if (opts?.gid != null) {\n    body.gid = opts.gid;\n  }\n  if (opts?.envs != null) {\n    body.envs = opts.envs;\n  }\n  return body;\n}\n\nfunction parseOptionalDate(value: unknown, field: string): Date | undefined {\n  if (value == null) return undefined;\n  if (value instanceof Date) return value;\n  if (typeof value !== \"string\") {\n    throw new Error(`Invalid ${field}: expected ISO string, got ${typeof value}`);\n  }\n  const parsed = new Date(value);\n  if (Number.isNaN(parsed.getTime())) {\n    throw new Error(`Invalid ${field}: ${value}`);\n  }\n  return parsed;\n}\n\nexport interface CommandsAdapterOptions {\n  /**\n   * Must match the baseUrl used by the ExecdClient.\n   */\n  baseUrl: string;\n  fetch?: typeof fetch;\n  headers?: Record<string, string>;\n}\n\nexport class CommandsAdapter implements ExecdCommands {\n  private readonly fetch: typeof fetch;\n\n  constructor(\n    private readonly client: ExecdClient,\n    private readonly opts: CommandsAdapterOptions,\n  ) {\n    this.fetch = opts.fetch ?? fetch;\n  }\n\n  async interrupt(sessionId: string): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/command\", {\n      params: { query: { id: sessionId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Interrupt command failed\");\n  }\n\n  async getCommandStatus(commandId: string): Promise<CommandStatus> {\n    const { data, error, response } = await this.client.GET(\"/command/status/{id}\", {\n      params: { path: { id: commandId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get command status failed\");\n    const ok = data as ApiCommandStatusOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Get command status failed: unexpected response shape\");\n    }\n    return {\n      id: ok.id,\n      content: ok.content,\n      running: ok.running,\n      exitCode: ok.exit_code ?? null,\n      error: ok.error,\n      startedAt: parseOptionalDate(ok.started_at, \"startedAt\"),\n      finishedAt: parseOptionalDate(ok.finished_at, \"finishedAt\") ?? null,\n    };\n  }\n\n  async getBackgroundCommandLogs(commandId: string, cursor?: number): Promise<CommandLogs> {\n    const { data, error, response } = await this.client.GET(\"/command/{id}/logs\", {\n      params: { path: { id: commandId }, query: cursor == null ? {} : { cursor } },\n      parseAs: \"text\",\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get command logs failed\");\n    const ok = data as ApiCommandLogsOk | undefined;\n    if (typeof ok !== \"string\") {\n      throw new Error(\"Get command logs failed: unexpected response shape\");\n    }\n    const cursorHeader = response.headers.get(\"EXECD-COMMANDS-TAIL-CURSOR\");\n    const parsedCursor = (cursorHeader != null && cursorHeader !== \"\") ? Number(cursorHeader) : undefined;\n    return {\n      content: ok,\n      cursor: Number.isFinite(parsedCursor ?? NaN) ? parsedCursor : undefined,\n    };\n  }\n\n  async *runStream(\n    command: string,\n    opts?: RunCommandOpts,\n    signal?: AbortSignal,\n  ): AsyncIterable<ServerStreamEvent> {\n    const url = joinUrl(this.opts.baseUrl, \"/command\");\n    const body = JSON.stringify(toRunCommandRequest(command, opts));\n\n    const res = await this.fetch(url, {\n      method: \"POST\",\n      headers: {\n        \"accept\": \"text/event-stream\",\n        \"content-type\": \"application/json\",\n        ...(this.opts.headers ?? {}),\n      },\n      body,\n      signal,\n    });\n\n    for await (const ev of parseJsonEventStream<ServerStreamEvent>(res, { fallbackErrorMessage: \"Run command failed\" })) {\n      yield ev;\n    }\n  }\n\n  async run(\n    command: string,\n    opts?: RunCommandOpts,\n    handlers?: ExecutionHandlers,\n    signal?: AbortSignal,\n  ): Promise<CommandExecution> {\n    const execution: CommandExecution = {\n      logs: { stdout: [], stderr: [] },\n      result: [],\n    };\n    const dispatcher = new ExecutionEventDispatcher(execution, handlers);\n    for await (const ev of this.runStream(command, opts, signal)) {\n      // Keep legacy behavior: if server sends \"init\" with empty id, preserve previous id.\n      if (ev.type === \"init\" && (ev.text ?? \"\") === \"\" && execution.id) {\n        (ev as any).text = execution.id;\n      }\n      await dispatcher.dispatch(ev as any);\n    }\n\n    return execution;\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/egressAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { EgressClient } from \"../openapi/egressClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport type { paths as EgressPaths } from \"../api/egress.js\";\nimport type { NetworkPolicy, NetworkRule } from \"../models/sandboxes.js\";\nimport type { Egress } from \"../services/egress.js\";\n\ntype ApiGetPolicyOk =\n  EgressPaths[\"/policy\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiPatchRulesRequest =\n  EgressPaths[\"/policy\"][\"patch\"][\"requestBody\"][\"content\"][\"application/json\"];\n\nexport class EgressAdapter implements Egress {\n  constructor(private readonly client: EgressClient) {}\n\n  async getPolicy(): Promise<NetworkPolicy> {\n    const { data, error, response } = await this.client.GET(\"/policy\");\n    throwOnOpenApiFetchError({ error, response }, \"Get sandbox egress policy failed\");\n    const raw = data as ApiGetPolicyOk | undefined;\n    if (!raw || typeof raw !== \"object\" || !raw.policy || typeof raw.policy !== \"object\") {\n      throw new Error(\"Get sandbox egress policy failed: unexpected response shape\");\n    }\n    return raw.policy as NetworkPolicy;\n  }\n\n  async patchRules(rules: NetworkRule[]): Promise<void> {\n    const body: ApiPatchRulesRequest = rules as unknown as ApiPatchRulesRequest;\n    const { error, response } = await this.client.PATCH(\"/policy\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Patch sandbox egress rules failed\");\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecdClient } from \"../openapi/execdClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport type { SandboxFiles } from \"../services/filesystem.js\";\nimport type { paths as ExecdPaths } from \"../api/execd.js\";\nimport type {\n  ContentReplaceEntry,\n  FileInfo,\n  FileMetadata,\n  FilesInfoResponse,\n  MoveEntry,\n  Permission,\n  RenameFileItem,\n  ReplaceFileContentItem,\n  SearchEntry,\n  SearchFilesResponse,\n  SetPermissionEntry,\n  WriteEntry,\n} from \"../models/filesystem.js\";\nimport { SandboxApiException, SandboxError } from \"../core/exceptions.js\";\n\nfunction joinUrl(baseUrl: string, pathname: string): string {\n  const base = baseUrl.endsWith(\"/\") ? baseUrl.slice(0, -1) : baseUrl;\n  const path = pathname.startsWith(\"/\") ? pathname : `/${pathname}`;\n  return `${base}${path}`;\n}\n\nfunction toUploadBlob(data: Blob | Uint8Array | ArrayBuffer | string): Blob {\n  if (typeof data === \"string\") return new Blob([data]);\n  if (data instanceof Blob) return data;\n  if (data instanceof ArrayBuffer) return new Blob([data]);\n  // Copy into a new Uint8Array backed by ArrayBuffer (not SharedArrayBuffer)\n  const copied = Uint8Array.from(data);\n  return new Blob([copied.buffer]);\n}\n\nfunction isReadableStream(v: unknown): v is ReadableStream<Uint8Array> {\n  return !!v && typeof (v as any).getReader === \"function\";\n}\n\nfunction isAsyncIterable(v: unknown): v is AsyncIterable<Uint8Array> {\n  return !!v && typeof (v as any)[Symbol.asyncIterator] === \"function\";\n}\n\nfunction isNodeRuntime(): boolean {\n  const p = (globalThis as any)?.process;\n  return !!(p?.versions?.node);\n}\n\nasync function collectBytes(\n  source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>\n): Promise<Uint8Array> {\n  const chunks: Uint8Array[] = [];\n  let total = 0;\n\n  if (isReadableStream(source)) {\n    const reader = source.getReader();\n    try {\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n        if (value) {\n          chunks.push(value);\n          total += value.length;\n        }\n      }\n    } finally {\n      reader.releaseLock();\n    }\n  } else {\n    for await (const chunk of source) {\n      chunks.push(chunk);\n      total += chunk.length;\n    }\n  }\n\n  const out = new Uint8Array(total);\n  let offset = 0;\n  for (const chunk of chunks) {\n    out.set(chunk, offset);\n    offset += chunk.length;\n  }\n  return out;\n}\n\nfunction toReadableStream(\n  it: AsyncIterable<Uint8Array>\n): ReadableStream<Uint8Array> {\n  const RS: any = ReadableStream as any;\n  if (typeof RS?.from === \"function\") return RS.from(it);\n  const iterator = it[Symbol.asyncIterator]();\n  return new ReadableStream<Uint8Array>({\n    async pull(controller) {\n      const r = await iterator.next();\n      if (r.done) {\n        controller.close();\n        return;\n      }\n      controller.enqueue(r.value);\n    },\n    async cancel() {\n      await iterator.return?.();\n    },\n  });\n}\n\nfunction basename(p: string): string {\n  const parts = p.split(\"/\").filter(Boolean);\n  return parts.length ? parts[parts.length - 1] : \"file\";\n}\n\nfunction encodeUtf8(s: string): Uint8Array {\n  return new TextEncoder().encode(s);\n}\n\nasync function* multipartUploadBody(opts: {\n  boundary: string;\n  metadataJson: string;\n  fileName: string;\n  fileContentType: string;\n  file: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;\n}): AsyncIterable<Uint8Array> {\n  const b = opts.boundary;\n\n  // Part 1: metadata (application/json)\n  yield encodeUtf8(`--${b}\\r\\n`);\n  yield encodeUtf8(\n    `Content-Disposition: form-data; name=\"metadata\"; filename=\"metadata\"\\r\\n`\n  );\n  yield encodeUtf8(`Content-Type: application/json\\r\\n\\r\\n`);\n  yield encodeUtf8(opts.metadataJson);\n  yield encodeUtf8(`\\r\\n`);\n\n  // Part 2: file\n  yield encodeUtf8(`--${b}\\r\\n`);\n  yield encodeUtf8(\n    `Content-Disposition: form-data; name=\"file\"; filename=\"${opts.fileName}\"\\r\\n`\n  );\n  yield encodeUtf8(`Content-Type: ${opts.fileContentType}\\r\\n\\r\\n`);\n\n  if (isReadableStream(opts.file)) {\n    const reader = opts.file.getReader();\n    try {\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n        if (value) yield value;\n      }\n    } finally {\n      reader.releaseLock();\n    }\n  } else {\n    for await (const chunk of opts.file) {\n      yield chunk;\n    }\n  }\n\n  yield encodeUtf8(`\\r\\n--${b}--\\r\\n`);\n}\n\nexport interface FilesystemAdapterOptions {\n  /**\n   * Must match the baseUrl used by the ExecdClient, used for binary endpoints\n   * like download/upload where we bypass JSON parsing.\n   */\n  baseUrl: string;\n  fetch?: typeof fetch;\n  headers?: Record<string, string>;\n}\n\nfunction toPermission(e: {\n  mode?: number;\n  owner?: string;\n  group?: string;\n}): Permission {\n  return {\n    mode: e.mode ?? 755,\n    owner: e.owner,\n    group: e.group,\n  } as Permission;\n}\n\n/**\n * Filesystem adapter that exposes user-facing file APIs (`sandbox.files`).\n *\n * This adapter owns all request/response conversions:\n * - Maps friendly method shapes to API payloads\n * - Parses timestamps into `Date`\n * - Implements streaming upload/download helpers\n */\nexport class FilesystemAdapter implements SandboxFiles {\n  private readonly fetch: typeof fetch;\n\n  private static readonly Api = {\n    // This is intentionally derived from OpenAPI schema types so API changes surface quickly.\n    SearchFilesOk:\n      null as unknown as ExecdPaths[\"/files/search\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"],\n    FilesInfoOk:\n      null as unknown as ExecdPaths[\"/files/info\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"],\n    MakeDirsRequest:\n      null as unknown as ExecdPaths[\"/directories\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"],\n    SetPermissionsRequest:\n      null as unknown as ExecdPaths[\"/files/permissions\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"],\n    MoveFilesRequest:\n      null as unknown as ExecdPaths[\"/files/mv\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"],\n    ReplaceContentsRequest:\n      null as unknown as ExecdPaths[\"/files/replace\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"],\n  };\n\n  constructor(\n    private readonly client: ExecdClient,\n    private readonly opts: FilesystemAdapterOptions\n  ) {\n    this.fetch = opts.fetch ?? fetch;\n  }\n\n  private parseIsoDate(field: string, v: unknown): Date {\n    if (typeof v !== \"string\" || !v) {\n      throw new Error(`Invalid ${field}: expected ISO string, got ${typeof v}`);\n    }\n    const d = new Date(v);\n    if (Number.isNaN(d.getTime())) {\n      throw new Error(`Invalid ${field}: ${v}`);\n    }\n    return d;\n  }\n\n  private static readonly _ApiFileInfo =\n    null as unknown as (typeof FilesystemAdapter.Api.SearchFilesOk)[number];\n\n  private mapApiFileInfo(raw: typeof FilesystemAdapter._ApiFileInfo): FileInfo {\n    const { path, size, created_at, modified_at, mode, owner, group, ...rest } =\n      raw;\n\n    return {\n      ...rest,\n      path,\n      size,\n      mode,\n      owner,\n      group,\n      createdAt: created_at\n        ? this.parseIsoDate(\"createdAt\", created_at)\n        : undefined,\n      modifiedAt: modified_at\n        ? this.parseIsoDate(\"modifiedAt\", modified_at)\n        : undefined,\n    };\n  }\n\n  async getFileInfo(paths: string[]): Promise<Record<string, FileInfo>> {\n    const { data, error, response } = await this.client.GET(\"/files/info\", {\n      params: { query: { path: paths } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get file info failed\");\n    const raw = data as typeof FilesystemAdapter.Api.FilesInfoOk | undefined;\n    if (!raw) return {} as FilesInfoResponse;\n    if (typeof raw !== \"object\") {\n      throw new Error(\n        `Get file info failed: unexpected response shape (got ${typeof raw})`\n      );\n    }\n    const out: Record<string, FileInfo> = {};\n    for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {\n      if (!v || typeof v !== \"object\") {\n        throw new Error(\n          `Get file info failed: invalid file info for path=${k}`\n        );\n      }\n      out[k] = this.mapApiFileInfo(v as typeof FilesystemAdapter._ApiFileInfo);\n    }\n    return out as FilesInfoResponse;\n  }\n\n  async deleteFiles(paths: string[]): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/files\", {\n      params: { query: { path: paths } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Delete files failed\");\n  }\n\n  async createDirectories(\n    entries: Pick<WriteEntry, \"path\" | \"mode\" | \"owner\" | \"group\">[]\n  ): Promise<void> {\n    const map: Record<string, Permission> = {};\n    for (const e of entries) {\n      map[e.path] = toPermission(e);\n    }\n    const body = map as unknown as typeof FilesystemAdapter.Api.MakeDirsRequest;\n    const { error, response } = await this.client.POST(\"/directories\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Create directories failed\");\n  }\n\n  async deleteDirectories(paths: string[]): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/directories\", {\n      params: { query: { path: paths } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Delete directories failed\");\n  }\n\n  async setPermissions(entries: SetPermissionEntry[]): Promise<void> {\n    const req: Record<string, Permission> = {};\n    for (const e of entries) {\n      req[e.path] = toPermission(e);\n    }\n    const body =\n      req as unknown as typeof FilesystemAdapter.Api.SetPermissionsRequest;\n    const { error, response } = await this.client.POST(\"/files/permissions\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Set permissions failed\");\n  }\n\n  async moveFiles(entries: MoveEntry[]): Promise<void> {\n    const req: RenameFileItem[] = entries.map((e) => ({\n      src: e.src,\n      dest: e.dest,\n    }));\n    const body =\n      req as unknown as typeof FilesystemAdapter.Api.MoveFilesRequest;\n    const { error, response } = await this.client.POST(\"/files/mv\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Move files failed\");\n  }\n\n  async replaceContents(entries: ContentReplaceEntry[]): Promise<void> {\n    const req: Record<string, ReplaceFileContentItem> = {};\n    for (const e of entries) {\n      req[e.path] = { old: e.oldContent, new: e.newContent };\n    }\n    const body =\n      req as unknown as typeof FilesystemAdapter.Api.ReplaceContentsRequest;\n    const { error, response } = await this.client.POST(\"/files/replace\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Replace contents failed\");\n  }\n\n  async search(entry: SearchEntry): Promise<SearchFilesResponse> {\n    const { data, error, response } = await this.client.GET(\"/files/search\", {\n      params: { query: { path: entry.path, pattern: entry.pattern } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Search files failed\");\n\n    // Make the OpenAPI contract explicit (and fail loudly on unexpected shapes).\n    const ok = data as typeof FilesystemAdapter.Api.SearchFilesOk | undefined;\n    if (!ok) return [];\n    if (!Array.isArray(ok)) {\n      throw new Error(\n        `Search files failed: unexpected response shape (expected array, got ${typeof ok})`\n      );\n    }\n    return ok.map((x) => this.mapApiFileInfo(x));\n  }\n\n  private async uploadFile(\n    meta: FileMetadata,\n    data:\n      | Blob\n      | Uint8Array\n      | ArrayBuffer\n      | string\n      | AsyncIterable<Uint8Array>\n      | ReadableStream<Uint8Array>\n  ): Promise<void> {\n    const url = joinUrl(this.opts.baseUrl, \"/files/upload\");\n    const fileName = basename(meta.path);\n    const metadataJson = JSON.stringify(meta);\n\n    // Streaming path (large files): build multipart body manually to avoid buffering.\n    if (isReadableStream(data) || isAsyncIterable(data)) {\n      // Browsers do not allow streaming multipart requests with custom boundaries.\n      // Fall back to in-memory uploads when streaming is unavailable.\n      if (!isNodeRuntime()) {\n        const bytes = await collectBytes(data);\n        return await this.uploadFile(meta, bytes);\n      }\n      const boundary = `opensandbox_${Math.random()\n        .toString(16)\n        .slice(2)}_${Date.now()}`;\n      const bodyIt = multipartUploadBody({\n        boundary,\n        metadataJson,\n        fileName,\n        fileContentType: \"application/octet-stream\",\n        file: data,\n      });\n      const stream = toReadableStream(bodyIt);\n\n      const res = await this.fetch(url, {\n        method: \"POST\",\n        headers: {\n          \"content-type\": `multipart/form-data; boundary=${boundary}`,\n          ...(this.opts.headers ?? {}),\n        },\n        body: stream as any,\n        // Node fetch (undici) requires duplex for streaming request bodies.\n        duplex: \"half\" as any,\n      } as any);\n\n      if (!res.ok) {\n        const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n        const rawBody = await res.text().catch(() => undefined);\n        throw new SandboxApiException({\n          message: `Upload failed (status=${res.status})`,\n          statusCode: res.status,\n          requestId,\n          error: new SandboxError(\n            SandboxError.UNEXPECTED_RESPONSE,\n            \"Upload failed\"\n          ),\n          rawBody,\n        });\n      }\n      return;\n    }\n\n    // In-memory path (small files): use FormData.\n    const form = new FormData();\n    form.append(\n      \"metadata\",\n      new Blob([metadataJson], { type: \"application/json\" }),\n      \"metadata\"\n    );\n\n    if (typeof data === \"string\") {\n      const textBlob = new Blob([data], { type: \"text/plain; charset=utf-8\" });\n      form.append(\"file\", textBlob, fileName);\n    } else {\n      const blob = toUploadBlob(data);\n      const fileBlob = blob.type\n        ? blob\n        : new Blob([blob], { type: \"application/octet-stream\" });\n      form.append(\"file\", fileBlob, fileName);\n    }\n\n    const res = await this.fetch(url, {\n      method: \"POST\",\n      headers: {\n        ...(this.opts.headers ?? {}),\n      },\n      body: form,\n    });\n\n    if (!res.ok) {\n      const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n      const rawBody = await res.text().catch(() => undefined);\n      throw new SandboxApiException({\n        message: `Upload failed (status=${res.status})`,\n        statusCode: res.status,\n        requestId,\n        error: new SandboxError(\n          SandboxError.UNEXPECTED_RESPONSE,\n          \"Upload failed\"\n        ),\n        rawBody,\n      });\n    }\n  }\n\n  async readBytes(\n    path: string,\n    opts?: { range?: string }\n  ): Promise<Uint8Array> {\n    const url =\n      joinUrl(this.opts.baseUrl, \"/files/download\") +\n      `?path=${encodeURIComponent(path)}`;\n    const res = await this.fetch(url, {\n      method: \"GET\",\n      headers: {\n        ...(this.opts.headers ?? {}),\n        ...(opts?.range ? { Range: opts.range } : {}),\n      },\n    });\n    if (!res.ok) {\n      const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n      const rawBody = await res.text().catch(() => undefined);\n      throw new SandboxApiException({\n        message: \"Download failed\",\n        statusCode: res.status,\n        requestId,\n        error: new SandboxError(\n          SandboxError.UNEXPECTED_RESPONSE,\n          \"Download failed\"\n        ),\n        rawBody,\n      });\n    }\n    const ab = await res.arrayBuffer();\n    return new Uint8Array(ab);\n  }\n\n  readBytesStream(\n    path: string,\n    opts?: { range?: string }\n  ): AsyncIterable<Uint8Array> {\n    return this.downloadStream(path, opts);\n  }\n\n  private async *downloadStream(\n    path: string,\n    opts?: { range?: string }\n  ): AsyncIterable<Uint8Array> {\n    const url =\n      joinUrl(this.opts.baseUrl, \"/files/download\") +\n      `?path=${encodeURIComponent(path)}`;\n    const res = await this.fetch(url, {\n      method: \"GET\",\n      headers: {\n        ...(this.opts.headers ?? {}),\n        ...(opts?.range ? { Range: opts.range } : {}),\n      },\n    });\n    if (!res.ok) {\n      const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n      const rawBody = await res.text().catch(() => undefined);\n      throw new SandboxApiException({\n        message: \"Download stream failed\",\n        statusCode: res.status,\n        requestId,\n        error: new SandboxError(\n          SandboxError.UNEXPECTED_RESPONSE,\n          \"Download stream failed\"\n        ),\n        rawBody,\n      });\n    }\n\n    const body = res.body as ReadableStream<Uint8Array> | null;\n    if (!body) return;\n    const reader = body.getReader();\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) return;\n      if (value) yield value;\n    }\n  }\n\n  async readFile(\n    path: string,\n    opts?: { encoding?: string; range?: string }\n  ): Promise<string> {\n    const bytes = await this.readBytes(path, { range: opts?.range });\n    const encoding = opts?.encoding ?? \"utf-8\";\n    return new TextDecoder(encoding).decode(bytes);\n  }\n\n  async writeFiles(entries: WriteEntry[]): Promise<void> {\n    for (const e of entries) {\n      const meta: FileMetadata = {\n        path: e.path,\n        owner: e.owner,\n        group: e.group,\n        mode: e.mode,\n      };\n      await this.uploadFile(meta, e.data ?? \"\");\n    }\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/healthAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecdClient } from \"../openapi/execdClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport type { ExecdHealth } from \"../services/execdHealth.js\";\n\nexport class HealthAdapter implements ExecdHealth {\n  constructor(private readonly client: ExecdClient) {}\n\n  async ping(): Promise<boolean> {\n    const { error, response } = await this.client.GET(\"/ping\");\n    throwOnOpenApiFetchError({ error, response }, \"Execd ping failed\");\n    return true;\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/metricsAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecdClient } from \"../openapi/execdClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport type { paths as ExecdPaths } from \"../api/execd.js\";\nimport type { SandboxMetrics } from \"../models/execd.js\";\nimport type { ExecdMetrics } from \"../services/execdMetrics.js\";\n\ntype ApiMetricsOk =\n  ExecdPaths[\"/metrics\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\n\nfunction normalizeMetrics(m: ApiMetricsOk): SandboxMetrics {\n  const cpuCount = m.cpu_count ?? 0;\n  const cpuUsedPercentage = m.cpu_used_pct ?? 0;\n  const memoryTotalMiB = m.mem_total_mib ?? 0;\n  const memoryUsedMiB = m.mem_used_mib ?? 0;\n  const timestamp = m.timestamp ?? 0;\n  return {\n    cpuCount: Number(cpuCount),\n    cpuUsedPercentage: Number(cpuUsedPercentage),\n    memoryTotalMiB: Number(memoryTotalMiB),\n    memoryUsedMiB: Number(memoryUsedMiB),\n    timestamp: Number(timestamp),\n  };\n}\n\nexport class MetricsAdapter implements ExecdMetrics {\n  constructor(private readonly client: ExecdClient) {}\n\n  async getMetrics(): Promise<SandboxMetrics> {\n    const { data, error, response } = await this.client.GET(\"/metrics\");\n    throwOnOpenApiFetchError({ error, response }, \"Get execd metrics failed\");\n    const ok = data as ApiMetricsOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Get execd metrics failed: unexpected response shape\");\n    }\n    return normalizeMetrics(ok);\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/openapiError.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { SandboxApiException, SandboxError } from \"../core/exceptions.js\";\n\nexport function throwOnOpenApiFetchError(\n  result: { error?: unknown; response: Response },\n  fallbackMessage: string,\n): void {\n  if (!result.error) return;\n\n  const requestId = result.response.headers.get(\"x-request-id\") ?? undefined;\n  const status = (result.response as any).status ?? 0;\n\n  const err = result.error as any;\n  const message =\n    err?.message ??\n    err?.error?.message ??\n    fallbackMessage;\n\n  const code = err?.code ?? err?.error?.code;\n  const msg = err?.message ?? err?.error?.message ?? message;\n\n  throw new SandboxApiException({\n    message: msg,\n    statusCode: status,\n    requestId,\n    error: code ? new SandboxError(String(code), String(msg ?? \"\")) : new SandboxError(SandboxError.UNEXPECTED_RESPONSE, String(msg ?? \"\")),\n    rawBody: result.error,\n  });\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { LifecycleClient } from \"../openapi/lifecycleClient.js\";\nimport { throwOnOpenApiFetchError } from \"./openapiError.js\";\nimport type { paths as LifecyclePaths } from \"../api/lifecycle.js\";\nimport type {\n  Sandboxes,\n} from \"../services/sandboxes.js\";\nimport type {\n  CreateSandboxRequest,\n  CreateSandboxResponse,\n  Endpoint,\n  ListSandboxesParams,\n  ListSandboxesResponse,\n  RenewSandboxExpirationRequest,\n  RenewSandboxExpirationResponse,\n  SandboxId,\n  SandboxInfo,\n} from \"../models/sandboxes.js\";\n\ntype ApiCreateSandboxRequest =\n  LifecyclePaths[\"/sandboxes\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"];\ntype ApiCreateSandboxOk =\n  LifecyclePaths[\"/sandboxes\"][\"post\"][\"responses\"][202][\"content\"][\"application/json\"];\ntype ApiGetSandboxOk =\n  LifecyclePaths[\"/sandboxes/{sandboxId}\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiListSandboxesOk =\n  LifecyclePaths[\"/sandboxes\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiRenewSandboxExpirationRequest =\n  LifecyclePaths[\"/sandboxes/{sandboxId}/renew-expiration\"][\"post\"][\"requestBody\"][\"content\"][\"application/json\"];\ntype ApiRenewSandboxExpirationOk =\n  LifecyclePaths[\"/sandboxes/{sandboxId}/renew-expiration\"][\"post\"][\"responses\"][200][\"content\"][\"application/json\"];\ntype ApiEndpointOk =\n  LifecyclePaths[\"/sandboxes/{sandboxId}/endpoints/{port}\"][\"get\"][\"responses\"][200][\"content\"][\"application/json\"];\n\nfunction encodeMetadataFilter(metadata: Record<string, string>): string {\n  // The Lifecycle API expects a single `metadata` query parameter whose value is `k=v&k2=v2`.\n  // The query serializer will URL-encode the value (e.g. `=` -> %3D and `&` -> %26).\n  const parts: string[] = [];\n  for (const [k, v] of Object.entries(metadata)) {\n    parts.push(`${k}=${v}`);\n  }\n  return parts.join(\"&\");\n}\n\nexport class SandboxesAdapter implements Sandboxes {\n  constructor(private readonly client: LifecycleClient) {}\n\n  private parseIsoDate(field: string, v: unknown): Date {\n    if (typeof v !== \"string\" || !v) {\n      throw new Error(`Invalid ${field}: expected ISO string, got ${typeof v}`);\n    }\n    const d = new Date(v);\n    if (Number.isNaN(d.getTime())) {\n      throw new Error(`Invalid ${field}: ${v}`);\n    }\n    return d;\n  }\n\n  private parseOptionalIsoDate(field: string, v: unknown): Date | null {\n    if (v == null) return null;\n    return this.parseIsoDate(field, v);\n  }\n\n  private mapSandboxInfo(raw: ApiGetSandboxOk): SandboxInfo {\n    return {\n      ...(raw ?? {}),\n      createdAt: this.parseIsoDate(\"createdAt\", raw?.createdAt),\n      expiresAt: this.parseOptionalIsoDate(\"expiresAt\", raw?.expiresAt),\n    } as SandboxInfo;\n  }\n\n  async createSandbox(req: CreateSandboxRequest): Promise<CreateSandboxResponse> {\n    // Make the OpenAPI contract explicit so backend schema changes surface quickly.\n    const body: ApiCreateSandboxRequest = req as unknown as ApiCreateSandboxRequest;\n    const { data, error, response } = await this.client.POST(\"/sandboxes\", {\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Create sandbox failed\");\n    const raw = data as ApiCreateSandboxOk | undefined;\n    if (!raw || typeof raw !== \"object\") {\n      throw new Error(\"Create sandbox failed: unexpected response shape\");\n    }\n    return {\n      ...(raw ?? {}),\n      createdAt: this.parseIsoDate(\"createdAt\", raw?.createdAt),\n      expiresAt: this.parseOptionalIsoDate(\"expiresAt\", raw?.expiresAt),\n    } as CreateSandboxResponse;\n  }\n\n  async getSandbox(sandboxId: SandboxId): Promise<SandboxInfo> {\n    const { data, error, response } = await this.client.GET(\"/sandboxes/{sandboxId}\", {\n      params: { path: { sandboxId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get sandbox failed\");\n    const ok = data as ApiGetSandboxOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Get sandbox failed: unexpected response shape\");\n    }\n    return this.mapSandboxInfo(ok);\n  }\n\n  async listSandboxes(params: ListSandboxesParams = {}): Promise<ListSandboxesResponse> {\n    const query: Record<string, string | number | boolean | undefined | null | (string | number)[]> = {};\n    if (params.states?.length) query.state = params.states;\n    if (params.metadata && Object.keys(params.metadata).length) {\n      query.metadata = encodeMetadataFilter(params.metadata);\n    }\n    if (params.page != null) query.page = params.page;\n    if (params.pageSize != null) query.pageSize = params.pageSize;\n\n    const { data, error, response } = await this.client.GET(\"/sandboxes\", {\n      params: { query },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"List sandboxes failed\");\n    const raw = data as ApiListSandboxesOk | undefined;\n    if (!raw || typeof raw !== \"object\") {\n      throw new Error(\"List sandboxes failed: unexpected response shape\");\n    }\n    const itemsRaw = raw.items;\n    if (!Array.isArray(itemsRaw)) throw new Error(\"List sandboxes failed: unexpected items shape\");\n    return {\n      ...(raw ?? {}),\n      items: itemsRaw.map((x) => this.mapSandboxInfo(x)),\n    } as ListSandboxesResponse;\n  }\n\n  async deleteSandbox(sandboxId: SandboxId): Promise<void> {\n    const { error, response } = await this.client.DELETE(\"/sandboxes/{sandboxId}\", {\n      params: { path: { sandboxId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Delete sandbox failed\");\n  }\n\n  async pauseSandbox(sandboxId: SandboxId): Promise<void> {\n    const { error, response } = await this.client.POST(\"/sandboxes/{sandboxId}/pause\", {\n      params: { path: { sandboxId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Pause sandbox failed\");\n  }\n\n  async resumeSandbox(sandboxId: SandboxId): Promise<void> {\n    const { error, response } = await this.client.POST(\"/sandboxes/{sandboxId}/resume\", {\n      params: { path: { sandboxId } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Resume sandbox failed\");\n  }\n\n  async renewSandboxExpiration(\n    sandboxId: SandboxId,\n    req: RenewSandboxExpirationRequest,\n  ): Promise<RenewSandboxExpirationResponse> {\n    const body: ApiRenewSandboxExpirationRequest = req as unknown as ApiRenewSandboxExpirationRequest;\n    const { data, error, response } = await this.client.POST(\"/sandboxes/{sandboxId}/renew-expiration\", {\n      params: { path: { sandboxId } },\n      body,\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Renew sandbox expiration failed\");\n    const raw = data as ApiRenewSandboxExpirationOk | undefined;\n    if (!raw || typeof raw !== \"object\") {\n      throw new Error(\"Renew sandbox expiration failed: unexpected response shape\");\n    }\n    return {\n      ...(raw ?? {}),\n      expiresAt: raw?.expiresAt ? this.parseIsoDate(\"expiresAt\", raw.expiresAt) : undefined,\n    } as RenewSandboxExpirationResponse;\n  }\n\n  async getSandboxEndpoint(\n    sandboxId: SandboxId,\n    port: number,\n    useServerProxy = false\n  ): Promise<Endpoint> {\n    const { data, error, response } = await this.client.GET(\"/sandboxes/{sandboxId}/endpoints/{port}\", {\n      params: { path: { sandboxId, port }, query: { use_server_proxy: useServerProxy } },\n    });\n    throwOnOpenApiFetchError({ error, response }, \"Get sandbox endpoint failed\");\n    const ok = data as ApiEndpointOk | undefined;\n    if (!ok || typeof ok !== \"object\") {\n      throw new Error(\"Get sandbox endpoint failed: unexpected response shape\");\n    }\n    return ok as unknown as Endpoint;\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/adapters/sse.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { SandboxApiException, SandboxError } from \"../core/exceptions.js\";\n\nfunction tryParseJson(line: string): unknown | undefined {\n  try {\n    return JSON.parse(line);\n  } catch {\n    return undefined;\n  }\n}\n\n/**\n * Parses an SSE-like stream that may be either:\n * - standard SSE frames (`data: {...}\\n\\n`)\n * - newline-delimited JSON (one JSON object per line)\n */\nexport async function* parseJsonEventStream<T>(\n  res: Response,\n  opts?: { fallbackErrorMessage?: string },\n): AsyncIterable<T> {\n  if (!res.ok) {\n    const text = await res.text().catch(() => \"\");\n    const parsed = tryParseJson(text);\n    const err = parsed && typeof parsed === \"object\" ? (parsed as any) : undefined;\n    const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n    const message = err?.message ?? opts?.fallbackErrorMessage ?? `Stream request failed (status=${res.status})`;\n    const code = err?.code ? String(err.code) : SandboxError.UNEXPECTED_RESPONSE;\n    throw new SandboxApiException({\n      message,\n      statusCode: res.status,\n      requestId,\n      error: new SandboxError(code, err?.message ? String(err.message) : message),\n      rawBody: parsed ?? text,\n    });\n  }\n\n  if (!res.body) {\n    return;\n  }\n\n  const reader = res.body.getReader();\n  const decoder = new TextDecoder(\"utf-8\");\n  let buf = \"\";\n\n  while (true) {\n    const { value, done } = await reader.read();\n    if (done) break;\n\n    buf += decoder.decode(value, { stream: true });\n    let idx: number;\n\n    while ((idx = buf.indexOf(\"\\n\")) >= 0) {\n      const rawLine = buf.slice(0, idx);\n      buf = buf.slice(idx + 1);\n\n      const line = rawLine.trim();\n      if (!line) continue;\n\n      // Support standard SSE \"data:\" prefix\n      if (line.startsWith(\":\")) continue;\n      if (line.startsWith(\"event:\") || line.startsWith(\"id:\") || line.startsWith(\"retry:\")) continue;\n\n      const jsonLine = line.startsWith(\"data:\") ? line.slice(\"data:\".length).trim() : line;\n      if (!jsonLine) continue;\n\n      const parsed = tryParseJson(jsonLine);\n      if (!parsed) continue;\n      yield parsed as T;\n    }\n  }\n\n  // Flush any buffered UTF-8 bytes from the decoder.\n  buf += decoder.decode();\n\n  // flush last line if exists\n  const last = buf.trim();\n  if (last) {\n    const jsonLine = last.startsWith(\"data:\") ? last.slice(\"data:\".length).trim() : last;\n    const parsed = tryParseJson(jsonLine);\n    if (parsed) yield parsed as T;\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/api/egress.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd..\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\nexport interface paths {\n    \"/policy\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get current egress policy\n         * @description Returns the currently enforced egress policy and the sidecar's derived\n         *     runtime mode metadata.\n         */\n        get: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path?: never;\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /** @description Current policy returned successfully. */\n                200: {\n                    headers: {\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"PolicyStatusResponse\"];\n                    };\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        /**\n         * Patch egress rules\n         * @description Merge incoming egress rules with the currently enforced policy.\n         *\n         *     This endpoint uses merge semantics:\n         *     - Existing rules remain unless overridden by incoming rules.\n         *     - Incoming rules are applied with higher priority than existing rules.\n         *     - If multiple incoming rules refer to the same `target`, the first one wins.\n         */\n        patch: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path?: never;\n                cookie?: never;\n            };\n            requestBody: {\n                content: {\n                    \"application/json\": components[\"schemas\"][\"NetworkRule\"][];\n                };\n            };\n            responses: {\n                /** @description Patch applied successfully. */\n                200: {\n                    headers: {\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"PolicyStatusResponse\"];\n                    };\n                };\n                400: components[\"responses\"][\"BadRequest\"];\n                401: components[\"responses\"][\"Unauthorized\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        trace?: never;\n    };\n}\nexport type webhooks = Record<string, never>;\nexport interface components {\n    schemas: {\n        PolicyStatusResponse: {\n            /**\n             * @description Operation status reported by the sidecar.\n             * @example ok\n             */\n            status?: string;\n            /**\n             * @description Derived runtime mode for the current policy.\n             * @example deny_all\n             */\n            mode?: string;\n            /**\n             * @description Egress sidecar enforcement backend mode.\n             * @example dns\n             */\n            enforcementMode?: string;\n            /** @description Optional human-readable reason when the sidecar returns extra context. */\n            reason?: string;\n            policy?: components[\"schemas\"][\"NetworkPolicy\"];\n        };\n        /**\n         * @description Egress network policy matching the sidecar `/policy` request body.\n         *     If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n         *     object or null results in allow-all behavior at startup.\n         */\n        NetworkPolicy: {\n            /**\n             * @description Default action when no egress rule matches. Defaults to \"deny\".\n             * @enum {string}\n             */\n            defaultAction?: \"allow\" | \"deny\";\n            /** @description List of egress rules evaluated in order. */\n            egress?: components[\"schemas\"][\"NetworkRule\"][];\n        };\n        NetworkRule: {\n            /**\n             * @description Whether to allow or deny matching targets.\n             * @enum {string}\n             */\n            action: \"allow\" | \"deny\";\n            /**\n             * @description FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n             *     IP/CIDR not yet supported in the egress MVP.\n             */\n            target: string;\n        };\n    };\n    responses: {\n        /** @description The request was invalid or malformed. */\n        BadRequest: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                \"text/plain\": string;\n            };\n        };\n        /** @description Authentication failed for the egress sidecar. */\n        Unauthorized: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                \"text/plain\": string;\n            };\n        };\n        /** @description The sidecar failed to apply or fetch policy state. */\n        InternalServerError: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                \"text/plain\": string;\n            };\n        };\n    };\n    parameters: never;\n    requestBodies: never;\n    headers: never;\n    pathItems: never;\n}\nexport type $defs = Record<string, never>;\nexport type operations = Record<string, never>;\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/api/execd.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd..\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\nexport interface paths {\n    \"/ping\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Health check endpoint\n         * @description Performs a simple health check to verify that the server is running and responsive.\n         *     Returns HTTP 200 OK status if the server is healthy. This endpoint is typically used\n         *     by load balancers, monitoring systems, and orchestration platforms (like Kubernetes)\n         *     to check service availability.\n         */\n        get: operations[\"ping\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/code/contexts\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * List active code execution contexts\n         * @description Lists all active/available code execution contexts.\n         *     If `language` is provided, only contexts under that language/runtime are returned.\n         */\n        get: operations[\"listContexts\"];\n        put?: never;\n        post?: never;\n        /**\n         * Delete all contexts under a language\n         * @description Deletes all existing code execution contexts under the specified `language`/runtime.\n         *     This is a bulk operation intended for code-interpreter context cleanup.\n         */\n        delete: operations[\"deleteContextsByLanguage\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/code/contexts/{context_id}\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get a code execution context by id\n         * @description Retrieves the details of an existing code execution context (session) by id.\n         *     Returns the context ID, language, and any associated metadata.\n         */\n        get: operations[\"getContext\"];\n        put?: never;\n        post?: never;\n        /**\n         * Delete a code execution context by id\n         * @description Deletes an existing code execution context (session) by id.\n         *     This should terminate the underlying context thread/process and release resources.\n         */\n        delete: operations[\"deleteContext\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/code/context\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Create code execution context\n         * @description Creates a new code execution environment and returns a session ID that can be used\n         *     for subsequent code execution requests. The context maintains state across multiple\n         *     code executions within the same session.\n         */\n        post: operations[\"createCodeContext\"];\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/code\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Execute code in context\n         * @description Executes code using Jupyter kernel in a specified execution context and streams\n         *     the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n         *     languages (Python, JavaScript, etc.) and maintains execution state within the session.\n         *     Returns execution results, output streams, execution count, and any errors.\n         */\n        post: operations[\"runCode\"];\n        /**\n         * Interrupt code execution\n         * @description Interrupts the currently running code execution in the specified context.\n         *     This sends a signal to terminate the execution process and releases associated resources.\n         */\n        delete: operations[\"interruptCode\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/command\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Execute shell command\n         * @description Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n         *     The command can run in foreground or background mode. The response includes stdout, stderr,\n         *     execution status, and completion events.\n         *     Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n         *     terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n         *     with specific user/group IDs, and `envs` to inject environment variables.\n         */\n        post: operations[\"runCommand\"];\n        /**\n         * Interrupt command execution\n         * @description Interrupts the currently running command execution in the specified context.\n         *     This sends a signal to terminate the execution process and releases associated resources.\n         */\n        delete: operations[\"interruptCommand\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/command/status/{id}\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get command running status\n         * @description Returns the current status of a command (foreground or background) by command ID.\n         *     Includes running flag, exit code, error (if any), and start/finish timestamps.\n         */\n        get: operations[\"getCommandStatus\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/command/{id}/logs\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get background command stdout/stderr (non-streamed)\n         * @description Returns stdout and stderr for a background (detached) command by command ID.\n         *     Foreground commands should be consumed via SSE; this endpoint is intended for\n         *     polling logs of background commands. Supports incremental reads similar to a file seek:\n         *     pass a starting line via query to fetch output after that line and receive the latest\n         *     tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n         *     Response body is plain text so it can be rendered directly in browsers; the latest line index\n         *     is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n         */\n        get: operations[\"getBackgroundCommandLogs\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/info\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get file metadata\n         * @description Retrieves detailed metadata for one or multiple files including permissions, owner,\n         *     group, size, and modification time. Returns a map of file paths to their corresponding\n         *     FileInfo objects.\n         */\n        get: operations[\"getFilesInfo\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        post?: never;\n        /**\n         * Delete files\n         * @description Deletes one or multiple files from the sandbox. Only removes files, not directories.\n         *     Use RemoveDirs for directory removal.\n         */\n        delete: operations[\"removeFiles\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/permissions\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Change file permissions\n         * @description Changes permissions (mode), owner, and group for one or multiple files.\n         *     Accepts a map of file paths to permission settings including octal mode,\n         *     owner username, and group name.\n         */\n        post: operations[\"chmodFiles\"];\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/mv\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Rename or move files\n         * @description Renames or moves one or multiple files to new paths. Can be used for both\n         *     renaming within the same directory and moving to different directories.\n         *     Target directory must exist.\n         */\n        post: operations[\"renameFiles\"];\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/search\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Search for files\n         * @description Searches for files matching a glob pattern within a specified directory and\n         *     its subdirectories. Returns file metadata including path, permissions, owner,\n         *     and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n         */\n        get: operations[\"searchFiles\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/replace\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Replace file content\n         * @description Performs text replacement in one or multiple files. Replaces all occurrences\n         *     of the old string with the new string (similar to strings.ReplaceAll).\n         *     Preserves file permissions. Useful for batch text substitution across files.\n         */\n        post: operations[\"replaceContent\"];\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/upload\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Upload files to sandbox\n         * @description Uploads one or multiple files to specified paths within the sandbox.\n         *     Reads metadata and file content from multipart form parts in sequence.\n         *     Each file upload consists of two parts: a metadata part (JSON) followed\n         *     by the actual file part.\n         */\n        post: operations[\"uploadFile\"];\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/files/download\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Download file from sandbox\n         * @description Downloads a file from the specified path within the sandbox. Supports HTTP\n         *     range requests for resumable downloads and partial content retrieval.\n         *     Returns file as octet-stream with appropriate headers.\n         */\n        get: operations[\"downloadFile\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/directories\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Create directories\n         * @description Creates one or multiple directories with specified permissions. Creates parent\n         *     directories as needed (similar to mkdir -p). Accepts a map of directory paths\n         *     to permission objects.\n         */\n        post: operations[\"makeDirs\"];\n        /**\n         * Delete directories\n         * @description Recursively deletes one or multiple directories and all their contents.\n         *     Similar to rm -rf. Use with caution as this operation cannot be undone.\n         */\n        delete: operations[\"removeDirs\"];\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/metrics\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get system metrics\n         * @description Retrieves current system resource metrics including CPU usage percentage,\n         *     CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n         *     of system resource utilization at the time of request.\n         */\n        get: operations[\"getMetrics\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/metrics/watch\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Watch system metrics in real-time\n         * @description Streams system resource metrics in real-time using Server-Sent Events (SSE).\n         *     Updates are sent every second, providing continuous monitoring of CPU usage,\n         *     memory usage, and other system metrics. The connection remains open until\n         *     the client disconnects.\n         */\n        get: operations[\"watchMetrics\"];\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n}\nexport type webhooks = Record<string, never>;\nexport interface components {\n    schemas: {\n        /** @description Request to create a code execution context */\n        CodeContextRequest: {\n            /**\n             * @description Execution runtime (python, bash, java, etc.)\n             * @example python\n             */\n            language?: string;\n        };\n        /** @description Code execution context with session identifier */\n        CodeContext: {\n            /**\n             * @description Unique session identifier returned by CreateContext\n             * @example session-abc123\n             */\n            id?: string;\n            /**\n             * @description Execution runtime\n             * @example python\n             */\n            language: string;\n        };\n        /** @description Request to execute code in a context */\n        RunCodeRequest: {\n            context?: components[\"schemas\"][\"CodeContext\"];\n            /**\n             * @description Source code to execute\n             * @example import numpy as np\n             *     result = np.array([1, 2, 3])\n             *     print(result)\n             */\n            code: string;\n        };\n        /** @description Request to execute a shell command */\n        RunCommandRequest: {\n            /**\n             * @description Shell command to execute\n             * @example ls -la /workspace\n             */\n            command: string;\n            /**\n             * @description Working directory for command execution\n             * @example /workspace\n             */\n            cwd?: string;\n            /**\n             * @description Whether to run command in detached mode\n             * @default false\n             * @example false\n             */\n            background: boolean;\n            /**\n             * Format: int64\n             * @description Maximum allowed execution time in milliseconds before the command is forcefully terminated by the server. If omitted, the server will not enforce any timeout.\n             * @example 60000\n             */\n            timeout?: number;\n            /**\n             * Format: int32\n             * @description Unix user ID used to run the command. If `gid` is provided, `uid` is required.\n             * @example 1000\n             */\n            uid?: number;\n            /**\n             * Format: int32\n             * @description Unix group ID used to run the command. Requires `uid` to be provided.\n             * @example 1000\n             */\n            gid?: number;\n            /**\n             * @description Environment variables injected into the command process.\n             * @example {\n             *       \"PATH\": \"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\n             *       \"PYTHONUNBUFFERED\": \"1\"\n             *     }\n             */\n            envs?: {\n                [key: string]: string;\n            };\n        };\n        /** @description Command execution status (foreground or background) */\n        CommandStatusResponse: {\n            /**\n             * @description Command ID returned by RunCommand\n             * @example cmd-abc123\n             */\n            id?: string;\n            /**\n             * @description Original command content\n             * @example ls -la\n             */\n            content?: string;\n            /**\n             * @description Whether the command is still running\n             * @example false\n             */\n            running?: boolean;\n            /**\n             * Format: int32\n             * @description Exit code if the command has finished\n             * @example 0\n             */\n            exit_code?: number | null;\n            /**\n             * @description Error message if the command failed\n             * @example permission denied\n             */\n            error?: string;\n            /**\n             * Format: date-time\n             * @description Start time in RFC3339 format\n             * @example 2025-12-22T09:08:05Z\n             */\n            started_at?: string;\n            /**\n             * Format: date-time\n             * @description Finish time in RFC3339 format (null if still running)\n             * @example 2025-12-22T09:08:09Z\n             */\n            finished_at?: string | null;\n        };\n        /** @description Server-sent event for streaming execution output */\n        ServerStreamEvent: {\n            /**\n             * @description Event type for client-side handling\n             * @example stdout\n             * @enum {string}\n             */\n            type?: \"init\" | \"status\" | \"error\" | \"stdout\" | \"stderr\" | \"result\" | \"execution_complete\" | \"execution_count\" | \"ping\";\n            /**\n             * @description Textual data for status, init, and stream events\n             * @example Hello, World!\n             */\n            text?: string;\n            /**\n             * @description Cell execution number in the session\n             * @example 1\n             */\n            execution_count?: number;\n            /**\n             * Format: int64\n             * @description Execution duration in milliseconds\n             * @example 150\n             */\n            execution_time?: number;\n            /**\n             * Format: int64\n             * @description When the event was generated (Unix milliseconds)\n             * @example 1700000000000\n             */\n            timestamp?: number;\n            /**\n             * @description Execution output in various MIME types (e.g., \"text/plain\", \"text/html\")\n             * @example {\n             *       \"text/plain\": \"4\"\n             *     }\n             */\n            results?: {\n                [key: string]: unknown;\n            };\n            /** @description Execution error details if an error occurred */\n            error?: {\n                /**\n                 * @description Error name/type\n                 * @example NameError\n                 */\n                ename?: string;\n                /**\n                 * @description Error value/message\n                 * @example name 'undefined_var' is not defined\n                 */\n                evalue?: string;\n                /**\n                 * @description Stack trace lines\n                 * @example [\n                 *       \"Traceback (most recent call last):\",\n                 *       \"  File \\\"<stdin>\\\", line 1, in <module>\",\n                 *       \"NameError: name 'undefined_var' is not defined\"\n                 *     ]\n                 */\n                traceback?: string[];\n            };\n        };\n        /** @description File metadata including path and permissions */\n        FileInfo: {\n            /**\n             * @description Absolute file path\n             * @example /workspace/file.txt\n             */\n            path: string;\n            /**\n             * Format: int64\n             * @description File size in bytes\n             * @example 2048\n             */\n            size: number;\n            /**\n             * Format: date-time\n             * @description Last modification time\n             * @example 2025-11-16T14:30:45Z\n             */\n            modified_at: string;\n            /**\n             * Format: date-time\n             * @description File creation time\n             * @example 2025-11-16T14:30:45Z\n             */\n            created_at: string;\n            /**\n             * @description File owner username\n             * @example admin\n             */\n            owner: string;\n            /**\n             * @description File group name\n             * @example admin\n             */\n            group: string;\n            /**\n             * @description File permissions in octal format\n             * @example 755\n             */\n            mode: number;\n        };\n        /** @description File ownership and mode settings */\n        Permission: {\n            /**\n             * @description Owner username\n             * @example root\n             */\n            owner?: string;\n            /**\n             * @description Group name\n             * @example root\n             */\n            group?: string;\n            /**\n             * @description Permission mode in octal format (e.g., 644, 755)\n             * @default 755\n             * @example 755\n             */\n            mode: number;\n        };\n        /** @description File metadata for upload operations */\n        FileMetadata: {\n            /**\n             * @description Target file path\n             * @example /workspace/upload.txt\n             */\n            path?: string;\n            /**\n             * @description File owner\n             * @example admin\n             */\n            owner?: string;\n            /**\n             * @description File group\n             * @example admin\n             */\n            group?: string;\n            /**\n             * @description File permissions in octal\n             * @example 755\n             */\n            mode?: number;\n        };\n        /** @description File rename/move operation */\n        RenameFileItem: {\n            /**\n             * @description Source file path\n             * @example /workspace/old.txt\n             */\n            src: string;\n            /**\n             * @description Destination file path\n             * @example /workspace/new.txt\n             */\n            dest: string;\n        };\n        /** @description Content replacement operation */\n        ReplaceFileContentItem: {\n            /**\n             * @description String to be replaced\n             * @example localhost\n             */\n            old: string;\n            /**\n             * @description Replacement string\n             * @example 0.0.0.0\n             */\n            new: string;\n        };\n        /** @description System resource usage metrics */\n        Metrics: {\n            /**\n             * Format: float\n             * @description Number of CPU cores\n             * @example 4\n             */\n            cpu_count: number;\n            /**\n             * Format: float\n             * @description CPU usage percentage\n             * @example 45.5\n             */\n            cpu_used_pct: number;\n            /**\n             * Format: float\n             * @description Total memory in MiB\n             * @example 8192\n             */\n            mem_total_mib: number;\n            /**\n             * Format: float\n             * @description Used memory in MiB\n             * @example 4096\n             */\n            mem_used_mib: number;\n            /**\n             * Format: int64\n             * @description Timestamp when metrics were collected (Unix milliseconds)\n             * @example 1700000000000\n             */\n            timestamp: number;\n        };\n        /** @description Standard error response format */\n        ErrorResponse: {\n            /**\n             * @description Error code for programmatic handling\n             * @example INVALID_REQUEST_BODY\n             */\n            code: string;\n            /**\n             * @description Human-readable error message\n             * @example error parsing request, MAYBE invalid body format\n             */\n            message: string;\n        };\n    };\n    responses: {\n        /** @description Invalid request body format or missing required fields */\n        BadRequest: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                /**\n                 * @example {\n                 *       \"code\": \"INVALID_REQUEST_BODY\",\n                 *       \"message\": \"error parsing request, MAYBE invalid body format\"\n                 *     }\n                 */\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description File or resource not found */\n        NotFound: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                /**\n                 * @example {\n                 *       \"code\": \"FILE_NOT_FOUND\",\n                 *       \"message\": \"file not found\"\n                 *     }\n                 */\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description Runtime server error during operation */\n        InternalServerError: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                /**\n                 * @example {\n                 *       \"code\": \"RUNTIME_ERROR\",\n                 *       \"message\": \"error running code execution\"\n                 *     }\n                 */\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n    };\n    parameters: never;\n    requestBodies: never;\n    headers: never;\n    pathItems: never;\n}\nexport type $defs = Record<string, never>;\nexport interface operations {\n    ping: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Server is alive and healthy */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n        };\n    };\n    listContexts: {\n        parameters: {\n            query: {\n                /**\n                 * @description Filter contexts by execution runtime (python, bash, java, etc.)\n                 * @example python\n                 */\n                language: string;\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Array of active contexts */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"CodeContext\"][];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    deleteContextsByLanguage: {\n        parameters: {\n            query: {\n                /**\n                 * @description Target execution runtime whose contexts should be deleted\n                 * @example python\n                 */\n                language: string;\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Contexts deleted successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    getContext: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path: {\n                /**\n                 * @description Session/context id to get\n                 * @example session-abc123\n                 */\n                context_id: string;\n            };\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Context details retrieved successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"CodeContext\"];\n                };\n            };\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    deleteContext: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path: {\n                /**\n                 * @description Session/context id to delete\n                 * @example session-abc123\n                 */\n                context_id: string;\n            };\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Context deleted successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    createCodeContext: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                \"application/json\": components[\"schemas\"][\"CodeContextRequest\"];\n            };\n        };\n        responses: {\n            /** @description Successfully created context with session ID */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"CodeContext\"];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    runCode: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                \"application/json\": components[\"schemas\"][\"RunCodeRequest\"];\n            };\n        };\n        responses: {\n            /** @description Stream of code execution events */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"text/event-stream\": components[\"schemas\"][\"ServerStreamEvent\"];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    interruptCode: {\n        parameters: {\n            query: {\n                /**\n                 * @description Session ID of the execution context to interrupt\n                 * @example session-123\n                 */\n                id: string;\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Code execution successfully interrupted */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    runCommand: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                \"application/json\": components[\"schemas\"][\"RunCommandRequest\"];\n            };\n        };\n        responses: {\n            /** @description Stream of command execution events */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"text/event-stream\": components[\"schemas\"][\"ServerStreamEvent\"];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    interruptCommand: {\n        parameters: {\n            query: {\n                /**\n                 * @description Session ID of the execution context to interrupt\n                 * @example session-456\n                 */\n                id: string;\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Command execution successfully interrupted */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    getCommandStatus: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path: {\n                /**\n                 * @description Command ID returned by RunCommand\n                 * @example cmd-abc123\n                 */\n                id: string;\n            };\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Command status */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"CommandStatusResponse\"];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    getBackgroundCommandLogs: {\n        parameters: {\n            query?: {\n                /**\n                 * @description Optional 0-based line cursor (behaves like a file seek). When provided, only\n                 *     stdout/stderr lines after this line are returned. The response includes the\n                 *     latest line index (`cursor`) so the client can request incremental output\n                 *     on subsequent calls. If omitted, the full log is returned.\n                 * @example 120\n                 */\n                cursor?: number;\n            };\n            header?: never;\n            path: {\n                /**\n                 * @description Command ID returned by RunCommand\n                 * @example cmd-abc123\n                 */\n                id: string;\n            };\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Command output (plain text) and status metadata via headers */\n            200: {\n                headers: {\n                    /** @description Highest available 0-based line index after applying the request cursor (use as the next cursor for incremental reads) */\n                    \"EXECD-COMMANDS-TAIL-CURSOR\"?: number;\n                    [name: string]: unknown;\n                };\n                content: {\n                    /**\n                     * @example line1\n                     *     line2\n                     *     warn: something on stderr\n                     */\n                    \"text/plain\": string;\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    getFilesInfo: {\n        parameters: {\n            query: {\n                /** @description File path(s) to get info for (can be specified multiple times) */\n                path: string[];\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Map of file paths to FileInfo objects */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": {\n                        [key: string]: components[\"schemas\"][\"FileInfo\"];\n                    };\n                };\n            };\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    removeFiles: {\n        parameters: {\n            query: {\n                /**\n                 * @description File path(s) to delete (can be specified multiple times)\n                 * @example [\n                 *       \"/workspace/temp.txt\"\n                 *     ]\n                 */\n                path: string[];\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Files deleted successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    chmodFiles: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                /**\n                 * @example {\n                 *       \"/workspace/script.sh\": {\n                 *         \"owner\": \"admin\",\n                 *         \"group\": \"admin\",\n                 *         \"mode\": 755\n                 *       },\n                 *       \"/workspace/config.json\": {\n                 *         \"owner\": \"admin\",\n                 *         \"group\": \"admin\",\n                 *         \"mode\": 755\n                 *       }\n                 *     }\n                 */\n                \"application/json\": {\n                    [key: string]: components[\"schemas\"][\"Permission\"];\n                };\n            };\n        };\n        responses: {\n            /** @description Permissions changed successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    renameFiles: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                /**\n                 * @example [\n                 *       {\n                 *         \"src\": \"/workspace/old_name.txt\",\n                 *         \"dest\": \"/workspace/new_name.txt\"\n                 *       },\n                 *       {\n                 *         \"src\": \"/workspace/file.py\",\n                 *         \"dest\": \"/archive/file.py\"\n                 *       }\n                 *     ]\n                 */\n                \"application/json\": components[\"schemas\"][\"RenameFileItem\"][];\n            };\n        };\n        responses: {\n            /** @description Files renamed/moved successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    searchFiles: {\n        parameters: {\n            query: {\n                /** @description Root directory path to search in */\n                path: string;\n                /** @description Glob pattern to match files (default is **) */\n                pattern?: string;\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Array of matching files with metadata */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"FileInfo\"][];\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    replaceContent: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                /**\n                 * @example {\n                 *       \"/workspace/config.yaml\": {\n                 *         \"old\": \"localhost:8080\",\n                 *         \"new\": \"0.0.0.0:9090\"\n                 *       },\n                 *       \"/workspace/app.py\": {\n                 *         \"old\": \"DEBUG = True\",\n                 *         \"new\": \"DEBUG = False\"\n                 *       }\n                 *     }\n                 */\n                \"application/json\": {\n                    [key: string]: components[\"schemas\"][\"ReplaceFileContentItem\"];\n                };\n            };\n        };\n        responses: {\n            /** @description Content replaced successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    uploadFile: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                \"multipart/form-data\": {\n                    /**\n                     * @description JSON-encoded file metadata (FileMetadata object)\n                     * @example {\"path\":\"/workspace/file.txt\",\"owner\":\"admin\",\"group\":\"admin\",\"mode\":755}\n                     */\n                    metadata?: string;\n                    /**\n                     * Format: binary\n                     * @description File to upload\n                     */\n                    file?: string;\n                };\n            };\n        };\n        responses: {\n            /** @description Files uploaded successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    downloadFile: {\n        parameters: {\n            query: {\n                /**\n                 * @description Absolute or relative path of the file to download\n                 * @example /workspace/data.csv\n                 */\n                path: string;\n            };\n            header?: {\n                /**\n                 * @description HTTP Range header for partial content requests\n                 * @example bytes=0-1023\n                 */\n                Range?: string;\n            };\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description File content */\n            200: {\n                headers: {\n                    /** @description Attachment header with filename */\n                    \"Content-Disposition\"?: string;\n                    /** @description File size in bytes */\n                    \"Content-Length\"?: number;\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/octet-stream\": string;\n                };\n            };\n            /** @description Partial file content (when Range header is provided) */\n            206: {\n                headers: {\n                    /** @description Range of bytes being returned */\n                    \"Content-Range\"?: string;\n                    /** @description Length of the returned range */\n                    \"Content-Length\"?: number;\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/octet-stream\": string;\n                };\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            404: components[\"responses\"][\"NotFound\"];\n            /** @description Requested range not satisfiable */\n            416: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n                };\n            };\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    makeDirs: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody: {\n            content: {\n                /**\n                 * @example {\n                 *       \"/workspace/project\": {\n                 *         \"owner\": \"admin\",\n                 *         \"group\": \"admin\",\n                 *         \"mode\": 755\n                 *       },\n                 *       \"/workspace/logs\": {\n                 *         \"owner\": \"admin\",\n                 *         \"group\": \"admin\",\n                 *         \"mode\": 755\n                 *       }\n                 *     }\n                 */\n                \"application/json\": {\n                    [key: string]: components[\"schemas\"][\"Permission\"];\n                };\n            };\n        };\n        responses: {\n            /** @description Directories created successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            400: components[\"responses\"][\"BadRequest\"];\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    removeDirs: {\n        parameters: {\n            query: {\n                /**\n                 * @description Directory path(s) to delete (can be specified multiple times)\n                 * @example [\n                 *       \"/workspace/temp\"\n                 *     ]\n                 */\n                path: string[];\n            };\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Directories deleted successfully */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content?: never;\n            };\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    getMetrics: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Current system metrics including CPU and memory usage */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"application/json\": components[\"schemas\"][\"Metrics\"];\n                };\n            };\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n    watchMetrics: {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        requestBody?: never;\n        responses: {\n            /** @description Stream of system metrics updated every second */\n            200: {\n                headers: {\n                    [name: string]: unknown;\n                };\n                content: {\n                    \"text/event-stream\": components[\"schemas\"][\"Metrics\"];\n                };\n            };\n            500: components[\"responses\"][\"InternalServerError\"];\n        };\n    };\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/api/lifecycle.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd..\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * This file was auto-generated by openapi-typescript.\n * Do not make direct changes to the file.\n */\n\nexport interface paths {\n    \"/sandboxes\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * List sandboxes\n         * @description List all sandboxes with optional filtering and pagination using query parameters.\n         *     All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n         */\n        get: {\n            parameters: {\n                query?: {\n                    /**\n                     * @description Filter by lifecycle state. Pass multiple times for OR logic.\n                     *     Example: `?state=Running&state=Paused`\n                     */\n                    state?: string[];\n                    /**\n                     * @description Arbitrary metadata key-value pairs for filtering，keys and values must be url encoded\n                     *     Example: To filter by `project=Apollo` and `note=Demo Test`: `?metadata=project%3DApollo%26note%3DDemo%252520Test`\n                     */\n                    metadata?: string;\n                    /** @description Page number for pagination */\n                    page?: number;\n                    /** @description Number of items per page */\n                    pageSize?: number;\n                };\n                header?: never;\n                path?: never;\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /** @description Paginated collection of sandboxes */\n                200: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"ListSandboxesResponse\"];\n                    };\n                };\n                400: components[\"responses\"][\"BadRequest\"];\n                401: components[\"responses\"][\"Unauthorized\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        put?: never;\n        /**\n         * Create a sandbox from a container image\n         * @description Creates a new sandbox from a container image with optional resource limits,\n         *     environment variables, and metadata. Sandboxes are provisioned directly from\n         *     the specified image without requiring a pre-created template.\n         *\n         *     ## Authentication\n         *\n         *     API Key authentication is required via:\n         *     - `OPEN-SANDBOX-API-KEY: <api-key>` header\n         */\n        post: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path?: never;\n                cookie?: never;\n            };\n            requestBody: {\n                content: {\n                    \"application/json\": components[\"schemas\"][\"CreateSandboxRequest\"];\n                };\n            };\n            responses: {\n                /**\n                 * @description Sandbox created and accepted for provisioning.\n                 *\n                 *     The returned sandbox includes:\n                 *     - `id`: Unique sandbox identifier\n                 *     - `status.state: \"Pending\"` (auto-starting provisioning)\n                 *     - `status.reason` and `status.message` indicating initialization stage\n                 *     - `metadata`, `expiresAt`, `createdAt`: Core sandbox information\n                 *\n                 *     Note: `image` and `updatedAt` are not included in the create response.\n                 *     Use GET /sandboxes/{sandboxId} to retrieve the complete sandbox information including image spec.\n                 *\n                 *     To track provisioning progress, poll GET /sandboxes/{sandboxId}.\n                 *     The sandbox will automatically transition to `Running` state once provisioning completes.\n                 */\n                202: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        Location: components[\"headers\"][\"Location\"];\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"CreateSandboxResponse\"];\n                    };\n                };\n                400: components[\"responses\"][\"BadRequest\"];\n                401: components[\"responses\"][\"Unauthorized\"];\n                409: components[\"responses\"][\"Conflict\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/sandboxes/{sandboxId}\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path: {\n                /** @description Unique sandbox identifier */\n                sandboxId: components[\"parameters\"][\"SandboxId\"];\n            };\n            cookie?: never;\n        };\n        /**\n         * Fetch a sandbox by id\n         * @description Returns the complete sandbox information including:\n         *     - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n         *     - `image`: Container image specification (not included in create response)\n         *     - `entrypoint`: Entry process specification\n         *\n         *     This is the complete representation of the sandbox resource.\n         */\n        get: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                };\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /** @description Sandbox current state and metadata */\n                200: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"Sandbox\"];\n                    };\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        put?: never;\n        post?: never;\n        /**\n         * Delete a sandbox\n         * @description Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to Terminated.\n         */\n        delete: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                };\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /**\n                 * @description Sandbox successfully deleted.\n                 *\n                 *     Sandbox has been scheduled for termination and will transition to Stopping state, then Terminated.\n                 */\n                204: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content?: never;\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                409: components[\"responses\"][\"Conflict\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/sandboxes/{sandboxId}/pause\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Pause execution while retaining state\n         * @description Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state transition to Paused.\n         */\n        post: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                };\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /**\n                 * @description Pause operation accepted.\n                 *\n                 *     Sandbox will transition to Pausing state.\n                 *     Poll GET /sandboxes/{sandboxId} to track progress.\n                 */\n                202: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content?: never;\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                409: components[\"responses\"][\"Conflict\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/sandboxes/{sandboxId}/resume\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Resume a paused sandbox\n         * @description Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to Running.\n         */\n        post: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                };\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /**\n                 * @description Resume operation accepted.\n                 *\n                 *     Sandbox will transition from Paused → Running.\n                 *     Poll GET /sandboxes/{sandboxId} to track progress.\n                 */\n                202: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content?: never;\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                409: components[\"responses\"][\"Conflict\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/sandboxes/{sandboxId}/renew-expiration\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        get?: never;\n        put?: never;\n        /**\n         * Renew sandbox expiration\n         * @description Renew the absolute expiration time of a sandbox.\n         */\n        post: {\n            parameters: {\n                query?: never;\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                };\n                cookie?: never;\n            };\n            requestBody: {\n                content: {\n                    \"application/json\": components[\"schemas\"][\"RenewSandboxExpirationRequest\"];\n                };\n            };\n            responses: {\n                /**\n                 * @description Sandbox expiration updated successfully.\n                 *\n                 *     Returns only the updated expiresAt field.\n                 */\n                200: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"RenewSandboxExpirationResponse\"];\n                    };\n                };\n                400: components[\"responses\"][\"BadRequest\"];\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                409: components[\"responses\"][\"Conflict\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n    \"/sandboxes/{sandboxId}/endpoints/{port}\": {\n        parameters: {\n            query?: never;\n            header?: never;\n            path?: never;\n            cookie?: never;\n        };\n        /**\n         * Get sandbox access endpoint\n         * @description Get the public access endpoint URL for accessing a service running on a specific port\n         *     within the sandbox. The service must be listening on the specified port inside\n         *     the sandbox for the endpoint to be available.\n         */\n        get: {\n            parameters: {\n                query?: {\n                    /** @description Whether to return a server-proxied URL */\n                    use_server_proxy?: boolean;\n                };\n                header?: never;\n                path: {\n                    /** @description Unique sandbox identifier */\n                    sandboxId: components[\"parameters\"][\"SandboxId\"];\n                    /** @description Port number where the service is listening inside the sandbox */\n                    port: number;\n                };\n                cookie?: never;\n            };\n            requestBody?: never;\n            responses: {\n                /**\n                 * @description Endpoint retrieved successfully.\n                 *\n                 *     Returns the public URL for accessing the service on the specified port.\n                 */\n                200: {\n                    headers: {\n                        \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                        [name: string]: unknown;\n                    };\n                    content: {\n                        \"application/json\": components[\"schemas\"][\"Endpoint\"];\n                    };\n                };\n                401: components[\"responses\"][\"Unauthorized\"];\n                403: components[\"responses\"][\"Forbidden\"];\n                404: components[\"responses\"][\"NotFound\"];\n                500: components[\"responses\"][\"InternalServerError\"];\n            };\n        };\n        put?: never;\n        post?: never;\n        delete?: never;\n        options?: never;\n        head?: never;\n        patch?: never;\n        trace?: never;\n    };\n}\nexport type webhooks = Record<string, never>;\nexport interface components {\n    schemas: {\n        ListSandboxesResponse: {\n            items: components[\"schemas\"][\"Sandbox\"][];\n            pagination: components[\"schemas\"][\"PaginationInfo\"];\n        };\n        /** @description Pagination metadata for list responses */\n        PaginationInfo: {\n            /** @description Current page number */\n            page: number;\n            /** @description Number of items per page */\n            pageSize: number;\n            /** @description Total number of items matching the filter */\n            totalItems: number;\n            /** @description Total number of pages */\n            totalPages: number;\n            /** @description Whether there are more pages after the current one */\n            hasNextPage: boolean;\n        };\n        /** @description Response from creating a new sandbox. Contains essential information without image and updatedAt. */\n        CreateSandboxResponse: {\n            /** @description Unique sandbox identifier */\n            id: string;\n            /** @description Current lifecycle status and detailed state information */\n            status: components[\"schemas\"][\"SandboxStatus\"];\n            /** @description Custom metadata from creation request */\n            metadata?: {\n                [key: string]: string;\n            };\n            /** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */\n            expiresAt?: string | null;\n            /**\n             * Format: date-time\n             * @description Sandbox creation timestamp\n             */\n            createdAt: string;\n            /** @description Entry process specification from creation request */\n            entrypoint: string[];\n        };\n        /** @description Runtime execution environment provisioned from a container image */\n        Sandbox: {\n            /** @description Unique sandbox identifier */\n            id: string;\n            /**\n             * @description Container image specification used to provision this sandbox.\n             *     Only present in responses for GET/LIST operations. Not returned in createSandbox response.\n             */\n            image: components[\"schemas\"][\"ImageSpec\"];\n            /** @description Current lifecycle status and detailed state information */\n            status: components[\"schemas\"][\"SandboxStatus\"];\n            /** @description Custom metadata from creation request */\n            metadata?: {\n                [key: string]: string;\n            };\n            /**\n             * @description The command to execute as the sandbox's entry process.\n             *     Always present in responses since entrypoint is required in creation requests.\n             */\n            entrypoint: string[];\n            /** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */\n            expiresAt?: string | null;\n            /**\n             * Format: date-time\n             * @description Sandbox creation timestamp\n             */\n            createdAt: string;\n        };\n        /**\n         * @description High-level lifecycle state of the sandbox.\n         *\n         *     Common state values:\n         *     - Pending: Sandbox is being provisioned\n         *     - Running: Sandbox is running and ready to accept requests\n         *     - Pausing: Sandbox is in the process of pausing\n         *     - Paused: Sandbox has been paused while retaining its state\n         *     - Stopping: Sandbox is being terminated\n         *     - Terminated: Sandbox has been successfully terminated\n         *     - Failed: Sandbox encountered a critical error\n         *\n         *     State transitions:\n         *     - Pending → Running (after creation completes)\n         *     - Running → Pausing (when pause is requested)\n         *     - Pausing → Paused (pause operation completes)\n         *     - Paused → Running (when resume is requested)\n         *     - Running/Paused → Stopping (when kill is requested or TTL expires)\n         *     - Stopping → Terminated (kill/timeout operation completes)\n         *     - Pending/Running/Paused → Failed (on error)\n         *\n         *     Note: New state values may be added in future versions.\n         *     Clients should handle unknown state values gracefully.\n         */\n        SandboxState: string;\n        /** @description Detailed status information with lifecycle state and transition details */\n        SandboxStatus: {\n            /** @description Current lifecycle state of the sandbox */\n            state: components[\"schemas\"][\"SandboxState\"];\n            /**\n             * @description Short machine-readable reason code for the current state.\n             *     Examples: \"user_delete\", \"ttl_expiry\", \"provision_timeout\", \"runtime_error\"\n             */\n            reason?: string;\n            /** @description Human-readable message describing the current state or reason for state transition */\n            message?: string;\n            /**\n             * Format: date-time\n             * @description Timestamp of the last state transition\n             */\n            lastTransitionAt?: string;\n        };\n        /**\n         * @description Container image specification for sandbox provisioning.\n         *\n         *     Supports public registry images and private registry images with authentication.\n         */\n        ImageSpec: {\n            /**\n             * @description Container image URI in standard format.\n             *\n             *     Examples:\n             *       - \"python:3.11\" (Docker Hub)\n             *       - \"ubuntu:22.04\"\n             *       - \"gcr.io/my-project/model-server:v1.0\"\n             *       - \"private-registry.company.com:5000/app:latest\"\n             */\n            uri: string;\n            /** @description Registry authentication credentials (required for private registries) */\n            auth?: {\n                /** @description Registry username or service account */\n                username?: string;\n                /** @description Registry password or authentication token */\n                password?: string;\n            };\n        };\n        /**\n         * @description Request to create a new sandbox from a container image.\n         *\n         *     **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n         */\n        CreateSandboxRequest: {\n            /** @description Container image specification for the sandbox */\n            image: components[\"schemas\"][\"ImageSpec\"];\n            /**\n             * @description Sandbox timeout in seconds. The sandbox will automatically terminate after this duration.\n             *     The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`).\n             *     Omit or set null to disable automatic expiration and require explicit cleanup.\n             *     Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject\n             *     null timeout when the underlying workload provider does not support non-expiring sandboxes.\n             */\n            timeout?: number | null;\n            /**\n             * @description Runtime resource constraints for the sandbox instance.\n             *     SDK clients should provide sensible defaults (e.g., cpu: \"500m\", memory: \"512Mi\").\n             */\n            resourceLimits: components[\"schemas\"][\"ResourceLimits\"];\n            /**\n             * @description Environment variables to inject into the sandbox runtime.\n             * @example {\n             *       \"API_KEY\": \"secret-key\",\n             *       \"DEBUG\": \"true\",\n             *       \"LOG_LEVEL\": \"info\"\n             *     }\n             */\n            env?: {\n                [key: string]: string;\n            };\n            /**\n             * @description Custom key-value metadata for management, filtering, and tagging.\n             *     Use \"name\" key for a human-readable identifier.\n             * @example {\n             *       \"name\": \"Data Processing Sandbox\",\n             *       \"project\": \"data-processing\",\n             *       \"team\": \"ml\",\n             *       \"environment\": \"staging\"\n             *     }\n             */\n            metadata?: {\n                [key: string]: string;\n            };\n            /**\n             * @description The command to execute as the sandbox's entry process (required).\n             *\n             *     Explicitly specifies the user's expected main process, allowing the sandbox management\n             *     service to reliably inject control processes before executing this command.\n             *\n             *     Format: [executable, arg1, arg2, ...]\n             *\n             *     Examples:\n             *     - [\"python\", \"/app/main.py\"]\n             *     - [\"/bin/bash\"]\n             *     - [\"java\", \"-jar\", \"/app/app.jar\"]\n             *     - [\"node\", \"server.js\"]\n             * @example [\n             *       \"python\",\n             *       \"/app/main.py\"\n             *     ]\n             */\n            entrypoint: string[];\n            /**\n             * @description Optional outbound network policy for the sandbox.\n             *     Shape matches the sidecar `/policy` endpoint. If omitted or empty,\n             *     the sidecar starts in allow-all mode until updated.\n             */\n            networkPolicy?: components[\"schemas\"][\"NetworkPolicy\"];\n            /**\n             * @description Storage mounts for the sandbox. Each volume entry specifies a named backend-specific\n             *     storage source and common mount settings. Exactly one backend type must be specified\n             *     per volume entry.\n             */\n            volumes?: components[\"schemas\"][\"Volume\"][];\n            /**\n             * @description Opaque container for provider-specific or transient parameters not supported by the core API.\n             *\n             *     **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard parameters should be proposed as core API fields.\n             *\n             *     **Best Practices**:\n             *     - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.\n             *     - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.\n             */\n            extensions?: {\n                [key: string]: string;\n            };\n        };\n        /**\n         * @description Runtime resource constraints as key-value pairs. Similar to Kubernetes resource specifications,\n         *     allows flexible definition of resource limits. Common resource types include:\n         *     - `cpu`: CPU allocation in millicores (e.g., \"250m\" for 0.25 CPU cores)\n         *     - `memory`: Memory allocation in bytes or human-readable format (e.g., \"512Mi\", \"1Gi\")\n         *     - `gpu`: Number of GPU devices (e.g., \"1\")\n         *\n         *     New resource types can be added without API changes.\n         * @example {\n         *       \"cpu\": \"500m\",\n         *       \"memory\": \"512Mi\",\n         *       \"gpu\": \"1\"\n         *     }\n         */\n        ResourceLimits: {\n            [key: string]: string;\n        };\n        RenewSandboxExpirationRequest: {\n            /**\n             * Format: date-time\n             * @description New absolute expiration time in UTC (RFC 3339 format).\n             *     Must be in the future and after the current expiresAt time.\n             *\n             *     Example: \"2025-11-16T14:30:45Z\"\n             */\n            expiresAt: string;\n        };\n        RenewSandboxExpirationResponse: {\n            /**\n             * Format: date-time\n             * @description The new absolute expiration time in UTC (RFC 3339 format).\n             *\n             *     Example: \"2025-11-16T14:30:45Z\"\n             */\n            expiresAt: string;\n        };\n        /**\n         * @description Standard error response for all non-2xx HTTP responses.\n         *     HTTP status code indicates the error category; code and message provide details.\n         */\n        ErrorResponse: {\n            /**\n             * @description Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR).\n             *     Use this for programmatic error handling.\n             */\n            code: string;\n            /** @description Human-readable error message describing what went wrong and how to fix it. */\n            message: string;\n        };\n        /**\n         * @description Endpoint for accessing a service running in the sandbox.\n         *     The service must be listening on the specified port inside the sandbox for the endpoint to be available.\n         */\n        Endpoint: {\n            /**\n             * @description Public URL to access the service from outside the sandbox.\n             *     Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port}\n             *     Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080\n             */\n            endpoint: string;\n            /** @description Requests targeting the sandbox must include the corresponding header(s). */\n            headers?: {\n                [key: string]: string;\n            };\n        };\n        /**\n         * @description Egress network policy matching the sidecar `/policy` request body.\n         *     If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n         *     object or null results in allow-all behavior at startup.\n         */\n        NetworkPolicy: {\n            /**\n             * @description Default action when no egress rule matches. Defaults to \"deny\".\n             * @enum {string}\n             */\n            defaultAction?: \"allow\" | \"deny\";\n            /** @description List of egress rules evaluated in order. */\n            egress?: components[\"schemas\"][\"NetworkRule\"][];\n        };\n        NetworkRule: {\n            /**\n             * @description Whether to allow or deny matching targets.\n             * @enum {string}\n             */\n            action: \"allow\" | \"deny\";\n            /**\n             * @description FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n             *     IP/CIDR not yet supported in the egress MVP.\n             */\n            target: string;\n        };\n        /**\n         * @description Storage mount definition for a sandbox. Each volume entry contains:\n         *     - A unique name identifier\n         *     - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields\n         *     - Common mount settings (mountPath, readOnly, subPath)\n         */\n        Volume: {\n            /**\n             * @description Unique identifier for the volume within the sandbox.\n             *     Must be a valid DNS label (lowercase alphanumeric, hyphens allowed, max 63 chars).\n             */\n            name: string;\n            host?: components[\"schemas\"][\"Host\"];\n            pvc?: components[\"schemas\"][\"PVC\"];\n            ossfs?: components[\"schemas\"][\"OSSFS\"];\n            /**\n             * @description Absolute path inside the container where the volume is mounted.\n             *     Must start with '/'.\n             */\n            mountPath: string;\n            /**\n             * @description If true, the volume is mounted as read-only. Defaults to false (read-write).\n             * @default false\n             */\n            readOnly: boolean;\n            /**\n             * @description Optional subdirectory under the backend path to mount.\n             *     For `ossfs` backend, this field is used as the bucket prefix.\n             *     Must be a relative path without '..' components.\n             */\n            subPath?: string;\n        };\n        /**\n         * @description Host path bind mount backend. Maps a directory on the host filesystem\n         *     into the container. Only available when the runtime supports host mounts.\n         *\n         *     Security note: Host paths are restricted by server-side allowlist.\n         *     Users must specify paths under permitted prefixes.\n         */\n        Host: {\n            /**\n             * @description Absolute path on the host filesystem to mount.\n             *     Must start with '/' and be under an allowed prefix.\n             */\n            path: string;\n        };\n        /**\n         * @description Platform-managed named volume backend. A runtime-neutral abstraction\n         *     for referencing a pre-existing, platform-managed named volume.\n         *\n         *     - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.\n         *     - Docker: maps to a Docker named volume (created via `docker volume create`).\n         *\n         *     The volume must already exist on the target platform before sandbox\n         *     creation.\n         */\n        PVC: {\n            /**\n             * @description Name of the volume on the target platform.\n             *     In Kubernetes this is the PVC name; in Docker this is the named\n             *     volume name. Must be a valid DNS label.\n             */\n            claimName: string;\n        };\n        /**\n         * @description Alibaba Cloud OSS mount backend via ossfs.\n         *\n         *     The runtime mounts a host-side OSS path under `storage.ossfs_mount_root`\n         *     and bind-mounts the resolved path into the sandbox container.\n         *     Prefix selection is expressed via `Volume.subPath`.\n         *     In Docker runtime, OSSFS backend requires OpenSandbox Server to run on a Linux host with FUSE support.\n         */\n        OSSFS: {\n            /** @description OSS bucket name. */\n            bucket: string;\n            /** @description OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). */\n            endpoint: string;\n            /**\n             * @description ossfs major version used by runtime mount integration.\n             * @default 2.0\n             * @enum {string}\n             */\n            version: \"1.0\" | \"2.0\";\n            /**\n             * @description Additional ossfs mount options.\n             *     Runtime encodes options by `version`:\n             *     - `1.0`: mounts with `ossfs ... -o <option>`\n             *     - `2.0`: mounts with `ossfs2 mount ... -c <config-file>` and encodes options as `--<option>` lines in the config file\n             *     Option values must be provided as raw payloads without leading `-`.\n             */\n            options?: string[];\n            /** @description OSS access key ID for inline credentials mode. */\n            accessKeyId: string;\n            /** @description OSS access key secret for inline credentials mode. */\n            accessKeySecret: string;\n        };\n    };\n    responses: {\n        /** @description Error response envelope */\n        Error: {\n            headers: {\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description The request was invalid or malformed */\n        BadRequest: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description Authentication credentials are missing or invalid */\n        Unauthorized: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description The authenticated user lacks permission for this operation */\n        Forbidden: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description The requested resource does not exist */\n        NotFound: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description The operation conflicts with the current state */\n        Conflict: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n        /** @description An unexpected server error occurred */\n        InternalServerError: {\n            headers: {\n                \"X-Request-ID\": components[\"headers\"][\"XRequestId\"];\n                [name: string]: unknown;\n            };\n            content: {\n                \"application/json\": components[\"schemas\"][\"ErrorResponse\"];\n            };\n        };\n    };\n    parameters: {\n        /** @description Unique sandbox identifier */\n        SandboxId: string;\n    };\n    requestBodies: never;\n    headers: {\n        /** @description Unique request identifier for tracing */\n        XRequestId: string;\n        /** @description URI of the newly created or related resource */\n        Location: string;\n        /** @description Suggested delay in seconds before retrying */\n        RetryAfter: number;\n    };\n    pathItems: never;\n}\nexport type $defs = Record<string, never>;\nexport type operations = Record<string, never>;\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/config/connection.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport {DEFAULT_USER_AGENT} from \"../core/constants.js\";\n\nexport type ConnectionProtocol = \"http\" | \"https\";\n\n/**\n * Options for {@link ConnectionConfig}.\n *\n * Most users only need `domain`, `protocol`, and `apiKey`.\n */\nexport interface ConnectionConfigOptions {\n  /**\n   * API server domain (host[:port]) without scheme.\n   * Examples:\n   * - \"localhost:8080\"\n   * - \"api.opensandbox.io\"\n   *\n   * You may also pass a full URL (e.g. \"http://localhost:8080\" or \"https://api.example.com\").\n   * If the URL includes a path, it will be preserved and `/v1` will be appended automatically.\n   */\n  domain?: string;\n  protocol?: ConnectionProtocol;\n  apiKey?: string;\n  headers?: Record<string, string>;\n\n  /**\n   * Request timeout applied to all SDK HTTP calls (best-effort; wraps fetch).\n   * Defaults to 30 seconds.\n   */\n  requestTimeoutSeconds?: number;\n  /**\n   * Enable basic debug logging for HTTP requests (best-effort).\n   */\n  debug?: boolean;\n  /**\n   * Use sandbox server as proxy for process execd requests.\n   * Useful when the client SDK cannot access the created sandbox directly.\n   */\n  useServerProxy?: boolean;\n}\n\nfunction isNodeRuntime(): boolean {\n  const p = (globalThis as any)?.process;\n  return !!p?.versions?.node;\n}\n\nfunction redactHeaders(\n  headers: Record<string, string>\n): Record<string, string> {\n  const out: Record<string, string> = { ...headers };\n  for (const k of Object.keys(out)) {\n    if (k.toLowerCase() === \"open-sandbox-api-key\") out[k] = \"***\";\n  }\n  return out;\n}\n\nfunction readEnv(name: string): string | undefined {\n  const env = (globalThis as any)?.process?.env;\n  const v = env?.[name];\n  return typeof v === \"string\" && v.length ? v : undefined;\n}\n\nfunction stripTrailingSlashes(s: string): string {\n  return s.replace(/\\/+$/, \"\");\n}\n\nfunction stripV1Suffix(s: string): string {\n  const trimmed = stripTrailingSlashes(s);\n  return trimmed.endsWith(\"/v1\") ? trimmed.slice(0, -3) : trimmed;\n}\n\nconst DEFAULT_KEEPALIVE_TIMEOUT_MS = 30_000;\n\nfunction normalizeDomainBase(input: string): {\n  protocol?: ConnectionProtocol;\n  domainBase: string;\n} {\n  // Accept a full URL and preserve its path prefix (if any).\n  if (input.startsWith(\"http://\") || input.startsWith(\"https://\")) {\n    const u = new URL(input);\n    const proto = u.protocol === \"https:\" ? \"https\" : \"http\";\n    // Keep origin + pathname, drop query/hash.\n    const base = `${u.origin}${u.pathname}`;\n    return { protocol: proto, domainBase: stripV1Suffix(base) };\n  }\n\n  // No scheme: treat as \"host[:port]\" or \"host[:port]/prefix\" and normalize trailing \"/v1\" or \"/\".\n  return { domainBase: stripV1Suffix(input) };\n}\n\nfunction createNodeFetch(): {\n  fetch: typeof fetch;\n  close: () => Promise<void>;\n} {\n  if (!isNodeRuntime()) {\n    return {\n      fetch,\n      close: async () => {\n        // Browser fetch has no pooled dispatcher to close.\n      },\n    };\n  }\n\n  const baseFetch = fetch;\n  let dispatcher: unknown | undefined;\n  let dispatcherPromise: Promise<unknown> | null = null;\n\n  const nodeFetch: typeof fetch = async (input: RequestInfo | URL, init?: RequestInit) => {\n    dispatcherPromise ??= (async () => {\n      try {\n        const mod = await import(\"undici\");\n        const Agent = (mod as { Agent?: new (...args: any[]) => unknown }).Agent;\n        if (!Agent) {\n          return undefined;\n        }\n        dispatcher = new Agent({\n          keepAliveTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,\n          keepAliveMaxTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,\n        });\n        return dispatcher;\n      } catch {\n        return undefined;\n      }\n    })();\n\n    if (dispatcherPromise) {\n      await dispatcherPromise;\n    }\n\n    if (dispatcher) {\n      const mergedInit = { ...(init ?? {}), dispatcher } as RequestInit & {\n        dispatcher?: unknown;\n      };\n      return baseFetch(input, mergedInit as RequestInit);\n    }\n\n    return baseFetch(input, init);\n  };\n\n  return {\n    fetch: nodeFetch,\n    close: async () => {\n      if (dispatcherPromise) {\n        await dispatcherPromise.catch(() => undefined);\n      }\n      if (\n        dispatcher &&\n        typeof dispatcher === \"object\" &&\n        typeof (dispatcher as any).close === \"function\"\n      ) {\n        try {\n          await (dispatcher as any).close();\n        } catch {\n          // swallow close errors\n        }\n      }\n    },\n  };\n}\n\nfunction createTimedFetch(opts: {\n  baseFetch: typeof fetch;\n  timeoutSeconds: number;\n  debug: boolean;\n  defaultHeaders?: Record<string, string>;\n  label: string;\n}): typeof fetch {\n  const baseFetch = opts.baseFetch;\n  const timeoutSeconds = opts.timeoutSeconds;\n  const debug = opts.debug;\n  const defaultHeaders = opts.defaultHeaders ?? {};\n  const label = opts.label;\n\n  return async (input: RequestInfo | URL, init?: RequestInit) => {\n    const method = init?.method ?? \"GET\";\n    const url =\n      typeof input === \"string\"\n        ? input\n        : (input as any)?.toString?.() ?? String(input);\n\n    const ac = new AbortController();\n    const timeoutMs = Math.floor(timeoutSeconds * 1000);\n    const t =\n      Number.isFinite(timeoutMs) && timeoutMs > 0\n        ? setTimeout(\n            () =>\n              ac.abort(\n                new Error(\n                  `[${label}] Request timed out (timeoutSeconds=${timeoutSeconds})`\n                )\n              ),\n            timeoutMs\n          )\n        : undefined;\n\n    const onAbort = () =>\n      ac.abort((init?.signal as any)?.reason ?? new Error(\"Aborted\"));\n    if (init?.signal) {\n      if (init.signal.aborted) onAbort();\n      else\n        init.signal.addEventListener(\"abort\", onAbort, { once: true } as any);\n    }\n\n    const mergedInit: RequestInit = {\n      ...init,\n      signal: ac.signal,\n    };\n\n    if (debug) {\n      const mergedHeaders = {\n        ...defaultHeaders,\n        ...((init?.headers ?? {}) as any),\n      };\n      // eslint-disable-next-line no-console\n      console.log(\n        `[opensandbox:${label}] ->`,\n        method,\n        url,\n        redactHeaders(mergedHeaders)\n      );\n    }\n\n    try {\n      const res = await baseFetch(input, mergedInit);\n      if (debug) {\n        // eslint-disable-next-line no-console\n        console.log(`[opensandbox:${label}] <-`, method, url, res.status);\n      }\n      return res;\n    } finally {\n      if (t) clearTimeout(t);\n      if (init?.signal)\n        init.signal.removeEventListener(\"abort\", onAbort as any);\n    }\n  };\n}\n\nexport class ConnectionConfig {\n  readonly protocol: ConnectionProtocol;\n  readonly domain: string;\n  readonly apiKey?: string;\n  readonly headers: Record<string, string>;\n  private _fetch: typeof fetch | null;\n  private _sseFetch: typeof fetch | null;\n  readonly requestTimeoutSeconds: number;\n  readonly debug: boolean;\n  readonly userAgent: string = DEFAULT_USER_AGENT;\n  /**\n   * Use sandbox server as proxy for endpoint requests (default false).\n   */\n  readonly useServerProxy: boolean;\n  private _closeTransport: () => Promise<void>;\n  private _closePromise: Promise<void> | null = null;\n  private _transportInitialized = false;\n\n  /**\n   * Create a connection configuration.\n   *\n   * Environment variables (optional):\n   * - `OPEN_SANDBOX_DOMAIN` (default: `localhost:8080`)\n   * - `OPEN_SANDBOX_API_KEY`\n   */\n  constructor(opts: ConnectionConfigOptions = {}) {\n    const envDomain = readEnv(\"OPEN_SANDBOX_DOMAIN\");\n    const envApiKey = readEnv(\"OPEN_SANDBOX_API_KEY\");\n\n    const rawDomain = opts.domain ?? envDomain ?? \"localhost:8080\";\n    const normalized = normalizeDomainBase(rawDomain);\n\n    // If the domain includes a scheme, it overrides `protocol`.\n    this.protocol = normalized.protocol ?? opts.protocol ?? \"http\";\n    this.domain = normalized.domainBase;\n    this.apiKey = opts.apiKey ?? envApiKey;\n    this.requestTimeoutSeconds =\n      typeof opts.requestTimeoutSeconds === \"number\"\n        ? opts.requestTimeoutSeconds\n        : 30;\n    this.debug = !!opts.debug;\n    this.useServerProxy = !!opts.useServerProxy;\n\n    const headers: Record<string, string> = { ...(opts.headers ?? {}) };\n    // Attach API key via header unless the user already provided one.\n    if (this.apiKey && !headers[\"OPEN-SANDBOX-API-KEY\"]) {\n      headers[\"OPEN-SANDBOX-API-KEY\"] = this.apiKey;\n    }\n    // Best-effort user-agent (Node only).\n    if (\n      isNodeRuntime() &&\n      this.userAgent &&\n      !headers[\"user-agent\"] &&\n      !headers[\"User-Agent\"]\n    ) {\n      headers[\"user-agent\"] = this.userAgent;\n    }\n    this.headers = headers;\n    this._fetch = null;\n    this._sseFetch = null;\n    this._closeTransport = async () => {\n      // Init with empty close call\n    };\n    this._transportInitialized = false;\n  }\n\n  get fetch(): typeof fetch {\n    return this._fetch ?? fetch;\n  }\n\n  get sseFetch(): typeof fetch {\n    return this._sseFetch ?? fetch;\n  }\n\n  getBaseUrl(): string {\n    // If `domain` already contains a scheme, treat it as a full base URL prefix.\n    if (\n      this.domain.startsWith(\"http://\") ||\n      this.domain.startsWith(\"https://\")\n    ) {\n      return `${stripV1Suffix(this.domain)}/v1`;\n    }\n    return `${this.protocol}://${stripV1Suffix(this.domain)}/v1`;\n  }\n\n  private initializeTransport(): void {\n    if (this._transportInitialized) return;\n\n    const { fetch: baseFetch, close } = createNodeFetch();\n    this._fetch = createTimedFetch({\n      baseFetch,\n      timeoutSeconds: this.requestTimeoutSeconds,\n      debug: this.debug,\n      defaultHeaders: this.headers,\n      label: \"http\",\n    });\n    this._sseFetch = createTimedFetch({\n      baseFetch,\n      timeoutSeconds: 0,\n      debug: this.debug,\n      defaultHeaders: this.headers,\n      label: \"sse\",\n    });\n    this._closeTransport = close;\n    this._transportInitialized = true;\n  }\n  /**\n   * Ensure this configuration has transport helpers (fetch/SSE) allocated.\n   *\n   * On Node.js this creates a dedicated `undici` dispatcher; on browsers it\n   * simply reuses the global fetch. Returns either `this` or a cloned config\n   * with the transport initialized.\n   */\n  withTransportIfMissing(): ConnectionConfig {\n    if (this._transportInitialized) {\n      return this;\n    }\n\n    const clone = new ConnectionConfig({\n      domain: this.domain,\n      protocol: this.protocol,\n      apiKey: this.apiKey,\n      headers: { ...this.headers },\n      requestTimeoutSeconds: this.requestTimeoutSeconds,\n      debug: this.debug,\n      useServerProxy: this.useServerProxy,\n    });\n    clone.initializeTransport();\n    return clone;\n  }\n\n  /**\n   * Close the Node.js agent owned by this configuration.\n   */\n  async closeTransport(): Promise<void> {\n    if (!this._transportInitialized) return;\n    this._closePromise ??= this._closeTransport();\n    await this._closePromise;\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/core/constants.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport const DEFAULT_EXECD_PORT = 44772;\nexport const DEFAULT_EGRESS_PORT = 18080;\n\nexport const DEFAULT_ENTRYPOINT: string[] = [\"tail\", \"-f\", \"/dev/null\"];\n\nexport const DEFAULT_RESOURCE_LIMITS: Record<string, string> = {\n  cpu: \"1\",\n  memory: \"2Gi\",\n};\n\nexport const DEFAULT_TIMEOUT_SECONDS = 600;\nexport const DEFAULT_READY_TIMEOUT_SECONDS = 30;\nexport const DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS = 200;\n\nexport const DEFAULT_REQUEST_TIMEOUT_SECONDS = 30;\nexport const DEFAULT_USER_AGENT = \"OpenSandbox-JS-SDK/0.1.5\";\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/core/exceptions.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport type SandboxErrorCode =\n  | \"INTERNAL_UNKNOWN_ERROR\"\n  | \"READY_TIMEOUT\"\n  | \"UNHEALTHY\"\n  | \"INVALID_ARGUMENT\"\n  | \"UNEXPECTED_RESPONSE\"\n  // Allow server-defined codes as well.\n  | (string & {});\n\n/**\n * Structured error payload carried by {@link SandboxException}.\n *\n * - `code`: stable programmatic identifier\n * - `message`: optional human-readable message\n */\nexport class SandboxError {\n  static readonly INTERNAL_UNKNOWN_ERROR: SandboxErrorCode = \"INTERNAL_UNKNOWN_ERROR\";\n  static readonly READY_TIMEOUT: SandboxErrorCode = \"READY_TIMEOUT\";\n  static readonly UNHEALTHY: SandboxErrorCode = \"UNHEALTHY\";\n  static readonly INVALID_ARGUMENT: SandboxErrorCode = \"INVALID_ARGUMENT\";\n  static readonly UNEXPECTED_RESPONSE: SandboxErrorCode = \"UNEXPECTED_RESPONSE\";\n\n  constructor(\n    readonly code: SandboxErrorCode,\n    readonly message?: string,\n  ) {}\n}\n\ninterface SandboxExceptionOpts {\n  message?: string;\n  cause?: unknown;\n  error?: SandboxError;\n  requestId?: string;\n}\n\n/**\n * Base exception class for all SDK errors.\n *\n * All errors thrown by this SDK are subclasses of {@link SandboxException}.\n */\nexport class SandboxException extends Error {\n  readonly name: string = \"SandboxException\";\n  readonly error: SandboxError;\n  readonly cause?: unknown;\n  readonly requestId?: string;\n\n  constructor(opts: SandboxExceptionOpts = {}) {\n    super(opts.message);\n    this.cause = opts.cause;\n    this.error = opts.error ?? new SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR);\n    this.requestId = opts.requestId;\n  }\n}\n\nexport class SandboxApiException extends SandboxException {\n  readonly name: string = \"SandboxApiException\";\n  readonly statusCode?: number;\n  readonly rawBody?: unknown;\n\n  constructor(opts: SandboxExceptionOpts & {\n    statusCode?: number;\n    rawBody?: unknown;\n  }) {\n    super({\n      message: opts.message,\n      cause: opts.cause,\n      error: opts.error ?? new SandboxError(SandboxError.UNEXPECTED_RESPONSE, opts.message),\n      requestId: opts.requestId,\n    });\n    this.statusCode = opts.statusCode;\n    this.rawBody = opts.rawBody;\n  }\n}\n\nexport class SandboxInternalException extends SandboxException {\n  readonly name: string = \"SandboxInternalException\";\n\n  constructor(opts: { message?: string; cause?: unknown }) {\n    super({\n      message: opts.message,\n      cause: opts.cause,\n      error: new SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR, opts.message),\n    });\n  }\n}\n\nexport class SandboxUnhealthyException extends SandboxException {\n  readonly name: string = \"SandboxUnhealthyException\";\n\n  constructor(opts: { message?: string; cause?: unknown }) {\n    super({\n      message: opts.message,\n      cause: opts.cause,\n      error: new SandboxError(SandboxError.UNHEALTHY, opts.message),\n    });\n  }\n}\n\nexport class SandboxReadyTimeoutException extends SandboxException {\n  readonly name: string = \"SandboxReadyTimeoutException\";\n\n  constructor(opts: { message?: string; cause?: unknown }) {\n    super({\n      message: opts.message,\n      cause: opts.cause,\n      error: new SandboxError(SandboxError.READY_TIMEOUT, opts.message),\n    });\n  }\n}\n\nexport class InvalidArgumentException extends SandboxException {\n  readonly name: string = \"InvalidArgumentException\";\n\n  constructor(opts: { message?: string; cause?: unknown }) {\n    super({\n      message: opts.message,\n      cause: opts.cause,\n      error: new SandboxError(SandboxError.INVALID_ARGUMENT, opts.message),\n    });\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/factory/adapterFactory.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ConnectionConfig } from \"../config/connection.js\";\nimport type { SandboxFiles } from \"../services/filesystem.js\";\nimport type { Egress } from \"../services/egress.js\";\nimport type { ExecdCommands } from \"../services/execdCommands.js\";\nimport type { ExecdHealth } from \"../services/execdHealth.js\";\nimport type { ExecdMetrics } from \"../services/execdMetrics.js\";\nimport type { Sandboxes } from \"../services/sandboxes.js\";\n\nexport interface CreateLifecycleStackOptions {\n  connectionConfig: ConnectionConfig;\n  lifecycleBaseUrl: string;\n}\n\nexport interface LifecycleStack {\n  sandboxes: Sandboxes;\n}\n\nexport interface CreateExecdStackOptions {\n  connectionConfig: ConnectionConfig;\n  execdBaseUrl: string;\n  endpointHeaders?: Record<string, string>;\n}\n\nexport interface ExecdStack {\n  commands: ExecdCommands;\n  files: SandboxFiles;\n  health: ExecdHealth;\n  metrics: ExecdMetrics;\n}\n\nexport interface CreateEgressStackOptions {\n  connectionConfig: ConnectionConfig;\n  egressBaseUrl: string;\n  endpointHeaders?: Record<string, string>;\n}\n\nexport interface EgressStack {\n  egress: Egress;\n}\n\n/**\n * Factory abstraction to keep `Sandbox` and `SandboxManager` decoupled from concrete adapter implementations.\n *\n * This is primarily useful for advanced integrations (custom transports, dependency injection, testing).\n */\nexport interface AdapterFactory {\n  createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack;\n  createExecdStack(opts: CreateExecdStackOptions): ExecdStack;\n  createEgressStack(opts: CreateEgressStackOptions): EgressStack;\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { createExecdClient } from \"../openapi/execdClient.js\";\nimport { createEgressClient } from \"../openapi/egressClient.js\";\nimport { createLifecycleClient } from \"../openapi/lifecycleClient.js\";\n\nimport { CommandsAdapter } from \"../adapters/commandsAdapter.js\";\nimport { EgressAdapter } from \"../adapters/egressAdapter.js\";\nimport { FilesystemAdapter } from \"../adapters/filesystemAdapter.js\";\nimport { HealthAdapter } from \"../adapters/healthAdapter.js\";\nimport { MetricsAdapter } from \"../adapters/metricsAdapter.js\";\nimport { SandboxesAdapter } from \"../adapters/sandboxesAdapter.js\";\n\nimport type {\n  AdapterFactory,\n  CreateEgressStackOptions,\n  CreateExecdStackOptions,\n  CreateLifecycleStackOptions,\n  EgressStack,\n  ExecdStack,\n  LifecycleStack,\n} from \"./adapterFactory.js\";\n\nexport class DefaultAdapterFactory implements AdapterFactory {\n  createLifecycleStack(opts: CreateLifecycleStackOptions): LifecycleStack {\n    const lifecycleClient = createLifecycleClient({\n      baseUrl: opts.lifecycleBaseUrl,\n      apiKey: opts.connectionConfig.apiKey,\n      headers: opts.connectionConfig.headers,\n      fetch: opts.connectionConfig.fetch,\n    });\n    const sandboxes = new SandboxesAdapter(lifecycleClient);\n    return { sandboxes };\n  }\n\n  createExecdStack(opts: CreateExecdStackOptions): ExecdStack {\n    const headers: Record<string, string> = {\n      ...(opts.connectionConfig.headers ?? {}),\n      ...(opts.endpointHeaders ?? {}),\n    };\n    const execdClient = createExecdClient({\n      baseUrl: opts.execdBaseUrl,\n      headers,\n      fetch: opts.connectionConfig.fetch,\n    });\n\n    const health = new HealthAdapter(execdClient);\n    const metrics = new MetricsAdapter(execdClient);\n    const files = new FilesystemAdapter(execdClient, {\n      baseUrl: opts.execdBaseUrl,\n      fetch: opts.connectionConfig.fetch,\n      headers,\n    });\n    const commands = new CommandsAdapter(execdClient, {\n      baseUrl: opts.execdBaseUrl,\n      fetch: opts.connectionConfig.sseFetch,\n      headers,\n    });\n\n    return {\n      commands,\n      files,\n      health,\n      metrics,\n    };\n  }\n\n  createEgressStack(opts: CreateEgressStackOptions): EgressStack {\n    const headers: Record<string, string> = {\n      ...(opts.connectionConfig.headers ?? {}),\n      ...(opts.endpointHeaders ?? {}),\n    };\n    const egressClient = createEgressClient({\n      baseUrl: opts.egressBaseUrl,\n      headers,\n      fetch: opts.connectionConfig.fetch,\n    });\n    return {\n      egress: new EgressAdapter(egressClient),\n    };\n  }\n}\n\nexport function createDefaultAdapterFactory(): AdapterFactory {\n  return new DefaultAdapterFactory();\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/index.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport {\n  InvalidArgumentException,\n  SandboxApiException,\n  SandboxError,\n  SandboxException,\n  SandboxInternalException,\n  SandboxReadyTimeoutException,\n  SandboxUnhealthyException,\n} from \"./core/exceptions.js\";\n\n// Factory pattern (stable public interface; does NOT expose OpenAPI generated models).\nexport type { AdapterFactory } from \"./factory/adapterFactory.js\";\nexport { DefaultAdapterFactory, createDefaultAdapterFactory } from \"./factory/defaultAdapterFactory.js\";\n\nexport { ConnectionConfig } from \"./config/connection.js\";\nexport type { ConnectionConfigOptions, ConnectionProtocol } from \"./config/connection.js\";\n\nexport type {\n  CreateSandboxRequest,\n  CreateSandboxResponse,\n  Endpoint,\n  Host,\n  ListSandboxesParams,\n  ListSandboxesResponse,\n  NetworkPolicy,\n  NetworkRule,\n  NetworkRuleAction,\n  PVC,\n  RenewSandboxExpirationRequest,\n  RenewSandboxExpirationResponse,\n  SandboxId,\n  SandboxInfo,\n  Volume,\n} from \"./models/sandboxes.js\";\n\nexport type { Sandboxes } from \"./services/sandboxes.js\";\n\nexport { SandboxManager } from \"./manager.js\";\nexport type { SandboxFilter, SandboxManagerOptions } from \"./manager.js\";\n\nexport type { ExecdHealth } from \"./services/execdHealth.js\";\nexport type { ExecdMetrics } from \"./services/execdMetrics.js\";\nexport type {\n  FileInfo,\n  FileMetadata,\n  Permission,\n  RenameFileItem,\n  ReplaceFileContentItem,\n  SearchFilesResponse,\n  FilesInfoResponse,\n} from \"./models/filesystem.js\";\n\nexport type {\n  CommandExecution,\n  CommandLogs,\n  CommandStatus,\n  RunCommandOpts,\n  ServerStreamEvent,\n  CodeContextRequest,\n  SupportedLanguage,\n  Metrics,\n  SandboxMetrics,\n  PingResponse,\n} from \"./models/execd.js\";\nexport type { ExecdCommands } from \"./services/execdCommands.js\";\n\nexport type {\n  Execution,\n  ExecutionComplete,\n  ExecutionError,\n  ExecutionHandlers,\n  ExecutionInit,\n  ExecutionResult,\n  OutputMessage,\n} from \"./models/execution.js\";\nexport { ExecutionEventDispatcher } from \"./models/executionEventDispatcher.js\";\n\nexport {\n  DEFAULT_ENTRYPOINT,\n  DEFAULT_EGRESS_PORT,\n  DEFAULT_EXECD_PORT,\n  DEFAULT_RESOURCE_LIMITS,\n  DEFAULT_TIMEOUT_SECONDS,\n  DEFAULT_READY_TIMEOUT_SECONDS,\n  DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS,\n  DEFAULT_REQUEST_TIMEOUT_SECONDS,\n} from \"./core/constants.js\";\n\nexport type {\n  SandboxConnectOptions,\n  SandboxCreateOptions,\n} from \"./sandbox.js\";\nexport { Sandbox } from \"./sandbox.js\";\n\nexport type {\n  ContentReplaceEntry,\n  MoveEntry,\n  SearchEntry,\n  SetPermissionEntry,\n  WriteEntry,\n} from \"./models/filesystem.js\";\nexport type { SandboxFiles } from \"./services/filesystem.js\";\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/internal.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * INTERNAL / ADVANCED ENTRYPOINT\n *\n * This subpath exposes low-level OpenAPI clients and adapters for advanced integrations.\n * It is intentionally NOT exported from the root entrypoint (`@alibaba-group/opensandbox`),\n * because generated OpenAPI types are not considered stable public API.\n *\n * Import path:\n * - `@alibaba-group/opensandbox/internal`\n */\n\nexport { createLifecycleClient } from \"./openapi/lifecycleClient.js\";\nexport type { LifecycleClient } from \"./openapi/lifecycleClient.js\";\nexport { createExecdClient } from \"./openapi/execdClient.js\";\nexport type { ExecdClient } from \"./openapi/execdClient.js\";\n\n// OpenAPI schema types (NOT stable public API; internal-only).\nexport type { paths as LifecyclePaths } from \"./api/lifecycle.js\";\nexport type { paths as ExecdPaths } from \"./api/execd.js\";\n\nexport { SandboxesAdapter } from \"./adapters/sandboxesAdapter.js\";\nexport { HealthAdapter } from \"./adapters/healthAdapter.js\";\nexport { MetricsAdapter } from \"./adapters/metricsAdapter.js\";\nexport { FilesystemAdapter } from \"./adapters/filesystemAdapter.js\";\nexport { CommandsAdapter } from \"./adapters/commandsAdapter.js\";"
  },
  {
    "path": "sdks/sandbox/javascript/src/manager.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { ConnectionConfig, type ConnectionConfigOptions } from \"./config/connection.js\";\nimport { createDefaultAdapterFactory } from \"./factory/defaultAdapterFactory.js\";\nimport type { AdapterFactory } from \"./factory/adapterFactory.js\";\n\nimport type { ListSandboxesResponse, SandboxId, SandboxInfo } from \"./models/sandboxes.js\";\nimport type { Sandboxes } from \"./services/sandboxes.js\";\n\nexport interface SandboxManagerOptions {\n  /**\n   * Connection configuration for calling the OpenSandbox Lifecycle API.\n   */\n  connectionConfig?: ConnectionConfig | ConnectionConfigOptions;\n  /**\n   * Advanced override: inject a custom adapter factory (custom transports, dependency injection).\n   */\n  adapterFactory?: AdapterFactory;\n}\n\nexport interface SandboxFilter {\n  /**\n   * Filter by sandbox lifecycle states.\n   */\n  states?: string[];\n  /**\n   * Filter by metadata key-value pairs.\n   */\n  metadata?: Record<string, string>;\n  /**\n   * Pagination page number (1-indexed).\n   */\n  page?: number;\n  /**\n   * Number of items per page.\n   */\n  pageSize?: number;\n}\n\n/**\n * Administrative interface for managing sandboxes (list/get/pause/resume/kill/renew).\n *\n * For interacting *inside* a sandbox, use {@link Sandbox}.\n */\nexport class SandboxManager {\n  private readonly sandboxes: Sandboxes;\n  private readonly connectionConfig: ConnectionConfig;\n\n  private constructor(opts: { sandboxes: Sandboxes; connectionConfig: ConnectionConfig }) {\n    this.sandboxes = opts.sandboxes;\n    this.connectionConfig = opts.connectionConfig;\n  }\n\n  static create(opts: SandboxManagerOptions = {}): SandboxManager {\n    const baseConnectionConfig = opts.connectionConfig instanceof ConnectionConfig\n      ? opts.connectionConfig\n      : new ConnectionConfig(opts.connectionConfig);\n    const connectionConfig = baseConnectionConfig.withTransportIfMissing();\n    const lifecycleBaseUrl = connectionConfig.getBaseUrl();\n    const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory();\n    let sandboxes: Sandboxes;\n    try {\n      sandboxes = adapterFactory.createLifecycleStack({\n        connectionConfig,\n        lifecycleBaseUrl,\n      }).sandboxes;\n    } catch (err) {\n      void connectionConfig.closeTransport().catch(() => undefined);\n      throw err;\n    }\n    return new SandboxManager({ sandboxes, connectionConfig });\n  }\n\n  listSandboxInfos(filter: SandboxFilter = {}): Promise<ListSandboxesResponse> {\n    return this.sandboxes.listSandboxes({\n      states: filter.states,\n      metadata: filter.metadata,\n      page: filter.page,\n      pageSize: filter.pageSize,\n    });\n  }\n\n  getSandboxInfo(sandboxId: SandboxId): Promise<SandboxInfo> {\n    return this.sandboxes.getSandbox(sandboxId);\n  }\n\n  killSandbox(sandboxId: SandboxId): Promise<void> {\n    return this.sandboxes.deleteSandbox(sandboxId);\n  }\n\n  pauseSandbox(sandboxId: SandboxId): Promise<void> {\n    return this.sandboxes.pauseSandbox(sandboxId);\n  }\n\n  resumeSandbox(sandboxId: SandboxId): Promise<void> {\n    return this.sandboxes.resumeSandbox(sandboxId);\n  }\n\n  /**\n   * Renew expiration by setting expiresAt to now + timeoutSeconds.\n   */\n  async renewSandbox(sandboxId: SandboxId, timeoutSeconds: number): Promise<void> {\n    const expiresAt = new Date(Date.now() + timeoutSeconds * 1000).toISOString();\n    await this.sandboxes.renewSandboxExpiration(sandboxId, { expiresAt });\n  }\n\n  /**\n   * Release the HTTP agent resources allocated for this manager instance.\n   *\n   * Each manager clone owns a scoped `ConnectionConfig` clone.\n   *\n   * This mirrors the Python SDK's default transport lifecycle.\n   */\n  async close(): Promise<void> {\n    await this.connectionConfig.closeTransport();\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/models/execd.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { Execution } from \"./execution.js\";\n\n/**\n * Domain models for execd interactions.\n *\n * IMPORTANT:\n * - These are NOT OpenAPI-generated types.\n * - They are intentionally stable and JS-friendly.\n */\nexport interface ServerStreamEvent extends Record<string, unknown> {\n  type:\n    | \"init\"\n    | \"stdout\"\n    | \"stderr\"\n    | \"result\"\n    | \"execution_count\"\n    | \"execution_complete\"\n    | \"error\"\n    | string;\n  timestamp?: number;\n  text?: string;\n  results?: Record<string, unknown>;\n  error?: Record<string, unknown>;\n}\n\nexport interface CodeContextRequest extends Record<string, unknown> {\n  language: string;\n}\n\nexport type SupportedLanguage =\n  | \"python\"\n  | \"go\"\n  | \"javascript\"\n  | \"typescript\"\n  | \"bash\"\n  | \"java\";\n\nexport interface RunCommandOpts {\n  /**\n   * Working directory for command execution (maps to API `cwd`).\n   */\n  workingDirectory?: string;\n  /**\n   * Run command in detached mode.\n   */\n  background?: boolean;\n  /**\n   * Maximum execution time in seconds; server will terminate the command when reached.\n   * If omitted, the server will not enforce any timeout.\n   */\n  timeoutSeconds?: number;\n  /**\n   * Unix user ID used to run the command process.\n   */\n  uid?: number;\n  /**\n   * Unix group ID used to run the command process. Requires `uid`.\n   */\n  gid?: number;\n  /**\n   * Environment variables injected into the command process.\n   */\n  envs?: Record<string, string>;\n}\n\nexport interface CommandStatus {\n  id?: string;\n  content?: string;\n  running?: boolean;\n  exitCode?: number | null;\n  error?: string;\n  startedAt?: Date;\n  finishedAt?: Date | null;\n}\n\nexport interface CommandLogs {\n  content: string;\n  cursor?: number;\n}\n\nexport type CommandExecution = Execution;\n\nexport interface Metrics extends Record<string, unknown> {\n  cpu_count?: number;\n  cpu_used_pct?: number;\n  mem_total_mib?: number;\n  mem_used_mib?: number;\n  timestamp?: number;\n}\n\n/**\n * Normalized, JS-friendly metrics.\n */\nexport interface SandboxMetrics {\n  cpuCount: number;\n  cpuUsedPercentage: number;\n  memoryTotalMiB: number;\n  memoryUsedMiB: number;\n  timestamp: number;\n}\n\nexport type PingResponse = Record<string, unknown>;"
  },
  {
    "path": "sdks/sandbox/javascript/src/models/execution.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport interface OutputMessage {\n  text: string;\n  timestamp: number;\n  isError?: boolean;\n}\n\nexport interface ExecutionResult {\n  text?: string;\n  timestamp: number;\n  /**\n   * Raw mime map from execd event (e.g. \"text/plain\", \"text/html\", ...)\n   */\n  raw?: Record<string, unknown>;\n}\n\nexport interface ExecutionError {\n  name: string;\n  value: string;\n  timestamp: number;\n  traceback: string[];\n}\n\nexport interface ExecutionComplete {\n  timestamp: number;\n  executionTimeMs: number;\n}\n\nexport interface ExecutionInit {\n  id: string;\n  timestamp: number;\n}\n\nexport interface Execution {\n  id?: string;\n  executionCount?: number;\n  logs: {\n    stdout: OutputMessage[];\n    stderr: OutputMessage[];\n  };\n  result: ExecutionResult[];\n  error?: ExecutionError;\n  complete?: ExecutionComplete;\n}\n\nexport interface ExecutionHandlers {\n  /**\n   * Optional low-level hook for every server-sent event (SSE) received.\n   * Kept as `unknown` to avoid coupling to a specific OpenAPI schema module.\n   */\n  onEvent?: (ev: unknown) => void | Promise<void>;\n  onStdout?: (msg: OutputMessage) => void | Promise<void>;\n  onStderr?: (msg: OutputMessage) => void | Promise<void>;\n  onResult?: (res: ExecutionResult) => void | Promise<void>;\n  onExecutionComplete?: (c: ExecutionComplete) => void | Promise<void>;\n  onError?: (err: ExecutionError) => void | Promise<void>;\n  onInit?: (init: ExecutionInit) => void | Promise<void>;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/models/executionEventDispatcher.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { Execution, ExecutionComplete, ExecutionError, ExecutionHandlers, ExecutionInit, ExecutionResult, OutputMessage } from \"./execution.js\";\nimport type { ServerStreamEvent } from \"./execd.js\";\n\nfunction extractText(results: ServerStreamEvent[\"results\"] | undefined): string | undefined {\n  if (!results || typeof results !== \"object\") return undefined;\n  const r = results as any;\n  const v = r[\"text/plain\"] ?? r.text ?? r.textPlain;\n  return v == null ? undefined : String(v);\n}\n\n/**\n * Dispatches streamed execution events to handlers.\n *\n * This mutates the provided `execution` object (appending logs/results and setting fields like\n * `id`, `executionCount`, and `complete`) and invokes optional callbacks in {@link ExecutionHandlers}.\n */\nexport class ExecutionEventDispatcher {\n  constructor(\n    private readonly execution: Execution,\n    private readonly handlers?: ExecutionHandlers,\n  ) {}\n\n  async dispatch(ev: ServerStreamEvent): Promise<void> {\n    await this.handlers?.onEvent?.(ev);\n\n    const ts = ev.timestamp ?? Date.now();\n    switch (ev.type) {\n      case \"init\": {\n        const id = ev.text ?? \"\";\n        if (id) this.execution.id = id;\n        const init: ExecutionInit = { id, timestamp: ts };\n        await this.handlers?.onInit?.(init);\n        return;\n      }\n      case \"stdout\": {\n        const msg: OutputMessage = { text: ev.text ?? \"\", timestamp: ts, isError: false };\n        this.execution.logs.stdout.push(msg);\n        await this.handlers?.onStdout?.(msg);\n        return;\n      }\n      case \"stderr\": {\n        const msg: OutputMessage = { text: ev.text ?? \"\", timestamp: ts, isError: true };\n        this.execution.logs.stderr.push(msg);\n        await this.handlers?.onStderr?.(msg);\n        return;\n      }\n      case \"result\": {\n        const r: ExecutionResult = { text: extractText(ev.results), timestamp: ts, raw: ev.results as any };\n        this.execution.result.push(r);\n        await this.handlers?.onResult?.(r);\n        return;\n      }\n      case \"execution_count\": {\n        const c = (ev as any).execution_count;\n        if (typeof c === \"number\") this.execution.executionCount = c;\n        return;\n      }\n      case \"execution_complete\": {\n        const ms = (ev as any).execution_time;\n        const complete: ExecutionComplete = { timestamp: ts, executionTimeMs: typeof ms === \"number\" ? ms : 0 };\n        this.execution.complete = complete;\n        await this.handlers?.onExecutionComplete?.(complete);\n        return;\n      }\n      case \"error\": {\n        const e = ev.error as any;\n        if (e) {\n          const err: ExecutionError = {\n            name: String(e.ename ?? e.name ?? \"\"),\n            value: String(e.evalue ?? e.value ?? \"\"),\n            timestamp: ts,\n            traceback: Array.isArray(e.traceback) ? e.traceback.map(String) : [],\n          };\n          this.execution.error = err;\n          await this.handlers?.onError?.(err);\n        }\n        return;\n      }\n      default:\n        return;\n    }\n  }\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/models/filesystem.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * Domain models for filesystem.\n *\n * IMPORTANT:\n * - These are NOT OpenAPI-generated types.\n * - They are intentionally stable and JS-friendly.\n */\n\nexport interface FileInfo extends Record<string, unknown> {\n  path: string;\n  size?: number;\n  /**\n   * Last modification time.\n   */\n  modifiedAt?: Date;\n  /**\n   * Creation time.\n   */\n  createdAt?: Date;\n  mode?: number;\n  owner?: string;\n  group?: string;\n}\n\nexport interface Permission extends Record<string, unknown> {\n  mode: number;\n  owner?: string;\n  group?: string;\n}\n\nexport interface FileMetadata extends Record<string, unknown> {\n  path: string;\n  mode?: number;\n  owner?: string;\n  group?: string;\n}\n\nexport interface RenameFileItem extends Record<string, unknown> {\n  src: string;\n  dest: string;\n}\n\nexport interface ReplaceFileContentItem extends Record<string, unknown> {\n  old: string;\n  new: string;\n}\n\nexport type FilesInfoResponse = Record<string, FileInfo>;\n\nexport type SearchFilesResponse = FileInfo[];\n\n// High-level filesystem facade models used by `sandbox.files`.\nexport interface WriteEntry {\n  path: string;\n  /**\n   * File data to upload.\n   *\n   * Supports:\n   * - string / bytes / Blob (in-memory)\n   * - AsyncIterable<Uint8Array> or ReadableStream<Uint8Array> (streaming upload for large files)\n   */\n  data?: string | Uint8Array | ArrayBuffer | Blob | AsyncIterable<Uint8Array> | ReadableStream<Uint8Array>;\n  mode?: number;\n  owner?: string;\n  group?: string;\n}\n\nexport interface SearchEntry {\n  path: string;\n  pattern?: string;\n}\n\nexport interface MoveEntry {\n  src: string;\n  dest: string;\n}\n\nexport interface ContentReplaceEntry {\n  path: string;\n  oldContent: string;\n  newContent: string;\n}\n\nexport interface SetPermissionEntry {\n  path: string;\n  mode: number;\n  owner?: string;\n  group?: string;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/models/sandboxes.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/**\n * Domain models for sandbox lifecycle.\n *\n * IMPORTANT:\n * - These are NOT OpenAPI-generated types.\n * - They are intentionally stable and JS-friendly.\n *\n * The internal OpenAPI schemas may change frequently; adapters map responses into these models.\n */\n\nexport type SandboxId = string;\n\nexport interface ImageAuth extends Record<string, unknown> {\n  username?: string;\n  password?: string;\n  token?: string;\n}\n\nexport interface ImageSpec {\n  uri: string;\n  auth?: ImageAuth;\n}\n\nexport type ResourceLimits = Record<string, string>;\n\nexport type NetworkRuleAction = \"allow\" | \"deny\";\n\nexport interface NetworkRule extends Record<string, unknown> {\n  /**\n   * Whether to allow or deny matching targets.\n   */\n  action: NetworkRuleAction;\n  /**\n   * FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n   * IP/CIDR is not supported in the egress MVP.\n   */\n  target: string;\n}\n\nexport interface NetworkPolicy extends Record<string, unknown> {\n  /**\n   * Default action when no egress rule matches. Defaults to \"deny\".\n   */\n  defaultAction?: NetworkRuleAction;\n  /**\n   * List of egress rules evaluated in order.\n   */\n  egress?: NetworkRule[];\n}\n\n// ============================================================================\n// Volume Models\n// ============================================================================\n\n/**\n * Host path bind mount backend.\n *\n * Maps a directory on the host filesystem into the container.\n * Only available when the runtime supports host mounts.\n */\nexport interface Host extends Record<string, unknown> {\n  /**\n   * Absolute path on the host filesystem to mount.\n   */\n  path: string;\n}\n\n/**\n * Kubernetes PersistentVolumeClaim mount backend.\n *\n * References an existing PVC in the same namespace as the sandbox pod.\n * Only available in Kubernetes runtime.\n */\nexport interface PVC extends Record<string, unknown> {\n  /**\n   * Name of the PersistentVolumeClaim in the same namespace.\n   */\n  claimName: string;\n}\n\n/**\n * Storage mount definition for a sandbox.\n *\n * Each volume entry contains:\n * - A unique name identifier\n * - Exactly one backend (host, pvc) with backend-specific fields\n * - Common mount settings (mountPath, readOnly, subPath)\n */\nexport interface Volume extends Record<string, unknown> {\n  /**\n   * Unique identifier for the volume within the sandbox.\n   */\n  name: string;\n  /**\n   * Host path bind mount backend (mutually exclusive with pvc).\n   */\n  host?: Host;\n  /**\n   * Kubernetes PVC mount backend (mutually exclusive with host).\n   */\n  pvc?: PVC;\n  /**\n   * Absolute path inside the container where the volume is mounted.\n   */\n  mountPath: string;\n  /**\n   * If true, the volume is mounted as read-only. Defaults to false (read-write).\n   */\n  readOnly?: boolean;\n  /**\n   * Optional subdirectory under the backend path to mount.\n   */\n  subPath?: string;\n}\n\nexport type SandboxState =\n  | \"Creating\"\n  | \"Running\"\n  | \"Pausing\"\n  | \"Paused\"\n  | \"Resuming\"\n  | \"Deleting\"\n  | \"Deleted\"\n  | \"Error\"\n  | string;\n\nexport interface SandboxStatus extends Record<string, unknown> {\n  state: SandboxState;\n  reason?: string;\n  message?: string;\n}\n\nexport interface SandboxInfo extends Record<string, unknown> {\n  id: SandboxId;\n  image: ImageSpec;\n  entrypoint: string[];\n  metadata?: Record<string, string>;\n  status: SandboxStatus;\n  /**\n   * Sandbox creation time.\n   */\n  createdAt: Date;\n  /**\n   * Sandbox expiration time (server-side TTL).\n   */\n  expiresAt: Date | null;\n}\n\nexport interface CreateSandboxRequest extends Record<string, unknown> {\n  image: ImageSpec;\n  entrypoint: string[];\n  /**\n   * Timeout in seconds (server semantics).\n   */\n  timeout?: number | null;\n  resourceLimits: ResourceLimits;\n  env?: Record<string, string>;\n  metadata?: Record<string, string>;\n  /**\n   * Optional outbound network policy for the sandbox.\n   */\n  networkPolicy?: NetworkPolicy;\n  /**\n   * Optional list of volume mounts for persistent storage.\n   */\n  volumes?: Volume[];\n  extensions?: Record<string, unknown>;\n}\n\nexport interface CreateSandboxResponse extends Record<string, unknown> {\n  id: SandboxId;\n  status: SandboxStatus;\n  metadata?: Record<string, string>;\n  /**\n   * Sandbox expiration time after creation.\n   */\n  expiresAt: Date | null;\n  /**\n   * Sandbox creation time.\n   */\n  createdAt: Date;\n  entrypoint: string[];\n}\n\nexport interface PaginationInfo extends Record<string, unknown> {\n  page: number;\n  pageSize: number;\n  totalItems: number;\n  totalPages: number;\n  hasNextPage: boolean;\n}\n\nexport interface ListSandboxesResponse extends Record<string, unknown> {\n  items: SandboxInfo[];\n  pagination?: PaginationInfo;\n}\n\nexport interface RenewSandboxExpirationRequest {\n  expiresAt: string;\n}\n\nexport interface RenewSandboxExpirationResponse extends Record<string, unknown> {\n  /**\n   * Updated expiration time (if the server returns it).\n   */\n  expiresAt?: Date;\n}\n\nexport interface Endpoint extends Record<string, unknown> {\n  endpoint: string;\n  /**\n   * Headers that must be included on every request targeting this endpoint\n   * (e.g. when the server requires them for routing or auth). Omit or empty if not required.\n   */\n  headers?: Record<string, string>;\n}\n\nexport interface ListSandboxesParams {\n  /**\n   * Filter by lifecycle state (the API supports multiple `state` query params).\n   * Example: `{ states: [\"Running\", \"Paused\"] }`\n   */\n  states?: string[];\n  /**\n   * Filter by metadata key-value pairs.\n   * NOTE: This will be encoded to a single `metadata` query parameter as described in the spec.\n   */\n  metadata?: Record<string, string>;\n  page?: number;\n  pageSize?: number;\n};\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/openapi/egressClient.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport createClient from \"openapi-fetch\";\nimport type { Client } from \"openapi-fetch\";\n\nimport type { paths as EgressPaths } from \"../api/egress.js\";\n\nexport type EgressClient = Client<EgressPaths>;\n\nexport interface CreateEgressClientOptions {\n  /**\n   * Base URL to the sandbox egress sidecar API.\n   */\n  baseUrl: string;\n  /**\n   * Extra headers applied to every request.\n   */\n  headers?: Record<string, string>;\n  /**\n   * Custom fetch implementation.\n   */\n  fetch?: typeof fetch;\n}\n\nexport function createEgressClient(opts: CreateEgressClientOptions): EgressClient {\n  const createClientFn =\n    (createClient as unknown as { default?: typeof createClient }).default ?? createClient;\n  return createClientFn<EgressPaths>({\n    baseUrl: opts.baseUrl,\n    headers: opts.headers,\n    fetch: opts.fetch,\n  });\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/openapi/execdClient.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport createClient from \"openapi-fetch\";\nimport type { Client } from \"openapi-fetch\";\n\nimport type { paths as ExecdPaths } from \"../api/execd.js\";\n\nexport type ExecdClient = Client<ExecdPaths>;\n\nexport interface CreateExecdClientOptions {\n  /**\n   * Base URL to the Execd API (no `/v1` prefix).\n   * Examples:\n   * - `http://localhost:44772`\n   * - `http://api.opensandbox.io/sandboxes/<id>/port/44772`\n   */\n  baseUrl: string;\n  /**\n   * Extra headers applied to every request.\n   */\n  headers?: Record<string, string>;\n  /**\n   * Custom fetch implementation.\n   *\n   * Useful for proxies, custom TLS, request tracing, retries, or running in environments\n   * where a global `fetch` is not available.\n   */\n  fetch?: typeof fetch;\n}\n\nexport function createExecdClient(opts: CreateExecdClientOptions): ExecdClient {\n  const createClientFn =\n      (createClient as unknown as { default?: typeof createClient }).default ?? createClient;\n  return createClientFn<ExecdPaths>({\n    baseUrl: opts.baseUrl,\n    headers: opts.headers,\n    fetch: opts.fetch,\n  });\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/openapi/lifecycleClient.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport createClient from \"openapi-fetch\";\nimport type { Client } from \"openapi-fetch\";\n\nimport type { paths as LifecyclePaths } from \"../api/lifecycle.js\";\n\nexport type LifecycleClient = Client<LifecyclePaths>;\n\nexport interface CreateLifecycleClientOptions {\n  /**\n   * Base URL to OpenSandbox Lifecycle API, including the `/v1` prefix.\n   * Example: `http://localhost:8080/v1`\n   */\n  baseUrl?: string;\n  /**\n   * API key for `OPEN-SANDBOX-API-KEY` header.\n   * If omitted, reads from `process.env.OPEN_SANDBOX_API_KEY` when available.\n   */\n  apiKey?: string;\n  /**\n   * Extra headers applied to every request.\n   */\n  headers?: Record<string, string>;\n  /**\n   * Custom fetch implementation.\n   *\n   * Useful for proxies, custom TLS, request tracing, retries, or running in environments\n   * where a global `fetch` is not available.\n   */\n  fetch?: typeof fetch;\n}\n\nfunction readEnvApiKey(): string | undefined {\n  // Avoid requiring @types/node by not referencing `process` directly.\n  // In Node, `globalThis.process.env` exists; in browsers it won't.\n  const env = (globalThis as any)?.process?.env;\n  const v = env?.OPEN_SANDBOX_API_KEY;\n  return typeof v === \"string\" && v.length ? v : undefined;\n}\n\nexport function createLifecycleClient(opts: CreateLifecycleClientOptions = {}): LifecycleClient {\n  const apiKey = opts.apiKey ?? readEnvApiKey();\n\n  const headers: Record<string, string> = {\n    ...(opts.headers ?? {}),\n  };\n\n  if (apiKey && !headers[\"OPEN-SANDBOX-API-KEY\"]) {\n    headers[\"OPEN-SANDBOX-API-KEY\"] = apiKey;\n  }\n\n  const createClientFn =\n      (createClient as unknown as { default?: typeof createClient }).default ?? createClient;\n  return createClientFn<LifecyclePaths>({\n    baseUrl: opts.baseUrl ?? \"http://localhost:8080/v1\",\n    headers,\n    fetch: opts.fetch,\n  });\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/sandbox.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport {\n  DEFAULT_ENTRYPOINT,\n  DEFAULT_EGRESS_PORT,\n  DEFAULT_EXECD_PORT,\n  DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS,\n  DEFAULT_READY_TIMEOUT_SECONDS,\n  DEFAULT_RESOURCE_LIMITS,\n  DEFAULT_TIMEOUT_SECONDS,\n} from \"./core/constants.js\";\nimport { ConnectionConfig, type ConnectionConfigOptions } from \"./config/connection.js\";\nimport type { SandboxFiles } from \"./services/filesystem.js\";\nimport type { Egress } from \"./services/egress.js\";\nimport { createDefaultAdapterFactory } from \"./factory/defaultAdapterFactory.js\";\nimport type { AdapterFactory } from \"./factory/adapterFactory.js\";\n\nimport type { Sandboxes } from \"./services/sandboxes.js\";\nimport type { ExecdCommands } from \"./services/execdCommands.js\";\nimport type { ExecdHealth } from \"./services/execdHealth.js\";\nimport type { ExecdMetrics } from \"./services/execdMetrics.js\";\nimport type {\n  CreateSandboxRequest,\n  Endpoint,\n  NetworkPolicy,\n  NetworkRule,\n  RenewSandboxExpirationResponse,\n  SandboxId,\n  SandboxInfo,\n  Volume,\n} from \"./models/sandboxes.js\";\nimport { SandboxReadyTimeoutException } from \"./core/exceptions.js\";\n\nexport interface SandboxCreateOptions {\n  /**\n   * Connection configuration for calling the OpenSandbox Lifecycle API and the sandbox's execd API.\n   */\n  connectionConfig?: ConnectionConfig | ConnectionConfigOptions;\n  /**\n   * Advanced override: inject a custom adapter factory (custom transports, dependency injection).\n   */\n  adapterFactory?: AdapterFactory;\n\n  /**\n   * Container image uri, e.g. `python:3.11`\n   */\n  image:\n    | string\n    | { uri: string; auth?: { username: string; password: string } };\n\n  /**\n   * Entrypoint command for the sandbox (defaults to tail -f /dev/null).\n   */\n  entrypoint?: string[];\n  /**\n   * Environment variables to inject into the sandbox runtime.\n   */\n  env?: Record<string, string>;\n  /**\n   * Custom metadata tags (used for filtering/management).\n   */\n  metadata?: Record<string, string>;\n  /**\n   * Optional outbound network policy for the sandbox.\n   * If provided without defaultAction, defaults to \"deny\".\n   */\n  networkPolicy?: NetworkPolicy;\n  /**\n   * Optional list of volume mounts for persistent storage.\n   * Each volume specifies a backend (host path or PVC) and mount configuration.\n   */\n  volumes?: Volume[];\n  /**\n   * Opaque extension parameters passed through to the server as-is.\n   */\n  extensions?: Record<string, string>;\n\n  /**\n   * Resource limits applied to the sandbox container.\n   *\n   * This is forwarded to the Lifecycle API as `resourceLimits`.\n   */\n  resource?: Record<string, string>;\n  /**\n   * Sandbox timeout in seconds. Set to `null` to require explicit cleanup.\n   */\n  timeoutSeconds?: number | null;\n\n  /**\n   * Skip readiness checks during create/connect.\n   *\n   * When true, the SDK will not wait for lifecycle state `Running` or perform the health check.\n   * The returned sandbox instance may not be ready yet.\n   */\n  skipHealthCheck?: boolean;\n  /**\n   * Optional custom readiness check used by {@link Sandbox.waitUntilReady}.\n   *\n   * If provided, the SDK will call this function during readiness checks instead of\n   * using the default `execd` ping check.\n   */\n  healthCheck?: (sbx: Sandbox) => boolean | Promise<boolean>;\n  readyTimeoutSeconds?: number;\n  healthCheckPollingInterval?: number;\n}\n\nexport interface SandboxConnectOptions {\n  /**\n   * Connection configuration for calling the OpenSandbox APIs.\n   */\n  connectionConfig?: ConnectionConfig | ConnectionConfigOptions;\n  /**\n   * Advanced override: inject a custom adapter factory (custom transports, dependency injection).\n   */\n  adapterFactory?: AdapterFactory;\n  /**\n   * ID of the existing sandbox to connect to.\n   */\n  sandboxId: SandboxId;\n\n  /**\n   * Skip readiness checks after connecting.\n   */\n  skipHealthCheck?: boolean;\n  /**\n   * Optional custom readiness check used by {@link Sandbox.waitUntilReady}.\n   */\n  healthCheck?: (sbx: Sandbox) => boolean | Promise<boolean>;\n  /**\n   * Max time to wait for readiness.\n   */\n  readyTimeoutSeconds?: number;\n  /**\n   * Polling interval for readiness checks (milliseconds).\n   */\n  healthCheckPollingInterval?: number;\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction toImageSpec(\n  image: SandboxCreateOptions[\"image\"]\n): CreateSandboxRequest[\"image\"] {\n  if (typeof image === \"string\") return { uri: image };\n  return { uri: image.uri, auth: image.auth };\n}\n\nexport class Sandbox {\n  readonly id: SandboxId;\n  readonly connectionConfig: ConnectionConfig;\n\n  /**\n   * Lifecycle (sandbox management) service.\n   */\n  readonly sandboxes: Sandboxes;\n\n  /**\n   * Execd services.\n   */\n  readonly commands: ExecdCommands;\n  /**\n   * High-level filesystem facade (JS-friendly).\n   */\n  readonly files: SandboxFiles;\n  readonly health: ExecdHealth;\n  readonly metrics: ExecdMetrics;\n\n  /**\n   * Internal state kept out of the public instance shape.\n   *\n   * This avoids nominal typing issues when multiple copies of the SDK exist in a dependency graph.\n   */\n  private static readonly _priv = new WeakMap<\n    Sandbox,\n    {\n      adapterFactory: AdapterFactory;\n      lifecycleBaseUrl: string;\n      execdBaseUrl: string;\n      egress: Egress;\n    }\n  >();\n\n  private constructor(opts: {\n    id: SandboxId;\n    connectionConfig: ConnectionConfig;\n    adapterFactory: AdapterFactory;\n    lifecycleBaseUrl: string;\n    execdBaseUrl: string;\n    sandboxes: Sandboxes;\n    commands: ExecdCommands;\n    files: SandboxFiles;\n    health: ExecdHealth;\n    metrics: ExecdMetrics;\n    egress: Egress;\n  }) {\n    this.id = opts.id;\n    this.connectionConfig = opts.connectionConfig;\n    Sandbox._priv.set(this, {\n      adapterFactory: opts.adapterFactory,\n      lifecycleBaseUrl: opts.lifecycleBaseUrl,\n      execdBaseUrl: opts.execdBaseUrl,\n      egress: opts.egress,\n    });\n\n    this.sandboxes = opts.sandboxes;\n    this.commands = opts.commands;\n    this.files = opts.files;\n    this.health = opts.health;\n    this.metrics = opts.metrics;\n  }\n\n  static async create(opts: SandboxCreateOptions): Promise<Sandbox> {\n    const baseConnectionConfig =\n      opts.connectionConfig instanceof ConnectionConfig\n        ? opts.connectionConfig\n        : new ConnectionConfig(opts.connectionConfig);\n    const connectionConfig = baseConnectionConfig.withTransportIfMissing();\n    const lifecycleBaseUrl = connectionConfig.getBaseUrl();\n    const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory();\n\n    let sandboxes: Sandboxes;\n    try {\n      sandboxes = adapterFactory.createLifecycleStack({\n        connectionConfig,\n        lifecycleBaseUrl,\n      }).sandboxes;\n    } catch (err) {\n      await connectionConfig.closeTransport();\n      throw err;\n    }\n\n    // Validate volumes: exactly one backend must be specified per volume\n    if (opts.volumes) {\n      for (const vol of opts.volumes) {\n        const backendsSpecified = [vol.host, vol.pvc].filter((b) => b !== undefined).length;\n        if (backendsSpecified === 0) {\n          throw new Error(\n            `Volume '${vol.name}' must specify exactly one backend (host, pvc), but none was provided.`\n          );\n        }\n        if (backendsSpecified > 1) {\n          throw new Error(\n            `Volume '${vol.name}' must specify exactly one backend (host, pvc), but multiple were provided.`\n          );\n        }\n      }\n    }\n\n    const rawTimeout = opts.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;\n    const timeoutSeconds =\n      opts.timeoutSeconds === null\n        ? null\n        : Math.floor(rawTimeout);\n    if (timeoutSeconds !== null && !Number.isFinite(timeoutSeconds)) {\n      throw new Error(\n        `timeoutSeconds must be a finite number, got ${opts.timeoutSeconds}`\n      );\n    }\n\n    const req: CreateSandboxRequest = {\n      image: toImageSpec(opts.image),\n      entrypoint: opts.entrypoint ?? DEFAULT_ENTRYPOINT,\n      resourceLimits: opts.resource ?? DEFAULT_RESOURCE_LIMITS,\n      env: opts.env ?? {},\n      metadata: opts.metadata ?? {},\n      networkPolicy: opts.networkPolicy\n        ? {\n            ...opts.networkPolicy,\n            defaultAction: opts.networkPolicy.defaultAction ?? \"deny\",\n          }\n        : undefined,\n      volumes: opts.volumes,\n      extensions: opts.extensions ?? {},\n    };\n    if (timeoutSeconds !== null) {\n      req.timeout = timeoutSeconds;\n    }\n\n    let sandboxId: SandboxId | undefined;\n    try {\n      const created = await sandboxes.createSandbox(req);\n      sandboxId = created.id as SandboxId;\n\n      const endpoint = await sandboxes.getSandboxEndpoint(\n        sandboxId,\n        DEFAULT_EXECD_PORT,\n        connectionConfig.useServerProxy\n      );\n      const egressEndpoint = await sandboxes.getSandboxEndpoint(\n        sandboxId,\n        DEFAULT_EGRESS_PORT,\n        connectionConfig.useServerProxy\n      );\n      const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`;\n      const egressBaseUrl = `${connectionConfig.protocol}://${egressEndpoint.endpoint}`;\n\n      const { commands, files, health, metrics } =\n        adapterFactory.createExecdStack({\n          connectionConfig,\n          execdBaseUrl,\n          endpointHeaders: endpoint.headers,\n        });\n      const { egress } = adapterFactory.createEgressStack({\n        connectionConfig,\n        egressBaseUrl,\n        endpointHeaders: egressEndpoint.headers,\n      });\n\n      const sbx = new Sandbox({\n        id: sandboxId,\n        connectionConfig,\n        adapterFactory,\n        lifecycleBaseUrl,\n        execdBaseUrl,\n        sandboxes,\n        commands,\n        files,\n        health,\n        metrics,\n        egress,\n      });\n\n      if (!(opts.skipHealthCheck ?? false)) {\n        await sbx.waitUntilReady({\n          readyTimeoutSeconds:\n            opts.readyTimeoutSeconds ?? DEFAULT_READY_TIMEOUT_SECONDS,\n          pollingIntervalMillis:\n            opts.healthCheckPollingInterval ??\n            DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS,\n          healthCheck: opts.healthCheck,\n        });\n      }\n\n      return sbx;\n    } catch (err) {\n      if (sandboxId) {\n        try {\n          await sandboxes.deleteSandbox(sandboxId);\n        } catch {\n          // Ignore cleanup failure; surface original error.\n        }\n      }\n      await connectionConfig.closeTransport();\n      throw err;\n    }\n  }\n\n  static async connect(opts: SandboxConnectOptions): Promise<Sandbox> {\n    const baseConnectionConfig =\n      opts.connectionConfig instanceof ConnectionConfig\n        ? opts.connectionConfig\n        : new ConnectionConfig(opts.connectionConfig);\n    const connectionConfig = baseConnectionConfig.withTransportIfMissing();\n    const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory();\n    const lifecycleBaseUrl = connectionConfig.getBaseUrl();\n\n    let sandboxes: Sandboxes;\n    try {\n      sandboxes = adapterFactory.createLifecycleStack({\n        connectionConfig,\n        lifecycleBaseUrl,\n      }).sandboxes;\n    } catch (err) {\n      await connectionConfig.closeTransport();\n      throw err;\n    }\n\n    try {\n      const endpoint = await sandboxes.getSandboxEndpoint(\n        opts.sandboxId,\n        DEFAULT_EXECD_PORT,\n        connectionConfig.useServerProxy\n      );\n      const egressEndpoint = await sandboxes.getSandboxEndpoint(\n        opts.sandboxId,\n        DEFAULT_EGRESS_PORT,\n        connectionConfig.useServerProxy\n      );\n      const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`;\n      const egressBaseUrl = `${connectionConfig.protocol}://${egressEndpoint.endpoint}`;\n      const { commands, files, health, metrics } =\n        adapterFactory.createExecdStack({\n          connectionConfig,\n          execdBaseUrl,\n          endpointHeaders: endpoint.headers,\n        });\n      const { egress } = adapterFactory.createEgressStack({\n        connectionConfig,\n        egressBaseUrl,\n        endpointHeaders: egressEndpoint.headers,\n      });\n\n      const sbx = new Sandbox({\n        id: opts.sandboxId,\n        connectionConfig,\n        adapterFactory,\n        lifecycleBaseUrl,\n        execdBaseUrl,\n        sandboxes,\n        commands,\n        files,\n        health,\n        metrics,\n        egress,\n      });\n\n      if (!(opts.skipHealthCheck ?? false)) {\n        await sbx.waitUntilReady({\n          readyTimeoutSeconds:\n            opts.readyTimeoutSeconds ?? DEFAULT_READY_TIMEOUT_SECONDS,\n          pollingIntervalMillis:\n            opts.healthCheckPollingInterval ??\n            DEFAULT_HEALTH_CHECK_POLLING_INTERVAL_MILLIS,\n          healthCheck: opts.healthCheck,\n        });\n      }\n\n      return sbx;\n    } catch (err) {\n      await connectionConfig.closeTransport();\n      throw err;\n    }\n  }\n\n  async getInfo(): Promise<SandboxInfo> {\n    return await this.sandboxes.getSandbox(this.id);\n  }\n\n  async isHealthy(): Promise<boolean> {\n    try {\n      return await this.health.ping();\n    } catch {\n      return false;\n    }\n  }\n\n  async getMetrics() {\n    return await this.metrics.getMetrics();\n  }\n\n  async pause(): Promise<void> {\n    await this.sandboxes.pauseSandbox(this.id);\n  }\n\n  /**\n   * Resume a paused sandbox and return a fresh, connected Sandbox instance.\n   *\n   * After resume, the execd endpoint may change, so this method returns a new\n   * {@link Sandbox} instance with a refreshed execd base URL.\n   */\n  async resume(\n    opts: {\n      skipHealthCheck?: boolean;\n      readyTimeoutSeconds?: number;\n      healthCheckPollingInterval?: number;\n    } = {}\n  ): Promise<Sandbox> {\n    await this.sandboxes.resumeSandbox(this.id);\n    return await Sandbox.connect({\n      sandboxId: this.id,\n      connectionConfig: this.connectionConfig,\n      adapterFactory: Sandbox._priv.get(this)!.adapterFactory,\n      skipHealthCheck: opts.skipHealthCheck ?? false,\n      readyTimeoutSeconds: opts.readyTimeoutSeconds,\n      healthCheckPollingInterval: opts.healthCheckPollingInterval,\n    });\n  }\n\n  /**\n   * Resume a paused sandbox by id, then connect to its execd endpoint.\n   */\n  static async resume(opts: SandboxConnectOptions): Promise<Sandbox> {\n    const baseConnectionConfig =\n      opts.connectionConfig instanceof ConnectionConfig\n        ? opts.connectionConfig\n        : new ConnectionConfig(opts.connectionConfig);\n    const adapterFactory = opts.adapterFactory ?? createDefaultAdapterFactory();\n    const resumeConnectionConfig = baseConnectionConfig.withTransportIfMissing();\n    const lifecycleBaseUrl = resumeConnectionConfig.getBaseUrl();\n\n    let sandboxes: Sandboxes;\n    try {\n      sandboxes = adapterFactory.createLifecycleStack({\n        connectionConfig: resumeConnectionConfig,\n        lifecycleBaseUrl,\n      }).sandboxes;\n      await sandboxes.resumeSandbox(opts.sandboxId);\n    } catch (err) {\n      await resumeConnectionConfig.closeTransport();\n      throw err;\n    }\n\n    await resumeConnectionConfig.closeTransport();\n    return await Sandbox.connect({ ...opts, connectionConfig: baseConnectionConfig, adapterFactory });\n  }\n\n  async kill(): Promise<void> {\n    await this.sandboxes.deleteSandbox(this.id);\n  }\n\n  /**\n   * Release any client-side resources (e.g. Node.js HTTP agents) owned by this Sandbox instance.\n   */\n  async close(): Promise<void> {\n    await this.connectionConfig.closeTransport();\n  }\n\n  /**\n   * Renew expiration by setting expiresAt to now + timeoutSeconds.\n   */\n  async renew(timeoutSeconds: number): Promise<RenewSandboxExpirationResponse> {\n    const expiresAt = new Date(\n      Date.now() + timeoutSeconds * 1000\n    ).toISOString();\n    return await this.sandboxes.renewSandboxExpiration(this.id, { expiresAt });\n  }\n\n  async getEgressPolicy(): Promise<NetworkPolicy> {\n    return await Sandbox._priv.get(this)!.egress.getPolicy();\n  }\n\n  async patchEgressRules(rules: NetworkRule[]): Promise<void> {\n    await Sandbox._priv.get(this)!.egress.patchRules(rules);\n  }\n\n  /**\n   * Get sandbox endpoint for a port (STRICT: no scheme), e.g. \"localhost:44772\" or \"domain/route/.../44772\".\n   */\n  async getEndpoint(port: number): Promise<Endpoint> {\n    return await this.sandboxes.getSandboxEndpoint(\n      this.id,\n      port,\n      this.connectionConfig.useServerProxy\n    );\n  }\n\n  /**\n   * Get absolute endpoint URL with scheme (convenience for HTTP clients).\n   */\n  async getEndpointUrl(port: number): Promise<string> {\n    const ep = await this.getEndpoint(port);\n    return `${this.connectionConfig.protocol}://${ep.endpoint}`;\n  }\n\n  async waitUntilReady(opts: {\n    readyTimeoutSeconds: number;\n    pollingIntervalMillis: number;\n    healthCheck?: (sbx: Sandbox) => boolean | Promise<boolean>;\n  }): Promise<void> {\n    const deadline = Date.now() + opts.readyTimeoutSeconds * 1000;\n    let attempt = 0;\n    let errorDetail = \"Health check returned false continuously.\";\n\n    const buildTimeoutMessage = () => {\n      const context = `domain=${this.connectionConfig.domain}, useServerProxy=${this.connectionConfig.useServerProxy}`;\n      let suggestion =\n        \"If this sandbox runs in Docker bridge or remote-network mode, consider enabling useServerProxy=true.\";\n      if (!this.connectionConfig.useServerProxy) {\n        suggestion += \" You can also configure server-side [docker].host_ip for direct endpoint access.\";\n      }\n      return `Sandbox health check timed out after ${opts.readyTimeoutSeconds}s (${attempt} attempts). ${errorDetail} Connection context: ${context}. ${suggestion}`;\n    };\n\n    // Wait until execd becomes reachable and passes health check.\n    while (true) {\n      if (Date.now() > deadline) {\n        throw new SandboxReadyTimeoutException({\n          message: buildTimeoutMessage(),\n        });\n      }\n      attempt++;\n      try {\n        if (opts.healthCheck) {\n          const ok = await opts.healthCheck(this);\n          if (ok) {\n            return;\n          }\n        } else {\n          const ok = await this.health.ping();\n          if (ok) {\n            return;\n          }\n        }\n        errorDetail = \"Health check returned false continuously.\";\n      } catch (err) {\n        const message = err instanceof Error ? err.message : String(err);\n        errorDetail = `Last health check error: ${message}`;\n      }\n      await sleep(opts.pollingIntervalMillis);\n    }\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/egress.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { NetworkPolicy, NetworkRule } from \"../models/sandboxes.js\";\n\nexport interface Egress {\n  getPolicy(): Promise<NetworkPolicy>;\n  /**\n   * Patch egress rules with sidecar merge semantics.\n   *\n   * Incoming rules take priority over existing rules with the same target.\n   * Existing rules for other targets remain unchanged. Within one patch payload,\n   * the first rule for a target wins. The current defaultAction is preserved.\n   */\n  patchRules(rules: NetworkRule[]): Promise<void>;\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/execdCommands.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { ExecutionHandlers } from \"../models/execution.js\";\nimport type {\n  CommandExecution,\n  CommandLogs,\n  CommandStatus,\n  RunCommandOpts,\n  ServerStreamEvent,\n} from \"../models/execd.js\";\n\nexport interface ExecdCommands {\n  /**\n   * Run a command and stream server events (SSE). This is the lowest-level API.\n   */\n  runStream(command: string, opts?: RunCommandOpts, signal?: AbortSignal): AsyncIterable<ServerStreamEvent>;\n\n  /**\n   * Convenience: run a command, consume the stream, and build a structured execution result.\n   */\n  run(command: string, opts?: RunCommandOpts, handlers?: ExecutionHandlers, signal?: AbortSignal): Promise<CommandExecution>;\n\n  /**\n   * Interrupt the current execution in the given context/session.\n   *\n   * Note: Execd spec uses `DELETE /command?id=<sessionId>`.\n   */\n  interrupt(sessionId: string): Promise<void>;\n\n  /**\n   * Get the current running status for a command id.\n   */\n  getCommandStatus(commandId: string): Promise<CommandStatus>;\n\n  /**\n   * Get background command logs (non-streamed).\n   */\n  getBackgroundCommandLogs(commandId: string, cursor?: number): Promise<CommandLogs>;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/execdHealth.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport interface ExecdHealth {\n  ping(): Promise<boolean>;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/execdMetrics.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { SandboxMetrics } from \"../models/execd.js\";\n\nexport interface ExecdMetrics {\n  getMetrics(): Promise<SandboxMetrics>;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/filesystem.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type { SearchFilesResponse } from \"../models/filesystem.js\";\nimport type {\n  ContentReplaceEntry,\n  FileInfo,\n  MoveEntry,\n  SearchEntry,\n  SetPermissionEntry,\n  WriteEntry,\n} from \"../models/filesystem.js\";\n\n/**\n * High-level filesystem facade (JS-friendly).\n *\n * This interface provides a convenience layer over the underlying execd filesystem API:\n * it offers common operations (read/write/search/move/delete) and supports streaming I/O for large files.\n */\nexport interface SandboxFiles {\n  getFileInfo(paths: string[]): Promise<Record<string, FileInfo>>;\n  search(entry: SearchEntry): Promise<SearchFilesResponse>;\n\n  createDirectories(entries: Pick<WriteEntry, \"path\" | \"mode\" | \"owner\" | \"group\">[]): Promise<void>;\n  deleteDirectories(paths: string[]): Promise<void>;\n\n  writeFiles(entries: WriteEntry[]): Promise<void>;\n  readFile(path: string, opts?: { encoding?: string; range?: string }): Promise<string>;\n  readBytes(path: string, opts?: { range?: string }): Promise<Uint8Array>;\n  readBytesStream(path: string, opts?: { range?: string }): AsyncIterable<Uint8Array>;\n\n  deleteFiles(paths: string[]): Promise<void>;\n  moveFiles(entries: MoveEntry[]): Promise<void>;\n  replaceContents(entries: ContentReplaceEntry[]): Promise<void>;\n  setPermissions(entries: SetPermissionEntry[]): Promise<void>;\n}"
  },
  {
    "path": "sdks/sandbox/javascript/src/services/sandboxes.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport type {\n  CreateSandboxRequest,\n  CreateSandboxResponse,\n  Endpoint,\n  ListSandboxesParams,\n  ListSandboxesResponse,\n  RenewSandboxExpirationRequest,\n  RenewSandboxExpirationResponse,\n  SandboxId,\n  SandboxInfo,\n} from \"../models/sandboxes.js\";\n\nexport interface Sandboxes {\n  createSandbox(req: CreateSandboxRequest): Promise<CreateSandboxResponse>;\n  getSandbox(sandboxId: SandboxId): Promise<SandboxInfo>;\n  listSandboxes(params?: ListSandboxesParams): Promise<ListSandboxesResponse>;\n  deleteSandbox(sandboxId: SandboxId): Promise<void>;\n\n  pauseSandbox(sandboxId: SandboxId): Promise<void>;\n  resumeSandbox(sandboxId: SandboxId): Promise<void>;\n\n  renewSandboxExpiration(\n    sandboxId: SandboxId,\n    req: RenewSandboxExpirationRequest,\n  ): Promise<RenewSandboxExpirationResponse>;\n\n  getSandboxEndpoint(\n    sandboxId: SandboxId,\n    port: number,\n    useServerProxy?: boolean\n  ): Promise<Endpoint>;\n}\n"
  },
  {
    "path": "sdks/sandbox/javascript/tests/sandbox.create.test.mjs",
    "content": "import assert from \"node:assert/strict\";\nimport test from \"node:test\";\n\nimport {\n  DEFAULT_EGRESS_PORT,\n  DEFAULT_EXECD_PORT,\n  DEFAULT_TIMEOUT_SECONDS,\n  Sandbox,\n} from \"../dist/index.js\";\n\nfunction createAdapterFactory() {\n  const recordedRequests = [];\n  const endpointCalls = [];\n  const egressStackCalls = [];\n  const egressService = {\n    async getPolicy() {\n      return {\n        defaultAction: \"deny\",\n        egress: [{ action: \"allow\", target: \"pypi.org\" }],\n      };\n    },\n    async patchRules() {},\n  };\n  const sandboxes = {\n    async createSandbox(req) {\n      recordedRequests.push(req);\n      return { id: \"sandbox-test-id\", expiresAt: null };\n    },\n    async getSandbox() {\n      throw new Error(\"not implemented\");\n    },\n    async listSandboxes() {\n      throw new Error(\"not implemented\");\n    },\n    async deleteSandbox() {},\n    async pauseSandbox() {},\n    async resumeSandbox() {},\n    async renewSandboxExpiration() {\n      throw new Error(\"not implemented\");\n    },\n    async getSandboxEndpoint(_sandboxId, port) {\n      endpointCalls.push(port);\n      return { endpoint: `127.0.0.1:${port}`, headers: { \"x-port\": String(port) } };\n    },\n  };\n\n  const adapterFactory = {\n    createLifecycleStack() {\n      return { sandboxes };\n    },\n    createExecdStack() {\n      return {\n        commands: {},\n        files: {},\n        health: {},\n        metrics: {},\n      };\n    },\n    createEgressStack(opts) {\n      egressStackCalls.push(opts);\n      return { egress: egressService };\n    },\n  };\n\n  return { adapterFactory, recordedRequests, endpointCalls, egressStackCalls };\n}\n\ntest(\"Sandbox.create omits timeout when timeoutSeconds is null\", async () => {\n  const { adapterFactory, recordedRequests } = createAdapterFactory();\n\n  await Sandbox.create({\n    adapterFactory,\n    connectionConfig: { domain: \"http://127.0.0.1:8080\" },\n    image: \"python:3.12\",\n    timeoutSeconds: null,\n    skipHealthCheck: true,\n  });\n\n  assert.equal(recordedRequests.length, 1);\n  assert.ok(!Object.hasOwn(recordedRequests[0], \"timeout\"));\n});\n\ntest(\"Sandbox.create floors finite timeoutSeconds\", async () => {\n  const { adapterFactory, recordedRequests } = createAdapterFactory();\n\n  await Sandbox.create({\n    adapterFactory,\n    connectionConfig: { domain: \"http://127.0.0.1:8080\" },\n    image: \"python:3.12\",\n    timeoutSeconds: 61.9,\n    skipHealthCheck: true,\n  });\n\n  assert.equal(recordedRequests.length, 1);\n  assert.equal(recordedRequests[0].timeout, 61);\n});\n\ntest(\"Sandbox.create uses the default timeout when timeoutSeconds is undefined\", async () => {\n  const { adapterFactory, recordedRequests } = createAdapterFactory();\n\n  await Sandbox.create({\n    adapterFactory,\n    connectionConfig: { domain: \"http://127.0.0.1:8080\" },\n    image: \"python:3.12\",\n    skipHealthCheck: true,\n  });\n\n  assert.equal(recordedRequests.length, 1);\n  assert.equal(recordedRequests[0].timeout, DEFAULT_TIMEOUT_SECONDS);\n});\n\ntest(\"Sandbox.create rejects non-finite timeoutSeconds\", async () => {\n  for (const timeoutSeconds of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {\n    const { adapterFactory } = createAdapterFactory();\n    await assert.rejects(\n      Sandbox.create({\n        adapterFactory,\n        connectionConfig: { domain: \"http://127.0.0.1:8080\" },\n        image: \"python:3.12\",\n        timeoutSeconds,\n        skipHealthCheck: true,\n      }),\n      /timeoutSeconds must be a finite number/\n    );\n  }\n});\n\ntest(\"Sandbox creates and reuses egress service during sandbox lifecycle\", async () => {\n  const { adapterFactory, endpointCalls, egressStackCalls } = createAdapterFactory();\n\n  const sandbox = await Sandbox.create({\n    adapterFactory,\n    connectionConfig: { domain: \"http://127.0.0.1:8080\" },\n    image: \"python:3.12\",\n    skipHealthCheck: true,\n  });\n\n  await sandbox.getEgressPolicy();\n  await sandbox.patchEgressRules([{ action: \"allow\", target: \"www.github.com\" }]);\n\n  assert.deepEqual(endpointCalls, [DEFAULT_EXECD_PORT, DEFAULT_EGRESS_PORT]);\n  assert.equal(egressStackCalls.length, 1);\n  assert.equal(egressStackCalls[0].egressBaseUrl, `http://127.0.0.1:${DEFAULT_EGRESS_PORT}`);\n  assert.deepEqual(egressStackCalls[0].endpointHeaders, { \"x-port\": String(DEFAULT_EGRESS_PORT) });\n});\n"
  },
  {
    "path": "sdks/sandbox/javascript/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/*.test.ts\"]\n}"
  },
  {
    "path": "sdks/sandbox/javascript/tsup.config.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\nimport { defineConfig } from \"tsup\";\n\nconst entries = [\"src/index.ts\", \"src/internal.ts\"];\n\nexport default defineConfig([\n  {\n    entry: entries,\n    format: [\"esm\"],\n    dts: true,\n    outDir: \"dist\",\n    clean: true,\n    sourcemap: true,\n    target: \"es2022\",\n  },\n  {\n    entry: entries,\n    format: [\"cjs\"],\n    outDir: \"dist/cjs\",\n    clean: false,\n    sourcemap: true,\n    target: \"es2022\",\n    outExtension: () => ({ js: \".cjs\" }),\n  },\n]);\n"
  },
  {
    "path": "sdks/sandbox/kotlin/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1.  Definitions.\n\n    \"License\" shall mean the terms and conditions for use, reproduction,\n    and distribution as defined by Sections 1 through 9 of this document.\n\n    \"Licensor\" shall mean the copyright owner or entity authorized by\n    the copyright owner that is granting the License.\n\n    \"Legal Entity\" shall mean the union of the acting entity and all\n    other entities that control, are controlled by, or are under common\n    control with that entity. For the purposes of this definition,\n    \"control\" means (i) the power, direct or indirect, to cause the\n    direction or management of such entity, whether by contract or\n    otherwise, or (ii) ownership of fifty percent (50%) or more of the\n    outstanding shares, or (iii) beneficial ownership of such entity.\n\n    \"You\" (or \"Your\") shall mean an individual or Legal Entity\n    exercising permissions granted by this License.\n\n    \"Source\" form shall mean the preferred form for making modifications,\n    including but not limited to software source code, documentation\n    source, and configuration files.\n\n    \"Object\" form shall mean any form resulting from mechanical\n    transformation or translation of a Source form, including but\n    not limited to compiled object code, generated documentation,\n    and conversions to other media types.\n\n    \"Work\" shall mean the work of authorship, whether in Source or\n    Object form, made available under the License, as indicated by a\n    copyright notice that is included in or attached to the work\n    (an example is provided in the Appendix below).\n\n    \"Derivative Works\" shall mean any work, whether in Source or Object\n    form, that is based on (or derived from) the Work and for which the\n    editorial revisions, annotations, elaborations, or other modifications\n    represent, as a whole, an original work of authorship. For the purposes\n    of this License, Derivative Works shall not include works that remain\n    separable from, or merely link (or bind by name) to the interfaces of,\n    the Work and Derivative Works thereof.\n\n    \"Contribution\" shall mean any work of authorship, including\n    the original version of the Work and any modifications or additions\n    to that Work or Derivative Works thereof, that is intentionally\n    submitted to Licensor for inclusion in the Work by the copyright owner\n    or by an individual or Legal Entity authorized to submit on behalf of\n    the copyright owner. For the purposes of this definition, \"submitted\"\n    means any form of electronic, verbal, or written communication sent\n    to the Licensor or its representatives, including but not limited to\n    communication on electronic mailing lists, source code control systems,\n    and issue tracking systems that are managed by, or on behalf of, the\n    Licensor for the purpose of discussing and improving the Work, but\n    excluding communication that is conspicuously marked or otherwise\n    designated in writing by the copyright owner as \"Not a Contribution.\"\n\n    \"Contributor\" shall mean Licensor and any individual or Legal Entity\n    on behalf of whom a Contribution has been received by Licensor and\n    subsequently incorporated within the Work.\n\n2.  Grant of Copyright License. Subject to the terms and conditions of\n    this License, each Contributor hereby grants to You a perpetual,\n    worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n    copyright license to reproduce, prepare Derivative Works of,\n    publicly display, publicly perform, sublicense, and distribute the\n    Work and such Derivative Works in Source or Object form.\n\n3.  Grant of Patent License. Subject to the terms and conditions of\n    this License, each Contributor hereby grants to You a perpetual,\n    worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n    (except as stated in this section) patent license to make, have made,\n    use, offer to sell, sell, import, and otherwise transfer the Work,\n    where such license applies only to those patent claims licensable\n    by such Contributor that are necessarily infringed by their\n    Contribution(s) alone or by combination of their Contribution(s)\n    with the Work to which such Contribution(s) was submitted. If You\n    institute patent litigation against any entity (including a\n    cross-claim or counterclaim in a lawsuit) alleging that the Work\n    or a Contribution incorporated within the Work constitutes direct\n    or contributory patent infringement, then any patent licenses\n    granted to You under this License for that Work shall terminate\n    as of the date such litigation is filed.\n\n4.  Redistribution. You may reproduce and distribute copies of the\n    Work or Derivative Works thereof in any medium, with or without\n    modifications, and in Source or Object form, provided that You\n    meet the following conditions:\n\n    (a) You must give any other recipients of the Work or\n    Derivative Works a copy of this License; and\n\n    (b) You must cause any modified files to carry prominent notices\n    stating that You changed the files; and\n\n    (c) You must retain, in the Source form of any Derivative Works\n    that You distribute, all copyright, patent, trademark, and\n    attribution notices from the Source form of the Work,\n    excluding those notices that do not pertain to any part of\n    the Derivative Works; and\n\n    (d) If the Work includes a \"NOTICE\" text file as part of its\n    distribution, then any Derivative Works that You distribute must\n    include a readable copy of the attribution notices contained\n    within such NOTICE file, excluding those notices that do not\n    pertain to any part of the Derivative Works, in at least one\n    of the following places: within a NOTICE text file distributed\n    as part of the Derivative Works; within the Source form or\n    documentation, if provided along with the Derivative Works; or,\n    within a display generated by the Derivative Works, if and\n    wherever such third-party notices normally appear. The contents\n    of the NOTICE file are for informational purposes only and\n    do not modify the License. You may add Your own attribution\n    notices within Derivative Works that You distribute, alongside\n    or as an addendum to the NOTICE text from the Work, provided\n    that such additional attribution notices cannot be construed\n    as modifying the License.\n\n    You may add Your own copyright statement to Your modifications and\n    may provide additional or different license terms and conditions\n    for use, reproduction, or distribution of Your modifications, or\n    for any such Derivative Works as a whole, provided Your use,\n    reproduction, and distribution of the Work otherwise complies with\n    the conditions stated in this License.\n\n5.  Submission of Contributions. Unless You explicitly state otherwise,\n    any Contribution intentionally submitted for inclusion in the Work\n    by You to the Licensor shall be under the terms and conditions of\n    this License, without any additional terms or conditions.\n    Notwithstanding the above, nothing herein shall supersede or modify\n    the terms of any separate license agreement you may have executed\n    with Licensor regarding such Contributions.\n\n6.  Trademarks. This License does not grant permission to use the trade\n    names, trademarks, service marks, or product names of the Licensor,\n    except as required for reasonable and customary use in describing the\n    origin of the Work and reproducing the content of the NOTICE file.\n\n7.  Disclaimer of Warranty. Unless required by applicable law or\n    agreed to in writing, Licensor provides the Work (and each\n    Contributor provides its Contributions) on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n    implied, including, without limitation, any warranties or conditions\n    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n    PARTICULAR PURPOSE. You are solely responsible for determining the\n    appropriateness of using or redistributing the Work and assume any\n    risks associated with Your exercise of permissions under this License.\n\n8.  Limitation of Liability. In no event and under no legal theory,\n    whether in tort (including negligence), contract, or otherwise,\n    unless required by applicable law (such as deliberate and grossly\n    negligent acts) or agreed to in writing, shall any Contributor be\n    liable to You for damages, including any direct, indirect, special,\n    incidental, or consequential damages of any character arising as a\n    result of this License or out of the use or inability to use the\n    Work (including but not limited to damages for loss of goodwill,\n    work stoppage, computer failure or malfunction, or any and all\n    other commercial damages or losses), even if such Contributor\n    has been advised of the possibility of such damages.\n\n9.  Accepting Warranty or Additional Liability. While redistributing\n    the Work or Derivative Works thereof, You may choose to offer,\n    and charge a fee for, acceptance of support, warranty, indemnity,\n    or other liability obligations and/or rights consistent with this\n    License. However, in accepting such obligations, You may act only\n    on Your own behalf and on Your sole responsibility, not on behalf\n    of any other Contributor, and only if You agree to indemnify,\n    defend, and hold each Contributor harmless for any liability\n    incurred by, or claims asserted against, such Contributor by reason\n    of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
  },
  {
    "path": "sdks/sandbox/kotlin/README.md",
    "content": "# Alibaba Sandbox SDK for Kotlin\n\nEnglish | [中文](README_zh.md)\n\nA Kotlin SDK for low-level interaction with OpenSandbox. It provides capabilities to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and monitoring resources.\n\n## Installation\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n    implementation(\"com.alibaba.opensandbox:sandbox:{latest_version}\")\n}\n```\n\n### Maven\n\n```xml\n<dependency>\n    <groupId>com.alibaba.opensandbox</groupId>\n    <artifactId>sandbox</artifactId>\n    <version>{latest_version}</version>\n</dependency>\n```\n\n## Quick Start\n\nThe following example shows how to create a sandbox and execute a shell command.\n\n> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions.\n\n```java\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;\n\npublic class QuickStart {\n    public static void main(String[] args) {\n        // 1. Configure connection\n        ConnectionConfig config = ConnectionConfig.builder()\n            .domain(\"api.opensandbox.io\")\n            .apiKey(\"your-api-key\")\n            .build();\n\n        // 2. Create a Sandbox using try-with-resources\n        try (Sandbox sandbox = Sandbox.builder()\n                .connectionConfig(config)\n                .image(\"ubuntu\")\n                .build()) {\n\n            // 3. Execute a shell command\n            Execution execution = sandbox\n                    .commands()\n                    .run(\"echo 'Hello Sandbox!'\");\n\n            // 4. Print output\n            System.out.println(execution.getLogs().getStdout().get(0).getText());\n\n            // 5. Cleanup (sandbox.close() called automatically)\n            // Note: kill() must be called explicitly if you want to terminate the remote sandbox instance immediately\n            sandbox.kill();\n        } catch (SandboxException e) {\n            // Handle Sandbox specific exceptions\n            System.err.println(\"Sandbox Error: [\" + e.getError().getCode() + \"] \" + e.getError().getMessage());\n            System.err.println(\"Request ID: \" + e.getRequestId());\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n    }\n}\n```\n\n## Usage Examples\n\n### 1. Lifecycle Management\n\nManage the sandbox lifecycle, including renewal, pausing, and resuming.\n\n```java\n// Renew the sandbox\n// This resets the expiration time to (current time + duration)\nsandbox.renew(Duration.ofMinutes(30));\n\n// Pause execution (suspends all processes)\nsandbox.pause();\n\n// Resume execution\nsandbox.resume();\n\n// Get current status\nSandboxInfo info = sandbox.getInfo();\nSystem.out.println(\"State: \" + info.getStatus().getState());\nSystem.out.println(\"Expires: \" + info.getExpiresAt()); // null when manual cleanup mode is used\n```\n\nCreate a non-expiring sandbox by passing `timeout(null)`:\n\n```java\nSandbox manual = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"ubuntu\")\n    .timeout(null)\n    .build();\n```\n\n### 2. Custom Health Check\n\nDefine custom logic to determine if the sandbox is healthy. This overrides the default ping check.\n\n```java\nSandbox sandbox = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"nginx:latest\")\n    // Custom check: Wait for port 80 to be accessible\n    .healthCheck(sbx -> {\n        try {\n            // 1. Get the external mapped address for port 80\n            SandboxEndpoint endpoint = sbx.getEndpoint(80);\n\n            // 2. Perform your connection check (e.g. HTTP request, Socket connect)\n            // return checkConnection(endpoint.getEndpoint());\n            return true;\n        } catch (Exception e) {\n            return false;\n        }\n    })\n    .build();\n```\n\n### 3. Command Execution & Streaming\n\nExecute commands and handle output streams in real-time.\n\n```java\n// Create handlers for streaming output\nExecutionHandlers handlers = ExecutionHandlers.builder()\n    .onStdout(msg -> System.out.println(\"STDOUT: \" + msg.getText()))\n    .onStderr(msg -> System.err.println(\"STDERR: \" + msg.getText()))\n    .onExecutionComplete(complete ->\n        System.out.println(\"Command finished in \" + complete.getExecutionTimeInMillis() + \"ms\")\n    )\n    .build();\n\n// Execute command with handlers\nRunCommandRequest request = RunCommandRequest.builder()\n    .command(\"for i in {1..5}; do echo \\\"Count $i\\\"; sleep 0.5; done\")\n    .handlers(handlers)\n    .build();\n\nsandbox.commands().run(request);\n```\n\n### 4. Comprehensive File Operations\n\nManage files and directories, including read, write, list, delete, and search.\n\n```java\n// 1. Write file\nsandbox.files().write(List.of(\n    WriteEntry.builder()\n        .path(\"/tmp/hello.txt\")\n        .data(\"Hello World\")\n        .mode(644)\n        .build()\n));\n\n// 2. Read file\nString content = sandbox.files().readFile(\"/tmp/hello.txt\", \"UTF-8\", null);\nSystem.out.println(\"Content: \" + content);\n\n// 3. List/Search files\nList<EntryInfo> files = sandbox.files().search(\n    SearchEntry.builder()\n        .path(\"/tmp\")\n        .pattern(\"*.txt\")\n        .build()\n);\nfiles.forEach(f -> System.out.println(\"Found: \" + f.getPath()));\n\n// 4. Delete file\nsandbox.files().deleteFiles(List.of(\"/tmp/hello.txt\"));\n```\n\n### 5. Sandbox Management (Admin)\n\nUse `SandboxManager` for administrative tasks and finding existing sandboxes.\n\n```java\nSandboxManager manager = SandboxManager.builder()\n    .connectionConfig(config)\n    .build();\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState;\n\n// ...\n\n// List running sandboxes\nPagedSandboxInfos sandboxes = manager.listSandboxInfos(\n    SandboxFilter.builder()\n        .states(SandboxState.RUNNING)\n        .pageSize(10)\n        .page(1)\n        .build()\n);\n\nsandboxes.getSandboxInfos().forEach(info -> {\n    System.out.println(\"Found sandbox: \" + info.getId());\n    // Perform admin actions\n    manager.killSandbox(info.getId());\n});\n\n// Try-with-resources will automatically call manager.close()\n// manager.close();\n```\n\n## Configuration\n\n### 1. Connection Configuration\n\nThe `ConnectionConfig` class manages API server connection settings.\n\n| Parameter        | Description                                | Default                      | Environment Variable   |\n| ---------------- | ------------------------------------------ | ---------------------------- | ---------------------- |\n| `apiKey`         | API Key for authentication                 | Required                     | `OPEN_SANDBOX_API_KEY` |\n| `domain`         | The endpoint domain of the sandbox service | Required (or localhost:8080) | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`       | HTTP protocol (http/https)                 | `http`                       | -                      |\n| `requestTimeout` | Timeout for API requests                   | 30 seconds                   | -                      |\n| `debug`          | Enable debug logging for HTTP requests     | `false`                      | -                      |\n| `headers`        | Custom HTTP headers                        | Empty                        | -                      |\n| `connectionPool` | Shared OKHttp ConnectionPool               | SDK-created per instance     | -                      |\n| `useServerProxy` | Use sandbox server as proxy for execd/endpoint requests (e.g. when client cannot reach the sandbox directly) | `false` | -                      |\n\n```java\n// 1. Basic configuration\nConnectionConfig config = ConnectionConfig.builder()\n    .apiKey(\"your-key\")\n    .domain(\"api.opensandbox.io\")\n    .requestTimeout(Duration.ofSeconds(60))\n    .build();\n\n// 2. Advanced: Shared Connection Pool\n// If you create many Sandbox instances, sharing a connection pool is recommended to save resources.\n// SDK default keep-alive is 30 seconds for its own pools.\nConnectionPool sharedPool = new ConnectionPool(50, 30, TimeUnit.SECONDS);\n\nConnectionConfig sharedConfig = ConnectionConfig.builder()\n    .apiKey(\"your-key\")\n    .domain(\"api.opensandbox.io\")\n    .headers(Map.of(\n        \"X-Custom-Header\", \"value\",\n        \"X-Request-ID\", \"trace-123\"\n    ))\n    .connectionPool(sharedPool) // Inject shared pool\n    .build();\n```\n\n### 2. Sandbox Creation Configuration\n\nThe `Sandbox.builder()` allows configuring the sandbox environment.\n\n| Parameter      | Description                              | Default                         |\n| -------------- | ---------------------------------------- | ------------------------------- |\n| `image`        | Docker image to use                      | Required                        |\n| `timeout`      | Automatic termination timeout            | 10 minutes                      |\n| `entrypoint`   | Container entrypoint command             | `[\"tail\", \"-f\", \"/dev/null\"]`   |\n| `resource`     | CPU and memory limits                    | `{\"cpu\": \"1\", \"memory\": \"2Gi\"}` |\n| `env`          | Environment variables                    | Empty                           |\n| `metadata`     | Custom metadata tags                     | Empty                           |\n| `networkPolicy` | Optional outbound network policy (egress) | -                             |\n| `readyTimeout` | Max time to wait for sandbox to be ready | 30 seconds                      |\n\nNote: metadata keys under `opensandbox.io/` are reserved for system-managed\nlabels and will be rejected by the server.\n\n```java\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy;\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule;\n\nSandbox sandbox = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"python:3.11\")\n    .timeout(Duration.ofMinutes(30))\n    .resource(map -> {\n        map.put(\"cpu\", \"2\");\n        map.put(\"memory\", \"4Gi\");\n    })\n    .env(\"PYTHONPATH\", \"/app\")\n    .metadata(\"project\", \"demo\")\n    .networkPolicy(\n        NetworkPolicy.builder()\n            .defaultAction(NetworkPolicy.DefaultAction.DENY)\n            .addEgress(\n                NetworkRule.builder()\n                    .action(NetworkRule.Action.ALLOW)\n                    .target(\"pypi.org\")\n                    .build()\n            )\n            .build()\n    )\n    .build();\n```\n\n### 3. Runtime Egress Policy Updates\n\nRuntime egress reads and patches go directly to the sandbox egress sidecar.\nThe SDK first resolves the sandbox endpoint on port `18080`, then calls the sidecar `/policy` API.\n\nPatch uses merge semantics:\n- Incoming rules take priority over existing rules with the same `target`.\n- Existing rules for other targets remain unchanged.\n- Within a single patch payload, the first rule for a `target` wins.\n- The current `defaultAction` is preserved.\n\n```java\nNetworkPolicy policy = sandbox.getEgressPolicy();\n\nsandbox.patchEgressRules(\n    List.of(\n        NetworkRule.builder().action(NetworkRule.Action.ALLOW).target(\"www.github.com\").build(),\n        NetworkRule.builder().action(NetworkRule.Action.DENY).target(\"pypi.org\").build()\n    )\n);\n```\n"
  },
  {
    "path": "sdks/sandbox/kotlin/README_zh.md",
    "content": "# Alibaba Sandbox SDK for Kotlin\n\n中文 | [English](README.md)\n\n用于与 OpenSandbox 进行底层交互的 Kotlin SDK。它提供了创建、管理和与安全沙箱环境交互的能力，包括执行 Shell 命令、管理文件和监控资源。\n\n## 安装指南\n\n### Gradle (Kotlin DSL)\n\n```kotlin\ndependencies {\n    implementation(\"com.alibaba.opensandbox:sandbox:{latest_version}\")\n}\n```\n\n### Maven\n\n```xml\n<dependency>\n    <groupId>com.alibaba.opensandbox</groupId>\n    <artifactId>sandbox</artifactId>\n    <version>{latest_version}</version>\n</dependency>\n```\n\n## 快速开始\n\n以下示例展示了如何创建一个沙箱并执行 Shell 命令。\n\n> **注意**: 在运行此示例之前，请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。\n\n```java\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest;\n\npublic class QuickStart {\n    public static void main(String[] args) {\n        // 1. 配置连接信息\n        ConnectionConfig config = ConnectionConfig.builder()\n            .domain(\"api.opensandbox.io\")\n            .apiKey(\"your-api-key\")\n            .build();\n\n        // 2. 使用 try-with-resources 创建 Sandbox\n        try (Sandbox sandbox = Sandbox.builder()\n                .connectionConfig(config)\n                .image(\"ubuntu\")\n                .build()) {\n\n            // 3. 执行 Shell 命令\n            Execution execution = sandbox\n                    .commands()\n                    .run(\"echo 'Hello Sandbox!'\");\n\n            // 4. 打印输出\n            System.out.println(execution.getLogs().getStdout().get(0).getText());\n\n            // 5. 清理资源 (自动调用 sandbox.close())\n            // 注意: 如果希望立即终止远程沙箱实例，仍需显式调用 kill()\n            sandbox.kill();\n        } catch (SandboxException e) {\n            // 处理 Sandbox 特定异常\n            System.err.println(\"沙箱错误: [\" + e.getError().getCode() + \"] \" + e.getError().getMessage());\n            System.err.println(\"Request ID: \" + e.getRequestId());\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n    }\n}\n```\n\n## 核心功能示例\n\n### 1. 生命周期管理\n\n管理沙箱的生命周期，包括续期、暂停、恢复和状态查询。\n\n```java\n// 续期沙箱\n// 此操作将沙箱的过期时间重置为 (当前时间 + duration)\nsandbox.renew(Duration.ofMinutes(30));\n\n// 暂停执行 (挂起所有进程)\nsandbox.pause();\n\n// 恢复执行\nsandbox.resume();\n\n// 获取当前状态\nSandboxInfo info = sandbox.getInfo();\nSystem.out.println(\"当前状态: \" + info.getStatus().getState());\nSystem.out.println(\"过期时间: \" + info.getExpiresAt()); // 使用手动清理模式时为 null\n```\n\n通过传入 `timeout(null)` 创建一个不会自动过期的沙箱：\n\n```java\nSandbox manual = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"ubuntu\")\n    .timeout(null)\n    .build();\n```\n\n### 2. 自定义健康检查\n\n定义自定义逻辑来判断沙箱是否健康。这可以覆盖默认的 Ping 检查。\n\n```java\nSandbox sandbox = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"nginx:latest\")\n    // 自定义检查：等待 80 端口可访问\n    .healthCheck(sb -> {\n        try {\n            // 1. 获取沙箱 80 端口映射的外部访问地址\n            SandboxEndpoint endpoint = sb.getEndpoint(80);\n\n            // 2. 执行你的连接检查逻辑 (例如 HTTP 请求, Socket 连接等)\n            // return checkConnection(endpoint.getEndpoint());\n            return true;\n        } catch (Exception e) {\n            return false;\n        }\n    })\n    .build();\n```\n\n### 3. 命令执行与流式响应\n\n执行命令并实时处理输出流。\n\n```java\n// 创建流式输出处理器\nExecutionHandlers handlers = ExecutionHandlers.builder()\n    .onStdout(msg -> System.out.println(\"STDOUT: \" + msg.getText()))\n    .onStderr(msg -> System.err.println(\"STDERR: \" + msg.getText()))\n    .onExecutionComplete(complete ->\n        System.out.println(\"命令执行耗时: \" + complete.getExecutionTimeInMillis() + \"ms\")\n    )\n    .build();\n\n// 带处理器的命令执行\nRunCommandRequest request = RunCommandRequest.builder()\n    .command(\"for i in {1..5}; do echo \\\"Count $i\\\"; sleep 0.5; done\")\n    .handlers(handlers)\n    .build();\n\nsandbox.commands().run(request);\n```\n\n### 4. 全面的文件操作\n\n管理文件和目录，包括读写、列表、删除和搜索。\n\n```java\n// 1. 写入文件\nsandbox.files().write(List.of(\n    WriteEntry.builder()\n        .path(\"/tmp/hello.txt\")\n        .data(\"Hello World\")\n        .mode(644)\n        .build()\n));\n\n// 2. 读取文件\nString content = sandbox.files().readFile(\"/tmp/hello.txt\", \"UTF-8\", null);\nSystem.out.println(\"文件内容: \" + content);\n\n// 3. 搜索/列表文件\nList<EntryInfo> files = sandbox.files().search(\n    SearchEntry.builder()\n        .path(\"/tmp\")\n        .pattern(\"*.txt\")\n        .build()\n);\nfiles.forEach(f -> System.out.println(\"找到文件: \" + f.getPath()));\n\n// 4. 删除文件\nsandbox.files().deleteFiles(List.of(\"/tmp/hello.txt\"));\n```\n\n### 5. 沙箱管理 (Sandbox Manager)\n\n使用 `SandboxManager` 进行管理操作，如查询现有沙箱列表。\n\n```java\nSandboxManager manager = SandboxManager.builder()\n    .connectionConfig(config)\n    .build();\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState;\n\n// ...\n\n// 列出运行中的沙箱\nPagedSandboxInfos sandboxes = manager.listSandboxInfos(\n    SandboxFilter.builder()\n        .states(SandboxState.RUNNING)\n        .pageSize(10)\n        .page(1)\n        .build()\n);\n\nsandboxes.getSandboxInfos().forEach(info -> {\n    System.out.println(\"Found sandbox: \" + info.getId());\n    // 执行管理操作\n    manager.killSandbox(info.getId());\n});\n\n// Try-with-resources 会自动调用 manager.close()\n// manager.close();\n```\n\n## 配置说明\n\n### 1. 连接配置 (Connection Configuration)\n\n`ConnectionConfig` 类管理与 API 服务器的连接设置。\n\n| 参数             | 描述                         | 默认值                   | 环境变量               |\n| ---------------- | ---------------------------- | ------------------------ | ---------------------- |\n| `apiKey`         | 用于认证的 API Key           | 必填                     | `OPEN_SANDBOX_API_KEY` |\n| `domain`         | 沙箱服务的端点域名           | 必填 (或 localhost:8080) | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`       | HTTP 协议 (http/https)       | `http`                   | -                      |\n| `requestTimeout` | API 请求超时时间             | 30 秒                    | -                      |\n| `debug`          | 是否开启 HTTP 请求的调试日志 | `false`                  | -                      |\n| `headers`        | 自定义 HTTP 请求头           | 空                       | -                      |\n| `connectionPool` | 共享 OKHttp 连接池           | SDK 每实例创建            | -                      |\n| `useServerProxy` | 是否通过沙箱服务代理访问 execd/endpoint（适用于客户端无法直连沙箱的场景） | `false` | -                      |\n\n```java\n// 1. 基础配置\nConnectionConfig config = ConnectionConfig.builder()\n    .apiKey(\"your-key\")\n    .domain(\"api.opensandbox.io\")\n    .requestTimeout(Duration.ofSeconds(60))\n    .build();\n\n// 2. 进阶配置：共享连接池 (Shared Connection Pool)\n// 如果你需要创建大量 Sandbox 实例，建议共享连接池以节省资源。\n// SDK 默认连接保活时间为 30 秒。\nConnectionPool sharedPool = new ConnectionPool(50, 30, TimeUnit.SECONDS);\n\nConnectionConfig sharedConfig = ConnectionConfig.builder()\n    .apiKey(\"your-key\")\n    .domain(\"api.opensandbox.io\")\n    .headers(Map.of(\n        \"X-Custom-Header\", \"value\",\n        \"X-Request-ID\", \"trace-123\"\n    ))\n    .connectionPool(sharedPool) // 注入共享连接池\n    .build();\n```\n\n### 2. 沙箱创建配置 (Sandbox Creation Configuration)\n\n`Sandbox.builder()` 用于配置沙箱环境。\n\n| 参数           | 描述                   | 默认值                          |\n| -------------- | ---------------------- | ------------------------------- |\n| `image`        | 使用的 Docker 镜像     | 必填                            |\n| `timeout`      | 自动终止的超时时间     | 10 分钟                         |\n| `entrypoint`   | 容器启动入口命令       | `[\"tail\", \"-f\", \"/dev/null\"]`   |\n| `resource`     | CPU 和内存限制         | `{\"cpu\": \"1\", \"memory\": \"2Gi\"}` |\n| `env`          | 环境变量               | 空                              |\n| `metadata`     | 自定义元数据标签       | 空                              |\n| `networkPolicy` | 可选的出站网络策略（egress） | -                         |\n| `readyTimeout` | 等待沙箱就绪的最大时间 | 30 秒                           |\n\n注意：`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签，服务端会拒绝用户传入。\n\n```java\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy;\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule;\n\nSandbox sandbox = Sandbox.builder()\n    .connectionConfig(config)\n    .image(\"python:3.11\")\n    .timeout(Duration.ofMinutes(30))\n    .resource(map -> {\n        map.put(\"cpu\", \"2\");\n        map.put(\"memory\", \"4Gi\");\n    })\n    .env(\"PYTHONPATH\", \"/app\")\n    .metadata(\"project\", \"demo\")\n    .networkPolicy(\n        NetworkPolicy.builder()\n            .defaultAction(NetworkPolicy.DefaultAction.DENY)\n            .addEgress(\n                NetworkRule.builder()\n                    .action(NetworkRule.Action.ALLOW)\n                    .target(\"pypi.org\")\n                    .build()\n            )\n            .build()\n    )\n    .build();\n```\n\n### 3. 运行时 Egress 策略更新\n\n运行时的 egress 查询和 patch 会直接访问沙箱内的 egress sidecar。\nSDK 会先解析 `18080` 端口对应的 sandbox endpoint，再调用 sidecar 的 `/policy` API。\n\n```java\nNetworkPolicy policy = sandbox.getEgressPolicy();\n\nsandbox.patchEgressRules(\n    List.of(\n        NetworkRule.builder().action(NetworkRule.Action.ALLOW).target(\"www.github.com\").build(),\n        NetworkRule.builder().action(NetworkRule.Action.DENY).target(\"pypi.org\").build()\n    )\n);\n```\n"
  },
  {
    "path": "sdks/sandbox/kotlin/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"UnstableApiUsage\")\n\nimport org.gradle.api.GradleException\nimport org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension\n\nfun Project.resolveVersionFromTag(expectedTagPrefix: String): String? {\n    val refName = System.getenv(\"GITHUB_REF_NAME\") ?: System.getenv(\"GITHUB_REF\")?.removePrefix(\"refs/tags/\")\n    val fromEnv =\n        refName\n            ?.trim()\n            ?.takeIf { it.startsWith(expectedTagPrefix) }\n            ?.removePrefix(expectedTagPrefix)\n            ?.trim()\n            ?.takeIf { it.isNotEmpty() }\n    return fromEnv\n}\n\nbuildscript {\n    repositories {\n        mavenCentral()\n        gradlePluginPortal()\n    }\n\n    dependencies {\n        classpath(libs.bundles.jackson.build)\n    }\n}\n\nplugins {\n    alias(libs.plugins.kotlin.jvm) apply false\n    alias(libs.plugins.kotlin.serialization) apply false\n    alias(libs.plugins.dokka) apply false\n    alias(libs.plugins.spotless)\n    alias(libs.plugins.mavenPublish) apply false\n}\n\nval manualProjectVersion = project.findProperty(\"project.version\") as String\nval tagVersion =\n    project.resolveVersionFromTag(\n        expectedTagPrefix = \"java/sandbox/v\",\n    )\n\nif (tagVersion != null && tagVersion != manualProjectVersion) {\n    throw GradleException(\n        \"Ref/tag version mismatch: expected version '$manualProjectVersion' from gradle.properties, \" +\n            \"but got '$tagVersion' from tag 'java/sandbox/v...'. Please align the tag and project.version.\",\n    )\n}\n\nextra[\"project.version\"] = manualProjectVersion\n\nallprojects {\n    group = project.findProperty(\"project.group\") as String\n    version = manualProjectVersion\n\n    repositories {\n        mavenCentral()\n    }\n}\n\nconfigure<com.diffplug.gradle.spotless.SpotlessExtension> {\n    kotlin {\n        target(\"**/*.kt\")\n        targetExclude(\"**/build/**/*.kt\", \"**/bin/**/*.kt\", \"**/generated/**/*.kt\")\n        ktlint()\n    }\n    kotlinGradle {\n        target(\"**/*.gradle.kts\")\n        ktlint()\n    }\n}\n\nval kotlinJvmId = libs.plugins.kotlin.jvm.get().pluginId\nval kotlinSerializationId = libs.plugins.kotlin.serialization.get().pluginId\nval dokkaId = libs.plugins.dokka.get().pluginId\nval mavenPublishId = libs.plugins.mavenPublish.get().pluginId\n\nsubprojects {\n    apply(plugin = mavenPublishId)\n\n    if (name != \"sandbox-bom\") {\n        apply(plugin = kotlinJvmId)\n        apply(plugin = kotlinSerializationId)\n        apply(plugin = dokkaId)\n\n        configure<KotlinJvmProjectExtension> {\n            jvmToolchain(8)\n            compilerOptions {\n                javaParameters.set(true)\n                freeCompilerArgs.add(\"-Xjvm-default=all\")\n            }\n        }\n    }\n\n    // Include license file in published artifacts (jars/sources jars) for compliance and clarity.\n    tasks.withType<Jar>().configureEach {\n        from(rootProject.file(\"LICENSE\")) {\n            into(\"META-INF\")\n        }\n    }\n\n    configure<com.vanniktech.maven.publish.MavenPublishBaseExtension> {\n        coordinates(project.group.toString(), project.name, project.version.toString())\n        publishToMavenCentral()\n        if (!gradle.startParameter.taskNames.any { it.contains(\"publishToMavenLocal\") }) {\n            signAllPublications()\n        }\n        pom {\n            name.set(project.name)\n            description.set(\"Alibaba Open Sandbox SDK\")\n            inceptionYear.set(\"2025\")\n            url.set(\"https://github.com/alibaba/OpenSandbox\")\n            licenses {\n                license {\n                    name.set(\"The Apache License, Version 2.0\")\n                    url.set(\"https://www.apache.org/licenses/LICENSE-2.0.txt\")\n                    distribution.set(\"repo\")\n                }\n            }\n            developers {\n                developer {\n                    id.set(\"alibaba\")\n                    name.set(\"Alibaba Group\")\n                    url.set(\"https://github.com/alibaba\")\n                    email.set(\"ninan.nn@alibaba-inc.com\")\n                }\n            }\n            scm {\n                url.set(\"https://github.com/alibaba/OpenSandbox\")\n                connection.set(\"scm:git:https://github.com/alibaba/OpenSandbox.git\")\n                developerConnection.set(\"scm:git:ssh://git@github.com/alibaba/OpenSandbox.git\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/gradle/libs.versions.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[versions]\nkotlin = \"2.2.21\"\nkotlinx-serialization = \"1.9.0\"\nokhttp = \"4.12.0\"\nslf4j = \"2.0.9\"\njunit = \"5.10.1\"\nmockk = \"1.13.8\"\nspotless = \"6.23.3\"\nmaven-publish = \"0.35.0\"\ndokka = \"1.9.10\"\nopenapi-generator = \"7.17.0\"\njackson = \"2.18.2\"\njunit-platform = \"1.13.4\"\n\n\n[libraries]\n# Kotlin\nkotlin-stdlib = { module = \"org.jetbrains.kotlin:kotlin-stdlib\", version.ref = \"kotlin\" }\n\n# HTTP\nokhttp = { module = \"com.squareup.okhttp3:okhttp\", version.ref = \"okhttp\" }\nokhttp-logging = { module = \"com.squareup.okhttp3:logging-interceptor\", version.ref = \"okhttp\" }\nokhttp-mockwebserver = { module = \"com.squareup.okhttp3:mockwebserver\", version.ref = \"okhttp\" }\n\n# Serialization\nkotlinx-serialization-json = { module = \"org.jetbrains.kotlinx:kotlinx-serialization-json\", version.ref = \"kotlinx-serialization\" }\n\n# Logging\nslf4j-api = { module = \"org.slf4j:slf4j-api\", version.ref = \"slf4j\" }\n\n# Testing\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\nmockk = { module = \"io.mockk:mockk\", version.ref = \"mockk\" }\njunit-platform-launcher = { module = \"org.junit.platform:junit-platform-launcher\", version = \"junit-platform\" }\n\n\n# Jackson(build-time)\njackson-core = { module = \"com.fasterxml.jackson.core:jackson-core\", version.ref = \"jackson\" }\njackson-databind = { module = \"com.fasterxml.jackson.core:jackson-databind\", version.ref = \"jackson\" }\njackson-yaml = { module = \"com.fasterxml.jackson.dataformat:jackson-dataformat-yaml\", version.ref = \"jackson\" }\njackson-kotlin = { module = \"com.fasterxml.jackson.module:jackson-module-kotlin\", version.ref = \"jackson\" }\n\n[plugins]\nkotlin-jvm = { id = \"org.jetbrains.kotlin.jvm\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"kotlin\" }\nspotless = { id = \"com.diffplug.spotless\", version.ref = \"spotless\" }\nmavenPublish = { id = \"com.vanniktech.maven.publish\", version.ref = \"maven-publish\" }\ndokka = { id = \"org.jetbrains.dokka\", version.ref = \"dokka\" }\nopenapi-generator = { id = \"org.openapi.generator\", version.ref = \"openapi-generator\" }\n\n[bundles]\nserialization = [\"kotlinx-serialization-json\"]\ntesting = [\"junit-jupiter\", \"mockk\", \"okhttp-mockwebserver\"]\njackson-build = [\"jackson-core\", \"jackson-databind\", \"jackson-yaml\", \"jackson-kotlin\"]\n"
  },
  {
    "path": "sdks/sandbox/kotlin/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.2.1-all.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "sdks/sandbox/kotlin/gradle.properties",
    "content": "# Build optimization\norg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\norg.gradle.caching=true\norg.gradle.parallel=true\n\n# Project metadata\nproject.group=com.alibaba.opensandbox\nproject.version=1.0.5\nproject.description=A Kotlin SDK for Open Sandbox API\n"
  },
  {
    "path": "sdks/sandbox/kotlin/gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/Module.md",
    "content": "# Module sandbox\nThe Open Sandbox SDK provides a comprehensive interface for creating and managing secure, isolated execution environments. Built with Kotlin and designed for both Kotlin and Java applications, it offers high-level abstractions for container-based sandboxing with advanced features like file system operations, command execution, and lifecycle management.\n\n## Features\n\n- **🔒 Secure Isolation**: Complete Linux OS access in isolated containers\n- **📁 File System Operations**: Create, read, update, delete files and directories\n- **⚡ Multi-language Execution**: Support for Python, Java, Bash, and other languages\n- **🎛️ Real-time Command Execution**: Streaming output with timeout handling\n- **📊 Resource Management**: CPU, memory, and storage constraints\n- **🔄 Lifecycle Management**: Create, pause, resume, terminate operations\n- **💚 Health Monitoring**: Automatic readiness detection and status tracking\n- **🏗️ Fluent API**: Type-safe builder pattern with DSL support\n\n## Quick Start\n\n### Basic Usage\n\n```kotlin\n// Create a simple Python sandbox\nval sandbox = Sandbox.builder()\n    .image(\"python:3.11\")\n    .build()\n\n// Write and execute code\nsandbox.filesystem.writeFile(\"hello.py\", \"print('Hello, World!')\")\nval result = sandbox.commands.execute(\"python hello.py\")\nprintln(result.stdout) // Output: Hello, World!\n\n// Clean up\nsandbox.terminate()\n```\n\n### Advanced Configuration\n\n```kotlin\nval sandbox = Sandbox.builder()\n    .image(\"myregistry.com/app:latest\")\n    .imageAuth(\"username\", \"password\")\n    .resource {\n        put(\"cpu\", \"1000m\")      // 1 CPU core\n        put(\"memory\", \"2Gi\")     // 2 GB RAM\n        put(\"gpu\", \"1\")          // 1 GPU device\n    }\n    .environment {\n        put(\"DEBUG\", \"true\")\n        put(\"LOG_LEVEL\", \"info\")\n    }\n    .metadata {\n        put(\"project\", \"my-project\")\n        put(\"team\", \"backend\")\n    }\n    .timeout(Duration.ofMinutes(30))\n    .readyTimeout(Duration.ofSeconds(120))\n    .build()\n```\n\n### File System Operations\n\n```kotlin\n// File operations\nsandbox.filesystem.writeFile(\"config.json\", \"\"\"{\"debug\": true}\"\"\")\nval content = sandbox.filesystem.readFile(\"config.json\")\nval exists = sandbox.filesystem.exists(\"config.json\")\n\n// Directory operations\nsandbox.filesystem.createDirectory(\"workspace\")\nval files = sandbox.filesystem.listDirectory(\"workspace\")\n\n// Advanced operations\nsandbox.filesystem.copy(\"source.txt\", \"backup.txt\")\nsandbox.filesystem.move(\"old.txt\", \"new.txt\")\nsandbox.filesystem.setPermissions(\"script.sh\", \"755\")\n```\n\n### Command Execution\n\n```kotlin\n// Synchronous execution\nval result = sandbox.commands.execute(\"ls -la\")\nprintln(\"Exit code: ${result.exitCode}\")\nprintln(\"Output: ${result.stdout}\")\n\n// With environment and working directory\nval result = sandbox.commands.execute(\n    command = \"npm install\",\n    workingDirectory = \"/app\",\n    environment = mapOf(\"NODE_ENV\" to \"production\"),\n    timeout = Duration.ofMinutes(5)\n)\n\n// Streaming execution\nsandbox.commands.executeStreaming(\"long-running-task\").collect { event ->\n    when (event) {\n        is StreamEvent.Stdout -> print(event.data)\n        is StreamEvent.Stderr -> System.err.print(event.data)\n        is StreamEvent.Completed -> println(\"Exit code: ${event.exitCode}\")\n        is StreamEvent.Error -> println(\"Error: ${event.message}\")\n    }\n}\n```\n\n## Key Components\n\n### Sandbox\nThe primary interface for interacting with sandbox environments. Provides methods for:\n- Creating new sandbox instances with fluent configuration\n- Connecting to existing sandboxes by ID\n- Managing sandbox lifecycle (pause, resume, terminate)\n- Accessing file system and command execution capabilities\n- Health monitoring and status checking\n\n### SandboxBuilder\nA fluent builder for configuring sandbox creation with:\n- Container image specification with authentication\n- Resource limits (CPU, memory, GPU)\n- Environment variables and metadata\n- Timeout and readiness configuration\n- API client configuration\n\n### Operations Interfaces\n\n#### FileSystemOperations\n- **File Operations**: Read, write, copy, move, delete files\n- **Directory Operations**: Create, list, navigate directories\n- **Metadata Operations**: Get file info, set permissions, check existence\n- **Batch Operations**: Replace multiple files atomically\n\n#### CommandOperations\n- **Synchronous Execution**: Run commands and wait for completion\n- **Streaming Execution**: Real-time output streaming with Flow API\n- **Background Execution**: Non-blocking command execution\n- **Shell Scripts**: Execute multi-line shell scripts\n- **Command Utilities**: Check command availability, get versions\n\n### Domain Models\n- **SandboxState**: Lifecycle states (PROVISIONING, RUNNING, PAUSED, etc.)\n- **ExecutionResult**: Command execution output with exit code and timing\n- **FileInfo**: File system entry information with permissions and metadata\n- **Resource Maps**: Kubernetes-style resource specifications as key-value pairs\n- **StreamEvent**: Real-time command output events\n\n### Infrastructure Layer\n- **ApiClientAdapter**: HTTP client management with authentication and retry logic\n- **SandboxConfig**: Centralized configuration with environment variable support\n- **ModelAdapter**: Translation between OpenAPI models and domain types\n- **Exception Hierarchy**: Specific exceptions for different error scenarios\n\n## Architecture\n\nThe SDK follows a clean architecture with clear separation of concerns:\n\n```\n┌─────────────────────────────────────────┐\n│              Public API                 │\n│         (Sandbox, SandboxBuilder)       │\n├─────────────────────────────────────────┤\n│            Operations Layer             │\n│     (FileSystem, Command, Lifecycle)    │\n├─────────────────────────────────────────┤\n│           Infrastructure Layer          │\n│      (API Clients, Configuration)       │\n├─────────────────────────────────────────┤\n│             Domain Layer                │\n│        (Types, Exceptions, Models)      │\n└─────────────────────────────────────────┘\n```\n\n## Java Interoperability\n\nThe SDK is fully compatible with Java applications:\n\n```java\n// Java usage example\nSandbox sandbox = Sandbox.builder()\n    .image(\"openjdk:11\")\n    .resource(Map.of(\n        \"cpu\", \"1000m\",\n        \"memory\", \"2Gi\"\n    ))\n    .build();\n\nExecutionResult result = sandbox.getCommands().execute(\"java -version\");\nSystem.out.println(\"Java version: \" + result.getStdout());\n\nsandbox.terminate();\n```\n\n## Best Practices\n\n### Resource Management\nAlways use try-with-resources or explicit cleanup:\n\n```kotlin\n// Using AutoCloseable\nSandbox.builder()\n    .image(\"python:3.11\")\n    .build()\n    .use { sandbox ->\n        // Use sandbox - automatically terminated when exiting\n        sandbox.filesystem.writeFile(\"script.py\", \"print('Hello')\")\n        sandbox.commands.execute(\"python script.py\")\n    }\n```\n\n### Error Handling\nHandle specific exception types:\n\n```kotlin\ntry {\n    val sandbox = Sandbox.builder().image(\"python:3.11\").build()\n} catch (e: AuthenticationException) {\n    // Handle auth errors\n} catch (e: TimeoutException) {\n    // Handle timeouts\n} catch (e: SandboxException) {\n    // Handle general sandbox errors\n}\n```\n\n## Usage Examples\n\nSee the [samples](../../samples/) directory for comprehensive usage examples including:\n- Basic sandbox creation and usage\n- Advanced configuration scenarios\n- File system operations\n- Command execution patterns\n- Error handling strategies\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\ndependencies {\n    implementation(project(\":sandbox-api\"))\n    api(libs.kotlin.stdlib)\n    api(libs.slf4j.api)\n\n    implementation(libs.okhttp)\n    implementation(libs.okhttp.logging)\n    implementation(libs.bundles.serialization)\n\n    testImplementation(libs.bundles.testing)\n    testRuntimeOnly(libs.junit.platform.launcher)\n}\n\n// Configure test tasks to use JDK 17\ntasks.withType<Test> {\n    javaLauncher.set(\n        javaToolchains.launcherFor {\n            languageVersion.set(JavaLanguageVersion.of(17))\n        },\n    )\n    useJUnitPlatform()\n}\n\n// Configure test compilation to use JDK 17\ntasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {\n    if (name.contains(\"test\", ignoreCase = true)) {\n        compilerOptions {\n            jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)\n        }\n    }\n    compilerOptions {\n        javaParameters.set(true)\n    }\n}\n\ntasks.withType<JavaCompile> {\n    if (name.contains(\"test\", ignoreCase = true)) {\n        javaCompiler.set(\n            javaToolchains.compilerFor {\n                languageVersion.set(JavaLanguageVersion.of(17))\n            },\n        )\n    }\n}\n\ntasks.withType<org.jetbrains.dokka.gradle.DokkaTask>().configureEach {\n    dokkaSourceSets {\n        named(\"main\") {\n            moduleName.set(\"Sandbox\")\n            includes.from(\"Module.md\")\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/HttpClientProvider.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox\n\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport okhttp3.ConnectionPool\nimport okhttp3.Interceptor\nimport okhttp3.OkHttpClient\nimport okhttp3.Response\nimport okhttp3.logging.HttpLoggingInterceptor\nimport org.slf4j.LoggerFactory\nimport java.util.concurrent.TimeUnit\n\n/**\n * Provider that manages HTTP client instances with proper configuration.\n */\nclass HttpClientProvider(\n    val config: ConnectionConfig,\n) : AutoCloseable {\n    private val logger = LoggerFactory.getLogger(HttpClientProvider::class.java)\n\n    private val defaultMaxIdleConnections = 32\n    private val defaultKeepAliveDurationSeconds = 30L\n\n    private val connectionPool =\n        config.connectionPool ?: ConnectionPool(defaultMaxIdleConnections, defaultKeepAliveDurationSeconds, TimeUnit.SECONDS)\n\n    private val connectionPoolOwnedBySdk: Boolean = config.connectionPool == null\n\n    private val baseBuilder: OkHttpClient.Builder\n        get() =\n            OkHttpClient.Builder()\n                .connectionPool(connectionPool)\n                .addInterceptor(UserAgentInterceptor(config.userAgent))\n                .addInterceptor(ExtraHeadersInterceptor(config.headers))\n\n    // 1. Explicit lazy definition to allow checking initialization status\n    private val httpClientLazy =\n        lazy {\n            baseBuilder\n                .applyStandardTimeouts()\n                .addLoggingInterceptor()\n                .build()\n        }\n\n    val httpClient: OkHttpClient by httpClientLazy\n\n    // 2. Explicit lazy definition for authenticated client\n    private val authenticatedClientLazy =\n        lazy {\n            baseBuilder\n                .applyStandardTimeouts()\n                .addInterceptor(AuthenticationInterceptor(config.getApiKey())) // Add auth before logging\n                .addLoggingInterceptor()\n                .build()\n        }\n\n    val authenticatedClient: OkHttpClient by authenticatedClientLazy\n\n    // 3. Explicit lazy definition for SSE client\n    private val sseClientLazy =\n        lazy {\n            baseBuilder\n                .connectTimeout(config.requestTimeout.toMillis(), TimeUnit.MILLISECONDS)\n                .readTimeout(0, TimeUnit.MILLISECONDS)\n                .writeTimeout(config.requestTimeout.toMillis(), TimeUnit.MILLISECONDS)\n                .callTimeout(0, TimeUnit.MILLISECONDS)\n                .addInterceptor(ExtraHeadersInterceptor(getSseHeaders()))\n                .addLoggingInterceptor()\n                .build()\n        }\n\n    val sseClient: OkHttpClient by sseClientLazy\n\n    // --- Helper Extensions ---\n\n    private fun OkHttpClient.Builder.applyStandardTimeouts(): OkHttpClient.Builder {\n        val timeout = config.requestTimeout.toMillis()\n        return this.connectTimeout(timeout, TimeUnit.MILLISECONDS)\n            .readTimeout(timeout, TimeUnit.MILLISECONDS)\n            .writeTimeout(timeout, TimeUnit.MILLISECONDS)\n            .callTimeout(timeout, TimeUnit.MILLISECONDS)\n    }\n\n    private fun OkHttpClient.Builder.addLoggingInterceptor(): OkHttpClient.Builder {\n        if (config.debug) {\n            val loggingInterceptor =\n                HttpLoggingInterceptor { message ->\n                    logger.debug(message)\n                }.apply {\n                    level = HttpLoggingInterceptor.Level.HEADERS\n                    // Redact sensitive headers in logs\n                    redactHeader(\"OPEN-SANDBOX-API-KEY\")\n                    redactHeader(\"Authorization\")\n                }\n            addInterceptor(loggingInterceptor)\n        }\n        return this\n    }\n\n    private fun getSseHeaders(): Map<String, String> {\n        return mapOf(\n            \"Accept\" to \"text/event-stream\",\n            \"Cache-Control\" to \"no-cache\",\n        )\n    }\n\n    // --- Interceptors ---\n\n    private class UserAgentInterceptor(private val userAgent: String) : Interceptor {\n        override fun intercept(chain: Interceptor.Chain): Response {\n            return chain.proceed(\n                chain.request().newBuilder()\n                    .header(\"User-Agent\", userAgent)\n                    .build(),\n            )\n        }\n    }\n\n    private class AuthenticationInterceptor(private val apiKey: String) : Interceptor {\n        override fun intercept(chain: Interceptor.Chain): Response {\n            return chain.proceed(\n                chain.request().newBuilder()\n                    .header(\"OPEN-SANDBOX-API-KEY\", apiKey)\n                    .build(),\n            )\n        }\n    }\n\n    private class ExtraHeadersInterceptor(private val headers: Map<String, String>) : Interceptor {\n        override fun intercept(chain: Interceptor.Chain): Response {\n            if (headers.isEmpty()) return chain.proceed(chain.request())\n\n            val builder = chain.request().newBuilder()\n            headers.forEach { (name, value) ->\n                builder.addHeader(name, value)\n            }\n            return chain.proceed(builder.build())\n        }\n    }\n\n    // --- Cleanup ---\n\n    /**\n     * Closes the underlying HTTP client and releases resources.\n     */\n    override fun close() {\n        // Now we can pass the specific backing fields to check initialization\n        shutdownClientQuietly(httpClientLazy, \"http client\")\n        shutdownClientQuietly(authenticatedClientLazy, \"authenticated client\")\n        shutdownClientQuietly(sseClientLazy, \"sse client\")\n\n        if (connectionPoolOwnedBySdk && !config.connectionPoolManagedByUser) {\n            try {\n                connectionPool.evictAll()\n            } catch (e: Exception) {\n                logger.warn(\"Error evicting connection pool\", e)\n            }\n        }\n    }\n\n    private fun shutdownClientQuietly(\n        lazyClient: Lazy<OkHttpClient>,\n        name: String,\n    ) {\n        if (lazyClient.isInitialized()) {\n            try {\n                val client = lazyClient.value\n                client.dispatcher.cancelAll()\n                client.dispatcher.executorService.shutdownNow()\n            } catch (e: Exception) {\n                logger.warn(\"Error closing $name\", e)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox\n\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxInternalException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EGRESS_PORT\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.DEFAULT_EXECD_PORT\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume\nimport com.alibaba.opensandbox.sandbox.domain.services.Commands\nimport com.alibaba.opensandbox.sandbox.domain.services.Egress\nimport com.alibaba.opensandbox.sandbox.domain.services.Filesystem\nimport com.alibaba.opensandbox.sandbox.domain.services.Health\nimport com.alibaba.opensandbox.sandbox.domain.services.Metrics\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory\nimport org.slf4j.LoggerFactory\nimport java.time.Duration\nimport java.time.OffsetDateTime\n\n/**\n * Main entrypoint for the Open Sandbox SDK providing secure, isolated execution environments.\n *\n * This class provides a comprehensive interface for interacting with containerized sandbox\n * environments, combining lifecycle management with high-level operations for file system\n * access, command execution, and real-time monitoring.\n *\n * ## Key Features\n *\n * - **Secure Isolation**: Complete Linux OS access in isolated containers\n * - **File System Operations**: Create, read, update, delete files and directories\n * - **Multi-language Execution**: Support for Python, Java, Bash, and other languages\n * - **Real-time Command Execution**: Streaming output with timeout handling\n * - **Resource Management**: CPU, memory, and storage constraints\n * - **Lifecycle Management**: Create, pause, resume, terminate operations\n * - **Health Monitoring**: Automatic readiness detection and status tracking\n *\n * ## Usage Example\n *\n * ```kotlin\n * // Create and configure a sandbox\n * val sandbox = Sandbox.builder()\n *     .image(\"python:3.11\")\n *     .resource(mapOf(\"cpu\" to \"1\", \"memory\" to \"500Mi\"))\n *     .timeout(Duration.ofMinutes(30))\n *     .build()\n *\n * // Use the sandbox\n * sandbox.writeFile(\"script.py\", \"print('Hello World')\")\n * val result = sandbox.execute(\"python script.py\")\n * println(result.stdout) // Output: Hello World\n *\n * // Always clean up resources\n * sandbox.terminate()\n * ```\n *\n */\nclass Sandbox internal constructor(\n    val id: String,\n    private val sandboxService: Sandboxes,\n    private val fileSystemService: Filesystem,\n    private val commandService: Commands,\n    private val healthService: Health,\n    private val metricsService: Metrics,\n    private val egressService: Egress,\n    private val customHealthCheck: ((sandbox: Sandbox) -> Boolean)? = null,\n    private val httpClientProvider: HttpClientProvider,\n) : AutoCloseable {\n    private val logger = LoggerFactory.getLogger(Sandbox::class.java)\n\n    /**\n     * Provides access to file system operations within the sandbox.\n     *\n     * Allows writing, reading, listing, and deleting files and directories.\n     *\n     * @return Service for filesystem manipulation\n     */\n    fun files() = fileSystemService\n\n    /**\n     * Provides access to command execution operations.\n     *\n     * Allows running shell commands, capturing output, and managing processes.\n     *\n     * @return Service for command execution\n     */\n    fun commands() = commandService\n\n    /**\n     * Provides access to sandbox metrics and monitoring.\n     *\n     * Allows retrieving resource usage statistics (CPU, memory) and other performance metrics.\n     *\n     * @return Service for metrics retrieval\n     */\n    fun metrics() = metricsService\n\n    /**\n     * Provides access to shared httpclient provider\n     *\n     * Allows retrieving underlying http client resources initialized with connection config\n     */\n    fun httpClientProvider() = httpClientProvider\n\n    companion object {\n        private val logger = LoggerFactory.getLogger(Sandbox::class.java)\n\n        /**\n         * Creates a new [Builder] for fluent sandbox configuration.\n         *\n         * @return A new Builder instance\n         */\n        @JvmStatic\n        fun builder(): Builder = Builder()\n\n        /**\n         * Creates a new [Connector] for fluent sandbox configuration.\n         *\n         * @return A new Connector instance\n         */\n        @JvmStatic\n        fun connector(): Connector = Connector()\n\n        @JvmStatic\n        fun resumer(): Resumer = Resumer()\n\n        /**\n         * Initialization result indicating the type of sandbox being initialized.\n         */\n        private sealed class InitializationResult {\n            abstract val id: String\n\n            data class NewSandbox(override val id: String) : InitializationResult()\n\n            data class ExistingSandbox(override val id: String) : InitializationResult()\n        }\n\n        /**\n         * Common initialization logic for create, connect, and resume operations.\n         *\n         * @param operationName Operation name for logging\n         * @param connectionConfig Connection configuration\n         * @param healthCheck Custom health check function\n         * @param timeout Timeout for readiness check\n         * @param healthCheckPollingInterval Polling interval for health check\n         * @param initAction Initialization action that returns the sandbox ID and type\n         * @return Fully initialized Sandbox instance\n         * @throws SandboxException if initialization fails\n         */\n        private fun initializeSandbox(\n            operationName: String,\n            connectionConfig: ConnectionConfig,\n            healthCheck: ((Sandbox) -> Boolean)?,\n            timeout: Duration,\n            healthCheckPollingInterval: Duration,\n            skipHealthCheck: Boolean,\n            initAction: (Sandboxes) -> InitializationResult,\n        ): Sandbox {\n            logger.info(\"Starting {} operation\", operationName)\n\n            val httpClientProvider = HttpClientProvider(connectionConfig)\n            val factory = AdapterFactory(httpClientProvider)\n            var initResult: InitializationResult? = null\n            var sandboxService: Sandboxes? = null\n\n            try {\n                sandboxService = factory.createSandboxes()\n                initResult = initAction(sandboxService)\n\n                val sandboxId = initResult.id\n\n                val execdEndpoint =\n                    sandboxService.getSandboxEndpoint(\n                        sandboxId,\n                        DEFAULT_EXECD_PORT,\n                        connectionConfig.useServerProxy,\n                    )\n                val fileSystemService = factory.createFilesystem(execdEndpoint)\n                val commandService = factory.createCommands(execdEndpoint)\n                val metricsService = factory.createMetrics(execdEndpoint)\n                val healthService = factory.createHealth(execdEndpoint)\n                val egressEndpoint =\n                    sandboxService.getSandboxEndpoint(\n                        sandboxId,\n                        DEFAULT_EGRESS_PORT,\n                        connectionConfig.useServerProxy,\n                    )\n                val egressService = factory.createEgress(egressEndpoint)\n\n                val sandbox =\n                    Sandbox(\n                        id = sandboxId,\n                        sandboxService = sandboxService,\n                        fileSystemService = fileSystemService,\n                        commandService = commandService,\n                        metricsService = metricsService,\n                        healthService = healthService,\n                        egressService = egressService,\n                        customHealthCheck = healthCheck,\n                        httpClientProvider = httpClientProvider,\n                    )\n\n                if (!skipHealthCheck) {\n                    sandbox.checkReady(timeout, healthCheckPollingInterval)\n                    logger.info(\"{} operation completed for sandbox {}\", operationName, sandboxId)\n                } else {\n                    logger.info(\n                        \"{} operation completed for sandbox {} (skipHealthCheck=true, sandbox may not be ready yet)\",\n                        operationName,\n                        sandboxId,\n                    )\n                }\n\n                return sandbox\n            } catch (e: Exception) {\n                if (initResult is InitializationResult.NewSandbox && sandboxService != null) {\n                    try {\n                        logger.warn(\n                            \"Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: {}\",\n                            initResult.id,\n                        )\n                        sandboxService.killSandbox(initResult.id)\n                    } catch (cleanupEx: Exception) {\n                        logger.error(\"Failed to clean up sandbox {} after creation failure\", initResult.id, cleanupEx)\n                        e.addSuppressed(cleanupEx)\n                    }\n                }\n\n                httpClientProvider.close()\n                when (e) {\n                    is SandboxException -> throw e\n                    else -> {\n                        logger.error(\"Unexpected exception during {}\", operationName, e)\n                        throw SandboxInternalException(\n                            message = \"Failed to $operationName: ${e.message}\",\n                            cause = e,\n                        )\n                    }\n                }\n            }\n        }\n\n        /**\n         * Creates a sandbox instance with the provided configuration.\n         *\n         * @param imageSpec Container image specification\n         * @param entrypoint Sandbox entrypoint command\n         * @param env Environment variables (optional)\n         * @param metadata Metadata for the sandbox (optional)\n         * @param timeout Sandbox timeout (automatic termination time)\n         * @param readyTimeout Timeout for waiting for sandbox readiness\n         * @param resource Resource limits (optional)\n         * @param networkPolicy Optional outbound network policy (egress)\n         * @param connectionConfig Connection configuration\n         * @param healthCheck Custom health check function (optional)\n         * @param healthCheckPollingInterval Polling interval for readiness/health check\n         * @param extensions Optional extension parameters for server-side customized behaviors\n         * @param volumes Optional list of volume mounts for persistent storage\n         * @return Fully configured and ready Sandbox instance\n         * @throws SandboxException if sandbox creation or initialization fails\n         */\n        private fun create(\n            imageSpec: SandboxImageSpec,\n            entrypoint: List<String>,\n            env: Map<String, String>,\n            metadata: Map<String, String>,\n            timeout: Duration?,\n            readyTimeout: Duration,\n            resource: Map<String, String>,\n            networkPolicy: NetworkPolicy?,\n            connectionConfig: ConnectionConfig,\n            healthCheck: ((Sandbox) -> Boolean)? = null,\n            healthCheckPollingInterval: Duration,\n            extensions: Map<String, String>,\n            skipHealthCheck: Boolean,\n            volumes: List<Volume>?,\n        ): Sandbox {\n            val timeoutLabel = if (timeout != null) \"${timeout.seconds}s\" else \"manual-cleanup\"\n            return initializeSandbox(\n                operationName = \"create sandbox with image ${imageSpec.image} (timeout: $timeoutLabel)\",\n                connectionConfig = connectionConfig,\n                healthCheck = healthCheck,\n                timeout = readyTimeout,\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                skipHealthCheck = skipHealthCheck,\n            ) { sandboxService ->\n                val response =\n                    sandboxService.createSandbox(\n                        imageSpec,\n                        entrypoint,\n                        env,\n                        metadata,\n                        timeout,\n                        resource,\n                        networkPolicy,\n                        extensions,\n                        volumes,\n                    )\n                InitializationResult.NewSandbox(response.id)\n            }\n        }\n\n        /**\n         * Connects to an existing sandbox instance by ID.\n         *\n         * This method allows you to reconnect to a previously created sandbox that\n         * is still running, enabling you to resume work or share sandbox access.\n         *\n         * @param sandboxId Unique identifier of the existing sandbox\n         * @return Connected Sandbox instance\n         * @throws SandboxException if connection fails\n         */\n        private fun connect(\n            sandboxId: String,\n            connectionConfig: ConnectionConfig,\n            healthCheck: ((Sandbox) -> Boolean)? = null,\n            connectTimeout: Duration,\n            healthCheckPollingInterval: Duration,\n            skipHealthCheck: Boolean,\n        ): Sandbox {\n            return initializeSandbox(\n                operationName = \"connect to sandbox $sandboxId\",\n                connectionConfig = connectionConfig,\n                healthCheck = healthCheck,\n                timeout = connectTimeout,\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                skipHealthCheck = skipHealthCheck,\n            ) { _ ->\n                InitializationResult.ExistingSandbox(sandboxId)\n            }\n        }\n\n        /**\n         * Resumes a paused sandbox and waits until it becomes healthy.\n         *\n         * This method performs the following steps:\n         * 1. Calls the server-side resume operation to transition the sandbox back to RUNNING.\n         * 2. Re-resolves the execd endpoint (it may change across pause/resume on some backends).\n         * 3. Rebuilds service adapters bound to the endpoint.\n         * 4. Waits for readiness/health with polling until [resumeTimeout] elapses.\n         *\n         * @param sandboxId Sandbox ID to resume\n         * @param connectionConfig Connection configuration\n         * @param healthCheck Optional custom health check; falls back to [Sandbox.ping]\n         * @param resumeTimeout Max time to wait for the sandbox to become ready after resuming\n         * @param healthCheckPollingInterval Polling interval for readiness/health check\n         * @return Resumed and ready Sandbox instance\n         * @throws SandboxException if resume or readiness check fails\n         */\n        private fun resume(\n            sandboxId: String,\n            connectionConfig: ConnectionConfig,\n            healthCheck: ((Sandbox) -> Boolean)? = null,\n            resumeTimeout: Duration,\n            healthCheckPollingInterval: Duration,\n            skipHealthCheck: Boolean,\n        ): Sandbox {\n            return initializeSandbox(\n                operationName = \"resume sandbox $sandboxId\",\n                connectionConfig = connectionConfig,\n                healthCheck = healthCheck,\n                timeout = resumeTimeout,\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                skipHealthCheck = skipHealthCheck,\n            ) { sandboxService ->\n                sandboxService.resumeSandbox(sandboxId)\n                InitializationResult.ExistingSandbox(sandboxId)\n            }\n        }\n    }\n\n    /**\n     * Gets the current status of this sandbox.\n     *\n     * @return Current sandbox status including state and metadata\n     * @throws SandboxException if status cannot be retrieved\n     */\n    fun getInfo(): SandboxInfo {\n        return sandboxService.getSandboxInfo(id)\n    }\n\n    /**\n     * Gets the current status of this sandbox.\n     *\n     * @return Current sandbox status including state and metadata\n     * @throws SandboxException if status cannot be retrieved\n     */\n    fun getEndpoint(port: Int): SandboxEndpoint {\n        return sandboxService.getSandboxEndpoint(id, port, httpClientProvider.config.useServerProxy)\n    }\n\n    /**\n     * Gets the current status of this sandbox.\n     *\n     * @return Current sandbox status including state and metadata\n     */\n    fun getMetrics(): SandboxMetrics {\n        return metricsService.getMetrics(id)\n    }\n\n    /**\n     * Renew the sandbox expiration time to delay automatic termination.\n     *\n     * The new expiration time will be set to the current time plus the provided duration.\n     *\n     * @param timeout Duration to add to the current time to set the new expiration\n     * @throws SandboxException if the operation fails\n     */\n    fun renew(timeout: Duration): SandboxRenewResponse {\n        logger.info(\"Renew sandbox {} timeout, estimated expiration to {}\", id, OffsetDateTime.now().plus(timeout))\n        return sandboxService.renewSandboxExpiration(id, OffsetDateTime.now().plus(timeout))\n    }\n\n    /**\n     * Gets current egress policy for this sandbox.\n     *\n     * @throws SandboxException if operation fails\n     */\n    fun getEgressPolicy(): NetworkPolicy {\n        return egressService.getPolicy()\n    }\n\n    /**\n     * Patches egress rules for this sandbox using sidecar merge semantics.\n     *\n     * Incoming rules take priority over existing rules with the same target.\n     * Existing rules for other targets remain unchanged. Within one patch payload,\n     * the first rule for a target wins. The current defaultAction is preserved.\n     *\n     * @throws SandboxException if operation fails\n     */\n    fun patchEgressRules(rules: List<NetworkRule>) {\n        egressService.patchRules(rules)\n    }\n\n    /**\n     * Pauses the sandbox while preserving its state.\n     *\n     * The sandbox will transition to PAUSED state and can be resumed later.\n     * All running processes will be suspended.\n     *\n     * @throws SandboxException if pause operation fails\n     */\n    fun pause() {\n        logger.info(\"Pausing sandbox: {}\", id)\n        sandboxService.pauseSandbox(id)\n    }\n\n    /**\n     * This method sends a termination signal to the remote sandbox instance, causing it to stop immediately.\n     * This is an irreversible operation.\n     *\n     * Note: This method does NOT close the local `Sandbox` object resources (like connection pools).\n     * You should call `close()` or use a try-with-resources block to clean up local resources.\n     *\n     * @throws SandboxException if termination fails\n     */\n    fun kill() {\n        sandboxService.killSandbox(id)\n    }\n\n    /**\n     * Closes this resource, relinquishing any underlying resources.\n     *\n     * This method closes the local HTTP client resources associated with this sandbox instance.\n     * It does **NOT** terminate the remote sandbox instance. If you wish to terminate the remote\n     * sandbox, call [kill] before closing.\n     *\n     * If this sandbox was created with a user-managed (shared) connection pool, the pool will NOT be closed.\n     * If it was created with a default (dedicated) pool, the pool will be evicted and destroyed.\n     */\n    override fun close() {\n        try {\n            httpClientProvider.close()\n        } catch (e: Exception) {\n            logger.warn(\"Error closing resources\", e)\n        }\n    }\n\n    /**\n     * Waits for the sandbox to pass a custom health check with polling.\n     *\n     * @param timeout Maximum time to wait for health check to pass\n     * @param pollingInterval Time between health check attempts\n     * @throws SandboxReadyTimeoutException if health check doesn't pass within timeout\n     * @throws SandboxException if health check fails\n     */\n    fun checkReady(\n        timeout: Duration,\n        pollingInterval: Duration,\n    ) {\n        logger.info(\"Waiting for sandbox {} to pass health check (timeout: {}s)\", id, timeout.seconds)\n\n        val deadline = System.currentTimeMillis() + timeout.toMillis()\n        var attempt = 0\n        var lastException: Throwable? = null\n\n        while (System.currentTimeMillis() < deadline) {\n            attempt++\n            logger.debug(\"Health check attempt #{} for sandbox {}\", attempt, id)\n\n            val isHealthy =\n                try {\n                    isHealthy()\n                } catch (e: Exception) {\n                    lastException = e\n                    logger.debug(\"Health check attempt #{} failed with exception: {}\", attempt, e.message)\n                    false\n                }\n\n            if (isHealthy) {\n                logger.info(\"Sandbox {} passed health check after {} attempts\", id, attempt)\n                return\n            }\n\n            if (lastException == null) {\n                logger.debug(\"Health check attempt #{} returned false\", attempt)\n            }\n\n            Thread.sleep(pollingInterval.toMillis())\n        }\n\n        val errorDetail =\n            if (lastException != null) {\n                \"Last error: ${lastException.message}\"\n            } else {\n                \"Check returned false continuously\"\n            }\n\n        val context = \"domain=${httpClientProvider.config.getDomain()}, useServerProxy=${httpClientProvider.config.useServerProxy}\"\n        var suggestion =\n            \"If this sandbox runs in Docker bridge or remote-network mode, consider enabling useServerProxy=true.\"\n        if (!httpClientProvider.config.useServerProxy) {\n            suggestion += \" You can also configure server-side [docker].host_ip for direct endpoint access.\"\n        }\n\n        val finalMessage =\n            \"Sandbox health check timed out after ${timeout.seconds}s ($attempt attempts). $errorDetail \" +\n                \"Connection context: $context. $suggestion\"\n\n        logger.error(finalMessage, lastException)\n\n        throw SandboxReadyTimeoutException(\n            message = finalMessage,\n        )\n    }\n\n    /**\n     * Checks if the sandbox is healthy and responsive.\n     *\n     * @return true if sandbox is healthy, false otherwise\n     */\n    fun isHealthy(): Boolean {\n        return customHealthCheck?.invoke(this) ?: ping()\n    }\n\n    /**\n     * Ping execd\n     *\n     * @return `true` if execd is reachable and healthy.\n     */\n    fun ping(): Boolean {\n        return healthService.ping(id)\n    }\n\n    /**\n     * Fluent connector for connecting to existing sandbox instances.\n     *\n     * This class provides a type-safe, fluent interface for configuring connection\n     * parameters to connect to a running sandbox instance.\n     *\n     * ## Basic Usage\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.connector()\n     *     .sandboxId(\"existing-sandbox-id\")\n     *     .build()\n     * ```\n     *\n     * ## Advanced Configuration\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.connector()\n     *     .sandboxId(\"existing-sandbox-id\")\n     *     .apiKey(\"your-api-key\")\n     *     .domain(\"api.custom-domain.com/v1\")\n     *     .requestTimeout(Duration.ofSeconds(60))\n     *     .healthCheck { sandbox -> sandbox.isHealthy() }\n     *     .build()\n     * ```\n     */\n    class Connector internal constructor() {\n        /**\n         * Sandbox ID to connect to\n         */\n        private var sandboxId: String? = null\n\n        /**\n         * Connection config\n         */\n        private var connectionConfig: ConnectionConfig? = null\n\n        /**\n         * Health check logic\n         */\n        private var healthCheck: ((Sandbox) -> Boolean)? = null\n\n        /**\n         * Max time to wait for the sandbox to become ready after connecting\n         */\n        private var connectTimeout: Duration = Duration.ofSeconds(30)\n\n        /**\n         * Polling interval for readiness/health check while waiting for resume\n         */\n        private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)\n\n        /**\n         * When true, do NOT wait for sandbox readiness/health during [connect].\n         *\n         * Default is false (wait until ready).\n         */\n        private var skipHealthCheck: Boolean = false\n\n        /**\n         * Sets the sandbox ID to connect to.\n         *\n         * @param sandboxId ID of the existing sandbox\n         * @return This connector for method chaining\n         * @throws InvalidArgumentException if sandboxId is blank\n         */\n        fun sandboxId(sandboxId: String): Connector {\n            this.sandboxId = sandboxId\n            return this\n        }\n\n        fun healthCheck(healthCheck: (Sandbox) -> Boolean): Connector {\n            this.healthCheck = healthCheck\n            return this\n        }\n\n        fun connectionConfig(connectionConfig: ConnectionConfig): Connector {\n            this.connectionConfig = connectionConfig\n            return this\n        }\n\n        /**\n         * Sets the max time to wait for readiness after the connect operation.\n         */\n        fun connectTimeout(timeout: Duration): Connector {\n            this.connectTimeout = timeout\n            return this\n        }\n\n        /**\n         * Sets the polling interval used while waiting for readiness after connecting.\n         */\n        fun healthCheckPollingInterval(pollingInterval: Duration): Connector {\n            this.healthCheckPollingInterval = pollingInterval\n            return this\n        }\n\n        /**\n         * Skip readiness/health check during [connect]. The returned sandbox may not be ready yet.\n         */\n        fun skipHealthCheck(skip: Boolean = true): Connector {\n            this.skipHealthCheck = skip\n            return this\n        }\n\n        /**\n         * Connects to the existing sandbox with the configured parameters.\n         *\n         * This method performs the following steps:\n         * 1. Validates all required configuration\n         * 2. Delegates to Sandbox.connect() to connect to the sandbox\n         * 3. Returns a connected Sandbox instance\n         *\n         * @return Connected Sandbox instance\n         * @throws InvalidArgumentException if required configuration is missing or invalid\n         * @throws SandboxException if sandbox connection fails\n         */\n        fun connect(): Sandbox {\n            // Validate required configuration\n            val id =\n                sandboxId ?: throw InvalidArgumentException(\n                    message = \"Sandbox ID must be specified\",\n                )\n            return connect(\n                sandboxId = id,\n                connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),\n                healthCheck = healthCheck,\n                connectTimeout = connectTimeout,\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                skipHealthCheck = skipHealthCheck,\n            )\n        }\n    }\n\n    /**\n     * Fluent builder for creating and configuring sandbox instances.\n     *\n     * This class provides a type-safe, fluent interface for configuring all aspects\n     * of sandbox creation, from sandbox images and resource limits to environment\n     * variables and lifecycle settings.\n     *\n     * ## Basic Usage\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.builder()\n     *     .image(\"python:3.11\")\n     *     .build()\n     * ```\n     *\n     * ## Advanced Configuration\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.builder()\n     *     .image(\"myregistry.com/app:latest\")\n     *     .imageAuth(\"username\", \"password\")\n     *     .entrypoint(\"python\", \"-u\", \"app.py\")\n     *     .resource {\n     *         put(\"cpu\", \"1000m\")\n     *         put(\"memory\", \"2Gi\")\n     *     }\n     *     .env {\n     *         put(\"LOG_LEVEL\", \"info\")\n     *     }\n     *     .metadata {\n     *         put(\"project\", \"my-project\")\n     *         put(\"team\", \"backend\")\n     *     }\n     *     .timeout(Duration.ofMinutes(30))\n     *     .readyTimeout(Duration.ofSeconds(120))\n     *     .build()\n     * ```\n     */\n    class Builder internal constructor() {\n        /**\n         * Image config\n         */\n        private var imageSpec: SandboxImageSpec? = null\n\n        /**\n         * Sandbox entrypoint\n         */\n        private var entrypoint: List<String> = listOf(\"tail\", \"-f\", \"/dev/null\")\n\n        /**\n         * Resource limits config\n         */\n        private val resource = mutableMapOf(\"cpu\" to \"1\", \"memory\" to \"2Gi\")\n\n        /**\n         * Env\n         */\n        private val env = mutableMapOf<String, String>()\n\n        /**\n         * Metadata\n         */\n        private val metadata = mutableMapOf<String, String>()\n\n        /**\n         * Optional extension parameters for server-side custom behaviors.\n         *\n         * This map is treated as opaque and is sent to the server as-is.\n         * Prefer namespaced keys (e.g. `storage.id`) to avoid collisions.\n         */\n        private val extensions = mutableMapOf<String, String>()\n\n        /**\n         * Optional outbound network policy (egress).\n         */\n        private var networkPolicy: NetworkPolicy? = null\n\n        /**\n         * Optional list of volume mounts for persistent storage.\n         */\n        private val volumes = mutableListOf<Volume>()\n\n        /**\n         * Lifecycle config\n         */\n        private var timeout: Duration? = Duration.ofSeconds(600)\n        private var readyTimeout: Duration = Duration.ofSeconds(30)\n        private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)\n        private var healthCheck: ((Sandbox) -> Boolean)? = null\n\n        /**\n         * When true, do NOT wait for sandbox readiness/health during [build].\n         *\n         * Default is false (wait until ready).\n         */\n        private var skipHealthCheck: Boolean = false\n\n        /**\n         * Connection config\n         */\n        private var connectionConfig: ConnectionConfig? = null\n\n        /**\n         * Sets the sandbox image for the sandbox.\n         *\n         * @param image Sandbox image reference (e.g., \"ubuntu:22.04\", \"python:3.11\")\n         * @return This builder for method chaining\n         * @throws InvalidArgumentException if image is blank\n         */\n        fun image(image: String): Builder {\n            if (image.isBlank()) {\n                throw InvalidArgumentException(\n                    message = \"Image cannot be blank\",\n                )\n            }\n            this.imageSpec =\n                SandboxImageSpec.builder()\n                    .image(image)\n                    .build()\n            return this\n        }\n\n        /**\n         * Sets the sandbox image specification.\n         *\n         * @param imageSpec Complete image specification including image and optional auth\n         * @return This builder for method chaining\n         */\n        fun imageSpec(imageSpec: SandboxImageSpec): Builder {\n            this.imageSpec = imageSpec\n            return this\n        }\n\n        /**\n         * Sets the entrypoint command for the sandbox.\n         *\n         * @param entrypoint List of command and arguments to use as entrypoint\n         * @return This builder for method chaining\n         */\n        fun entrypoint(entrypoint: List<String>): Builder {\n            this.entrypoint = entrypoint\n            return this\n        }\n\n        /**\n         * Sets the entrypoint command for the sandbox.\n         *\n         * @param entrypoint Vararg command and arguments to use as entrypoint\n         * @return This builder for method chaining\n         */\n        fun entrypoint(vararg entrypoint: String): Builder {\n            this.entrypoint = entrypoint.toList()\n            return this\n        }\n\n        /**\n         * Sets resource limits for the sandbox using a fluent configuration block.\n         *\n         * @param configure Configuration block for resource limits\n         * @return This builder for method chaining\n         */\n        fun resource(configure: MutableMap<String, String>.() -> Unit): Builder {\n            resource.configure()\n            return this\n        }\n\n        /**\n         * Sets resource limits for the sandbox.\n         *\n         * @param resource Resource limits map\n         * @return This builder for method chaining\n         */\n        fun resource(resource: Map<String, String>): Builder {\n            this.resource.clear()\n            this.resource.putAll(resource)\n            return this\n        }\n\n        /**\n         * Adds a single environment variable.\n         *\n         * @param key Environment variable name\n         * @param value Environment variable value\n         * @return This builder for method chaining\n         */\n        fun env(\n            key: String,\n            value: String,\n        ): Builder {\n            if (key.isBlank()) {\n                throw InvalidArgumentException(\n                    message = \"Environment variable key cannot be blank\",\n                )\n            }\n            env[key] = value\n            return this\n        }\n\n        /**\n         * Adds multiple environment variables.\n         *\n         * @param env Map of environment variables to add\n         * @return This builder for method chaining\n         */\n        fun env(env: Map<String, String>): Builder {\n            this.env.putAll(env)\n            return this\n        }\n\n        /**\n         * Configures environment variables using a fluent configuration block.\n         *\n         * @param configure Configuration block that receives a mutable map\n         * @return This builder for method chaining\n         */\n        fun env(configure: MutableMap<String, String>.() -> Unit): Builder {\n            env.configure()\n            return this\n        }\n\n        /**\n         * Adds a single metadata entry.\n         *\n         * @param key Metadata key\n         * @param value Metadata value\n         * @return This builder for method chaining\n         */\n        fun metadata(\n            key: String,\n            value: String,\n        ): Builder {\n            if (key.isBlank()) {\n                throw InvalidArgumentException(\n                    message = \"Metadata key cannot be blank\",\n                )\n            }\n            metadata[key] = value\n            return this\n        }\n\n        /**\n         * Adds multiple metadata entries.\n         *\n         * @param metadata Map of metadata to add\n         * @return This builder for method chaining\n         */\n        fun metadata(metadata: Map<String, String>): Builder {\n            this.metadata.putAll(metadata)\n            return this\n        }\n\n        /**\n         * Configures metadata using a fluent configuration block.\n         *\n         * @param configure Configuration block that receives a mutable map\n         * @return This builder for method chaining\n         */\n        fun metadata(configure: MutableMap<String, String>.() -> Unit): Builder {\n            metadata.configure()\n            return this\n        }\n\n        /**\n         * Sets a sandbox outbound network policy (egress).\n         */\n        fun networkPolicy(networkPolicy: NetworkPolicy): Builder {\n            this.networkPolicy = networkPolicy\n            return this\n        }\n\n        /**\n         * Configures a sandbox outbound network policy (egress).\n         */\n        fun networkPolicy(configure: NetworkPolicy.Builder.() -> Unit): Builder {\n            val builder = NetworkPolicy.builder()\n            builder.configure()\n            this.networkPolicy = builder.build()\n            return this\n        }\n\n        /**\n         * Adds a single volume mount.\n         *\n         * @param volume Volume configuration\n         * @return This builder for method chaining\n         */\n        fun volume(volume: Volume): Builder {\n            this.volumes.add(volume)\n            return this\n        }\n\n        /**\n         * Adds multiple volume mounts.\n         *\n         * @param volumes List of volume configurations to add\n         * @return This builder for method chaining\n         */\n        fun volumes(volumes: List<Volume>): Builder {\n            this.volumes.addAll(volumes)\n            return this\n        }\n\n        /**\n         * Configures a volume mount using a fluent configuration block.\n         *\n         * @param configure Configuration block for Volume.Builder\n         * @return This builder for method chaining\n         */\n        fun volume(configure: Volume.Builder.() -> Unit): Builder {\n            val builder = Volume.builder()\n            builder.configure()\n            this.volumes.add(builder.build())\n            return this\n        }\n\n        /**\n         * Adds a single extension parameter.\n         *\n         * Extensions are opaque client-side and are passed through to the server.\n         * Prefer stable, namespaced keys (e.g. `storage.id`).\n         *\n         * @throws InvalidArgumentException if [key] is blank\n         */\n        fun extension(\n            key: String,\n            value: String,\n        ): Builder {\n            if (key.isBlank()) {\n                throw InvalidArgumentException(\n                    message = \"Extension key cannot be blank\",\n                )\n            }\n            extensions[key] = value\n            return this\n        }\n\n        /**\n         * Adds multiple extension parameters.\n         *\n         * Extensions are opaque client-side and are passed through to the server.\n         */\n        fun extensions(extensions: Map<String, String>): Builder {\n            this.extensions.putAll(extensions)\n            return this\n        }\n\n        /**\n         * Configures extension parameters using a fluent configuration block.\n         *\n         * Extensions are opaque client-side and are passed through to the server.\n         */\n        fun extensions(configure: MutableMap<String, String>.() -> Unit): Builder {\n            extensions.configure()\n            return this\n        }\n\n        /**\n         * Sets the sandbox timeout (automatic termination time).\n         *\n         * @param timeout Maximum sandbox lifetime. Pass null to require explicit cleanup.\n         * @return This builder for method chaining\n         * @throws InvalidArgumentException if timeout is negative or zero\n         */\n        fun timeout(timeout: Duration?): Builder {\n            if (timeout != null && (timeout.isNegative || timeout.isZero)) {\n                throw InvalidArgumentException(\n                    message = \"Timeout must be positive, got: $timeout\",\n                )\n            }\n            this.timeout = timeout\n            return this\n        }\n\n        /**\n         * Disables automatic expiration and requires explicit cleanup.\n         *\n         * This provides a stable Java interop entrypoint for non-expiring sandboxes.\n         */\n        fun manualCleanup(): Builder {\n            this.timeout = null\n            return this\n        }\n\n        /**\n         * Sets the timeout for waiting for sandbox readiness.\n         *\n         * @param readyTimeout Maximum time to wait for sandbox to become ready\n         * @return This builder for method chaining\n         * @throws InvalidArgumentException if timeout is negative or zero\n         */\n        fun readyTimeout(readyTimeout: Duration): Builder {\n            if (readyTimeout.isNegative || readyTimeout.isZero) {\n                throw InvalidArgumentException(\n                    message = \"Ready timeout must be positive, got: $readyTimeout\",\n                )\n            }\n            this.readyTimeout = readyTimeout\n            return this\n        }\n\n        /**\n         * Sets the interval between readiness polling attempts.\n         *\n         * @param pollingInterval Time between readiness checks\n         * @return This builder for method chaining\n         * @throws InvalidArgumentException if interval is negative or zero\n         */\n        fun healthCheckPollingInterval(pollingInterval: Duration): Builder {\n            if (pollingInterval.isNegative || pollingInterval.isZero) {\n                throw InvalidArgumentException(\n                    message = \"Ready polling interval must be positive, got: $pollingInterval\",\n                )\n            }\n            this.healthCheckPollingInterval = pollingInterval\n            return this\n        }\n\n        fun healthCheck(healthCheck: (Sandbox) -> Boolean): Builder {\n            this.healthCheck = healthCheck\n            return this\n        }\n\n        /**\n         * Skip readiness/health check during [build]. The returned sandbox may not be ready yet.\n         */\n        fun skipHealthCheck(skip: Boolean = true): Builder {\n            this.skipHealthCheck = skip\n            return this\n        }\n\n        fun connectionConfig(connectionConfig: ConnectionConfig): Builder {\n            this.connectionConfig = connectionConfig\n            return this\n        }\n\n        /**\n         * Creates and starts the sandbox with the configured parameters.\n         *\n         * This method performs the following steps:\n         * 1. Validates all required configuration\n         * 2. Delegates to Sandbox.create() to create the sandbox\n         * 3. Returns a fully initialized Sandbox instance\n         *\n         * @return Fully configured and ready Sandbox instance\n         * @throws InvalidArgumentException if required configuration is missing or invalid\n         * @throws SandboxException if sandbox creation or initialization fails\n         */\n        fun build(): Sandbox {\n            // Validate required configuration\n            val spec =\n                imageSpec ?: throw InvalidArgumentException(\n                    message = \"Sandbox image must be specified\",\n                )\n\n            // Validate image specification\n            if (spec.image.isBlank()) {\n                throw InvalidArgumentException(\"Sandbox image cannot be blank\")\n            }\n\n            return create(\n                imageSpec = spec,\n                entrypoint = entrypoint,\n                env = env,\n                metadata = metadata,\n                timeout = timeout,\n                readyTimeout = readyTimeout,\n                resource = resource,\n                networkPolicy = networkPolicy,\n                extensions = extensions,\n                connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                healthCheck = healthCheck,\n                skipHealthCheck = skipHealthCheck,\n                volumes = if (volumes.isEmpty()) null else volumes.toList(),\n            )\n        }\n    }\n\n    /**\n     * Fluent resumer for resuming paused sandbox instances.\n     *\n     * This class provides a type-safe, fluent interface for configuring connection parameters\n     * and readiness behavior when resuming an existing sandbox.\n     *\n     * ## Basic Usage\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.resumer()\n     *     .sandboxId(existingSandboxId)\n     *     .resume()\n     * ```\n     *\n     * ## Advanced Configuration\n     *\n     * ```kotlin\n     * val sandbox = Sandbox.resumer()\n     *     .sandboxId(existingSandboxId)\n     *     .connectionConfig(ConnectionConfig.builder().apiKey(\"...\").build())\n     *     .resumeTimeout(Duration.ofSeconds(60))\n     *     .healthCheckPollingInterval(Duration.ofMillis(200))\n     *     .healthCheck { it.isHealthy() }\n     *     .resume()\n     * ```\n     */\n    class Resumer internal constructor() {\n        /**\n         * Sandbox ID to resume\n         */\n        private var sandboxId: String? = null\n\n        /**\n         * Connection config\n         */\n        private var connectionConfig: ConnectionConfig? = null\n\n        /**\n         * Health check logic\n         */\n        private var healthCheck: ((Sandbox) -> Boolean)? = null\n\n        /**\n         * Max time to wait for the sandbox to become ready after resuming\n         */\n        private var resumeTimeout: Duration = Duration.ofSeconds(30)\n\n        /**\n         * Polling interval for readiness/health check while waiting for resume\n         */\n        private var healthCheckPollingInterval: Duration = Duration.ofMillis(200)\n\n        /**\n         * When true, do NOT wait for sandbox readiness/health during [resume].\n         *\n         * Default is false (wait until ready).\n         */\n        private var skipHealthCheck: Boolean = false\n\n        /**\n         * Sets the sandbox ID to resume.\n         *\n         * @param sandboxId ID of the paused sandbox\n         * @return This resumer for method chaining\n         */\n        fun sandboxId(sandboxId: String): Resumer {\n            this.sandboxId = sandboxId\n            return this\n        }\n\n        /**\n         * Sets a custom health check used by [Sandbox.checkReady] after resuming.\n         *\n         * If not set, [Sandbox.ping] will be used.\n         */\n        fun healthCheck(healthCheck: (Sandbox) -> Boolean): Resumer {\n            this.healthCheck = healthCheck\n            return this\n        }\n\n        /**\n         * Sets the connection configuration used to talk to the Open Sandbox API.\n         */\n        fun connectionConfig(connectionConfig: ConnectionConfig): Resumer {\n            this.connectionConfig = connectionConfig\n            return this\n        }\n\n        /**\n         * Sets the max time to wait for readiness after the resume operation.\n         */\n        fun resumeTimeout(timeout: Duration): Resumer {\n            this.resumeTimeout = timeout\n            return this\n        }\n\n        /**\n         * Sets the polling interval used while waiting for readiness after resuming.\n         */\n        fun healthCheckPollingInterval(pollingInterval: Duration): Resumer {\n            this.healthCheckPollingInterval = pollingInterval\n            return this\n        }\n\n        /**\n         * Skip readiness/health check during [resume]. The returned sandbox may not be ready yet.\n         */\n        fun skipHealthCheck(skip: Boolean = true): Resumer {\n            this.skipHealthCheck = skip\n            return this\n        }\n\n        /**\n         * Resumes the sandbox with the configured parameters.\n         *\n         * This method validates required configuration, performs the server-side resume,\n         * rebuilds service adapters, and waits for readiness.\n         *\n         * @return Resumed and ready Sandbox instance\n         * @throws InvalidArgumentException if sandboxId is missing\n         * @throws SandboxException if resume or readiness check fails\n         */\n        fun resume(): Sandbox {\n            val id =\n                sandboxId ?: throw InvalidArgumentException(\n                    message = \"Sandbox ID must be specified\",\n                )\n\n            return resume(\n                sandboxId = id,\n                connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),\n                healthCheck = healthCheck,\n                resumeTimeout = resumeTimeout,\n                healthCheckPollingInterval = healthCheckPollingInterval,\n                skipHealthCheck = skipHealthCheck,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox\n\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory\nimport org.slf4j.LoggerFactory\nimport java.time.Duration\nimport java.time.OffsetDateTime\n\n/**\n * Sandbox management interface for administrative operations and monitoring sandbox instances.\n *\n * This class provides a centralized interface for managing sandbox instances,\n * enabling administrative operations and sandbox discovery.\n * It focuses on high-level management operations rather than individual sandbox interactions.\n *\n * ## Key Features\n *\n * - **Sandbox Discovery**: List and filter sandbox instances by various criteria\n * - **Administrative Operations**: Individual sandbox management operations\n * - **Connection Pool Management**: Efficient HTTP client reuse for multiple operations\n *\n * ## Usage Example\n *\n * ```kotlin\n * val manager = SandboxManager.builder()\n *     .connectionConfig(connectionConfig)\n *     .build()\n *\n * // List all running sandboxes\n * val runningSandboxes = manager.listSandboxInfos(\n *     SandboxFilter.builder().state(\"RUNNING\").build()\n * )\n *\n * // Individual operations\n * val sandboxId = \"sandbox-id\"\n * manager.getSandboxInfo(sandboxId)\n * manager.pauseSandbox(sandboxId)\n * manager.resumeSandbox(sandboxId)\n * manager.killSandbox(sandboxId)\n *\n * // Cleanup\n * manager.close()\n * ```\n *\n * **Note**: This class is designed for administrative operations.\n * For individual sandbox interactions, use the [Sandbox] class directly.\n */\nclass SandboxManager internal constructor(\n    private val sandboxService: Sandboxes,\n    private val httpClientProvider: HttpClientProvider,\n) : AutoCloseable {\n    private val logger = LoggerFactory.getLogger(SandboxManager::class.java)\n\n    /**\n     * Provides access to shared httpclient provider\n     *\n     * Allows retrieving underlying http client resources initialized with connection config\n     */\n    fun httpClientProvider() = httpClientProvider\n\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n\n        internal fun create(connectionConfig: ConnectionConfig): SandboxManager {\n            val httpClientProvider = HttpClientProvider(connectionConfig)\n            val factory = AdapterFactory(httpClientProvider)\n            val sandboxService = factory.createSandboxes()\n            return SandboxManager(sandboxService, httpClientProvider)\n        }\n    }\n\n    fun listSandboxInfos(filter: SandboxFilter): PagedSandboxInfos {\n        return sandboxService.listSandboxes(filter)\n    }\n\n    /**\n     * Gets information for a single sandbox by its ID.\n     *\n     * @param sandboxId Sandbox ID to retrieve information for\n     * @return SandboxInfo for the specified sandbox\n     * @throws SandboxException if the operation fails\n     */\n    fun getSandboxInfo(sandboxId: String): SandboxInfo {\n        logger.debug(\"Getting info for sandbox: {}\", sandboxId)\n        return sandboxService.getSandboxInfo(sandboxId)\n    }\n\n    /**\n     * Terminates a single sandbox.\n     *\n     * @param sandboxId Sandbox ID to terminate\n     * @throws SandboxException if the operation fails\n     */\n    fun killSandbox(sandboxId: String) {\n        logger.info(\"Terminating sandbox: {}\", sandboxId)\n        sandboxService.killSandbox(sandboxId)\n        logger.info(\"Successfully terminated sandbox: {}\", sandboxId)\n    }\n\n    /**\n     * Renew expiration time for a single sandbox.\n     *\n     * The new expiration time will be set to the current time plus the provided duration.\n     *\n     * @param sandboxId Sandbox ID to renew\n     * @param timeout Duration to add to the current time to set the new expiration\n     * @throws SandboxException if the operation fails\n     */\n    fun renewSandbox(\n        sandboxId: String,\n        timeout: Duration,\n    ): SandboxRenewResponse {\n        logger.info(\"Renew expiration for sandbox {} to {}\", sandboxId, OffsetDateTime.now().plus(timeout))\n        return sandboxService.renewSandboxExpiration(sandboxId, OffsetDateTime.now().plus(timeout))\n    }\n\n    /**\n     * Pauses a single sandbox while preserving its state.\n     *\n     * @param sandboxId Sandbox ID to pause\n     * @throws SandboxException if the operation fails\n     */\n    fun pauseSandbox(sandboxId: String) {\n        logger.info(\"Pausing sandbox: {}\", sandboxId)\n        sandboxService.pauseSandbox(sandboxId)\n    }\n\n    /**\n     * Resumes a previously paused sandbox.\n     *\n     * @param sandboxId Sandbox ID to resume\n     * @throws SandboxException if the operation fails\n     */\n    fun resumeSandbox(sandboxId: String) {\n        logger.info(\"Resuming sandbox: {}\", sandboxId)\n        sandboxService.resumeSandbox(sandboxId)\n    }\n\n    /**\n     * Closes this resource, relinquishing any underlying resources.\n     *\n     * This method closes the local HTTP client resources associated with this sandbox manager instance.\n     */\n    override fun close() {\n        try {\n            httpClientProvider.close()\n        } catch (e: Exception) {\n            logger.warn(\"Error closing resources\", e)\n        }\n    }\n\n    class Builder internal constructor() {\n        /**\n         * Connection config\n         */\n        private var connectionConfig: ConnectionConfig? = null\n\n        fun connectionConfig(connectionConfig: ConnectionConfig): Builder {\n            this.connectionConfig = connectionConfig\n            return this\n        }\n\n        /**\n         * Creates the sandbox manager with the configured parameters.\n         *\n         * @return Fully configured SandboxManager instance\n         * @throws InvalidArgumentException if required configuration is missing or invalid\n         * @throws SandboxException if manager creation fails\n         */\n        fun build(): SandboxManager {\n            return SandboxManager.create(\n                connectionConfig = connectionConfig ?: ConnectionConfig.builder().build(),\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/config/ConnectionConfig.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.config\n\nimport okhttp3.ConnectionPool\nimport java.time.Duration\n\n/**\n * Sandbox operations connection configuration.\n */\nclass ConnectionConfig private constructor(\n    /** API key for authentication with sandbox service */\n    private val apiKey: String?,\n    /** Base URL for the sandbox management API */\n    private val domain: String?,\n    /** Protocol to use (http/https) */\n    val protocol: String,\n    /** Timeout for HTTP requests to the management API */\n    val requestTimeout: Duration,\n    /** Enable debug logging for HTTP requests */\n    val debug: Boolean = false,\n    /** user agent */\n    val userAgent: String = DEFAULT_USER_AGENT,\n    /** User defined headers */\n    val headers: Map<String, String> = mutableMapOf(),\n    /** Connection pool (optional) */\n    val connectionPool: ConnectionPool?,\n    /** Whether the connection pool is managed by the user */\n    val connectionPoolManagedByUser: Boolean,\n    /**\n     * Use sandbox server as proxy for process execd requests.\n     * Useful when the client SDK cannot access the created sandbox directly.\n     */\n    val useServerProxy: Boolean = false,\n) {\n    companion object {\n        private const val DEFAULT_DOMAIN = \"localhost:8080\"\n        private const val DEFAULT_PROTOCOL = \"http\"\n        private const val ENV_API_KEY = \"OPEN_SANDBOX_API_KEY\"\n        private const val ENV_DOMAIN = \"OPEN_SANDBOX_DOMAIN\"\n\n        private const val DEFAULT_USER_AGENT = \"OpenSandbox-Kotlin-SDK/1.0.5\"\n        private const val API_VERSION = \"v1\"\n\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    fun getApiKey(): String {\n        return this.apiKey ?: System.getenv(ENV_API_KEY) ?: \"\"\n    }\n\n    fun getDomain(): String {\n        return this.domain ?: System.getenv(ENV_DOMAIN) ?: DEFAULT_DOMAIN\n    }\n\n    fun getBaseUrl(): String {\n        val currentDomain = getDomain()\n        // Python semantics:\n        // - If `domain` includes a scheme, treat it as a full base URL (without `/v1`) and append `/v1`.\n        // - If `domain` does not include a scheme, build `protocol://domain/v1`.\n        // Also normalize trailing slashes and avoid duplicating `/v1`.\n        if (currentDomain.startsWith(\"http://\") || currentDomain.startsWith(\"https://\")) {\n            val trimmed = currentDomain.removeSuffix(\"/\")\n            return if (trimmed.endsWith(\"/$API_VERSION\")) trimmed else \"$trimmed/$API_VERSION\"\n        }\n        val trimmed = currentDomain.removeSuffix(\"/\")\n        return if (trimmed.endsWith(\n                \"/$API_VERSION\",\n            )\n        ) {\n            \"$protocol://${trimmed.removeSuffix(\"/$API_VERSION\")}/$API_VERSION\"\n        } else {\n            \"$protocol://$trimmed/$API_VERSION\"\n        }\n    }\n\n    /**\n     * Builder for [ConnectionConfig].\n     *\n     * This builder is part of the public SDK surface and is intended to be used directly by end users.\n     *\n     * ### Defaults & environment variables\n     * - If `apiKey` is not provided, the SDK will read it from environment variable `OPEN_SANDBOX_API_KEY`.\n     * - If `domain` is not provided, the SDK will read it from environment variable `OPEN_SANDBOX_DOMAIN`,\n     *   falling back to `localhost:8080`.\n     *\n     * ### Lifecycle / resource ownership\n     * - If you do **not** provide a custom [ConnectionPool], the SDK creates and owns a default one\n     *   per Sandbox/Manager instance. Calling `Sandbox.close()` / `SandboxManager.close()` will\n     *   close SDK-owned HTTP clients and release the SDK-owned connection pool.\n     * - If you **do** provide a [ConnectionPool] via [connectionPool], it is treated as user-owned\n     *   and will **not** be evicted by the SDK on close.\n     *\n     * ### Notes\n     * - `domain` may include a scheme (e.g. `https://example.com`); in that case the SDK will ignore [protocol]\n     *   and append `/$API_VERSION` automatically when constructing the base URL.\n     */\n    class Builder internal constructor() {\n        private var apiKey: String? = null\n\n        private var domain: String? = null\n\n        private var protocol: String = DEFAULT_PROTOCOL\n\n        private var requestTimeout: Duration = Duration.ofSeconds(30)\n\n        private var debug: Boolean = false\n\n        private var headers: Map<String, String> = mutableMapOf()\n\n        private var connectionPool: ConnectionPool? = null\n\n        private var connectionPoolManagedByUser: Boolean = false\n\n        private var useServerProxy: Boolean = false\n\n        /**\n         * Use sandbox server as proxy for process execd requests.\n         * Useful when the client SDK cannot access the created sandbox directly.\n         */\n        fun useServerProxy(useServerProxy: Boolean): Builder {\n            this.useServerProxy = useServerProxy\n            return this\n        }\n\n        /**\n         * Set the API key used for authentication.\n         *\n         * If not set, the SDK falls back to environment variable `OPEN_SANDBOX_API_KEY`.\n         */\n        fun apiKey(apiKey: String): Builder {\n            require(apiKey.isNotBlank()) { \"API key cannot be blank\" }\n            this.apiKey = apiKey\n            return this\n        }\n\n        /**\n         * Set the API domain (host[:port]) or a full base URL.\n         *\n         * Examples:\n         * - `pre-agent-sandbox.alibaba-inc.com`\n         * - `localhost:8080`\n         * - `https://pre-agent-sandbox.alibaba-inc.com` (scheme included; [protocol] will be ignored)\n         *\n         * If not set, the SDK falls back to environment variable `OPEN_SANDBOX_DOMAIN`\n         * and then `localhost:8080`.\n         */\n        fun domain(domain: String): Builder {\n            require(domain.isNotBlank()) { \"Domain cannot be blank\" }\n            this.domain = domain\n            return this\n        }\n\n        /**\n         * Sets the protocol\n         * Defaults to \"http\".\n         *\n         * Note: if [domain] includes a scheme (starts with `http://` or `https://`),\n         * the SDK will use that and ignore this value when building the base URL.\n         */\n        fun protocol(protocol: String): Builder {\n            this.protocol = protocol.lowercase()\n            return this\n        }\n\n        /**\n         * Sets the request timeout used by the management API HTTP client.\n         *\n         * Must be a positive duration.\n         */\n        fun requestTimeout(requestTimeout: Duration): Builder {\n            require(!requestTimeout.isNegative && !requestTimeout.isZero) {\n                \"Request timeout must be positive, got: $requestTimeout\"\n            }\n            this.requestTimeout = requestTimeout\n            return this\n        }\n\n        /**\n         * Provide a custom OkHttp [ConnectionPool].\n         *\n         * Ownership semantics:\n         * - When you call this method, the pool is considered user-managed, and the SDK will not\n         *   evict it on close.\n         */\n        fun connectionPool(connectionPool: ConnectionPool): Builder {\n            this.connectionPool = connectionPool\n            this.connectionPoolManagedByUser = true\n            return this\n        }\n\n        /**\n         * Enable or disable HTTP request logging (headers).\n         *\n         * This is intended for local debugging. Sensitive headers will be redacted.\n         */\n        fun debug(enable: Boolean = true): Builder {\n            this.debug = enable\n            return this\n        }\n\n        /**\n         * Set extra headers that will be sent with every SDK request.\n         *\n         * Note: authentication header is managed by the SDK; you normally should not set\n         * `OPEN-SANDBOX-API-KEY` manually here.\n         */\n        fun headers(headers: Map<String, String>): Builder {\n            this.headers = headers\n            return this\n        }\n\n        /**\n         * Convenience DSL for setting extra headers.\n         *\n         * Example:\n         * ```\n         * ConnectionConfig.builder()\n         *   .headers {\n         *     put(\"X-Request-ID\", \"trace-123\")\n         *   }\n         *   .build()\n         * ```\n         */\n        fun headers(configure: MutableMap<String, String>.() -> Unit): Builder {\n            val map = mutableMapOf<String, String>()\n            map.configure()\n            this.headers = map\n            return this\n        }\n\n        /**\n         * Add a single extra header.\n         *\n         * This is equivalent to mutating [headers] and overwriting the value for the same key.\n         */\n        fun addHeader(\n            key: String,\n            value: String,\n        ): Builder {\n            require(key.isNotBlank()) { \"Header key cannot be blank\" }\n            val mutableHeaders = this.headers.toMutableMap()\n            mutableHeaders[key] = value\n            this.headers = mutableHeaders\n            return this\n        }\n\n        /**\n         * Build an immutable [ConnectionConfig].\n         */\n        fun build(): ConnectionConfig {\n            return ConnectionConfig(\n                apiKey = apiKey,\n                domain = domain,\n                protocol = protocol,\n                requestTimeout = requestTimeout,\n                debug = debug,\n                userAgent = DEFAULT_USER_AGENT,\n                headers = headers,\n                connectionPool = connectionPool,\n                connectionPoolManagedByUser = connectionPoolManagedByUser,\n                useServerProxy = useServerProxy,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.exceptions\n\n/**\n * Base exception class for all sandbox-related errors.\n *\n * Inherits from [RuntimeException] (Unchecked Exception) to avoid forcing\n * Java callers to implement verbose try-catch blocks while still allowing\n * specific error handling when needed.\n */\nopen class SandboxException(\n    message: String? = null,\n    cause: Throwable? = null,\n    val error: SandboxError,\n    val requestId: String? = null,\n) : RuntimeException(message, cause) {\n    // Keep the old constructor signature for binary compatibility with already-compiled clients.\n    constructor(\n        message: String?,\n        cause: Throwable?,\n        error: SandboxError,\n    ) : this(message = message, cause = cause, error = error, requestId = null)\n}\n\n/**\n * Thrown when the Sandbox API returns an error response (e.g., HTTP 4xx or 5xx) or meet unexpected error when calling api.\n */\nclass SandboxApiException(\n    message: String? = null,\n    cause: Throwable? = null,\n    val statusCode: Int? = null,\n    error: SandboxError = SandboxError(SandboxError.UNEXPECTED_RESPONSE),\n    requestId: String? = null,\n) : SandboxException(message, cause, error, requestId) {\n    // Keep the old constructor signature for binary compatibility with already-compiled clients.\n    constructor(\n        message: String?,\n        cause: Throwable?,\n        statusCode: Int?,\n        error: SandboxError,\n    ) : this(message = message, cause = cause, statusCode = statusCode, error = error, requestId = null)\n}\n\n/**\n * Thrown when an unexpected internal error occurs within the SDK\n */\nclass SandboxInternalException(\n    message: String? = null,\n    cause: Throwable? = null,\n) : SandboxException(\n        message = message,\n        cause = cause,\n        error = SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR),\n    )\n\n/**\n * Thrown when the operation times out waiting for the sandbox to become ready.\n */\nclass SandboxUnhealthyException(\n    message: String? = null,\n    cause: Throwable? = null,\n) : SandboxException(\n        message = message,\n        cause = cause,\n        error = SandboxError(SandboxError.UNHEALTHY, message),\n    )\n\n/**\n * Thrown when the operation times out waiting for the sandbox to become ready.\n */\nclass SandboxReadyTimeoutException(\n    message: String? = null,\n    cause: Throwable? = null,\n) : SandboxException(\n        message = message,\n        cause = cause,\n        error = SandboxError(SandboxError.READY_TIMEOUT, message),\n    )\n\n/**\n * Thrown when an invalid argument is provided to an SDK method.\n * Similar to [IllegalArgumentException] but within the SDK's exception hierarchy.\n */\nclass InvalidArgumentException(\n    message: String? = null,\n    cause: Throwable? = null,\n) : SandboxException(\n        message = message,\n        cause = cause,\n        error = SandboxError(SandboxError.INVALID_ARGUMENT, message),\n    )\n\n/**\n * Defines standardized common error codes and messages for the Sandbox SDK.\n */\ndata class SandboxError(\n    val code: String,\n    val message: String? = null,\n) {\n    companion object {\n        const val INTERNAL_UNKNOWN_ERROR = \"INTERNAL_UNKNOWN_ERROR\"\n        const val READY_TIMEOUT = \"READY_TIMEOUT\"\n        const val UNHEALTHY = \"UNHEALTHY\"\n        const val INVALID_ARGUMENT = \"INVALID_ARGUMENT\"\n        const val UNEXPECTED_RESPONSE = \"UNEXPECTED_RESPONSE\"\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/Constants.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.execd\n\nconst val DEFAULT_EXECD_PORT = 44772\nconst val DEFAULT_EGRESS_PORT = 18080\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/CommandModels.kt",
    "content": "/*\n * Copyright 2026 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.execd.executions\n\nimport java.time.OffsetDateTime\n\n/**\n * Command execution status (foreground or background).\n *\n * @property id Command ID returned by run command\n * @property content Original command content\n * @property running Whether the command is still running\n * @property exitCode Exit code if the command has finished\n * @property error Error message if the command failed\n * @property startedAt Start time in RFC3339 format\n * @property finishedAt Finish time in RFC3339 format (null if still running)\n */\nclass CommandStatus(\n    val id: String?,\n    val content: String?,\n    val running: Boolean?,\n    val exitCode: Int?,\n    val error: String?,\n    val startedAt: OffsetDateTime?,\n    val finishedAt: OffsetDateTime?,\n)\n\n/**\n * Background command logs with tail cursor.\n *\n * @property content Raw stdout/stderr content\n * @property cursor Latest cursor for incremental reads\n */\nclass CommandLogs(\n    val content: String,\n    val cursor: Long?,\n)\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/ExecutionModels.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.execd.executions\n\n/**\n * Represents a complete code execution session.\n *\n * This is the main model that tracks the entire lifecycle of code execution,\n * including results, errors, and output logs. It serves as the central container\n * for all execution-related data that is exposed to users.\n *\n * @property id Unique identifier for this execution session\n * @property executionCount Sequential execution counter for tracking execution order\n * @property result List of structured results produced by the code execution\n * @property error Error information if the execution failed\n * @property logs Container for stdout and stderr output messages\n */\nclass Execution(\n    var id: String? = null,\n    var executionCount: Long? = null,\n    val result: MutableList<ExecutionResult> = mutableListOf(),\n    var error: ExecutionError? = null,\n    val logs: ExecutionLogs = ExecutionLogs(),\n) {\n    /**\n     * Adds a new execution result to this execution.\n     * @param result The execution result to add\n     */\n    fun addResult(result: ExecutionResult) {\n        this.result.add(result)\n    }\n}\n\n/**\n * Container for execution output logs.\n *\n * Separates standard output and error output streams for better organization\n * and allows users to process different types of output appropriately.\n *\n * @property stdout List of messages written to standard output\n * @property stderr List of messages written to standard error\n */\nclass ExecutionLogs(\n    val stdout: MutableList<OutputMessage> = mutableListOf(),\n    val stderr: MutableList<OutputMessage> = mutableListOf(),\n) {\n    /**\n     * Adds a message to the standard output log.\n     * @param outputMessage The output message to add to stdout\n     */\n    fun addStdout(outputMessage: OutputMessage) {\n        this.stdout.add(outputMessage)\n    }\n\n    /**\n     * Adds a message to the standard error log.\n     * @param outputMessage The output message to add to stderr\n     */\n    fun addStderr(outputMessage: OutputMessage) {\n        this.stderr.add(outputMessage)\n    }\n}\n\n/**\n * Output message from code execution.\n *\n * Represents a single output message from either stdout or stderr streams\n * during code execution, including timing information.\n */\nclass OutputMessage(\n    /**\n     * The text content of the output message.\n     * Contains the actual text that was written to the output stream.\n     */\n    val text: String,\n    /**\n     * Timestamp when this message was generated.\n     * Unix timestamp in milliseconds indicating when the message was created.\n     */\n    val timestamp: Long,\n    /**\n     * Flag indicating if this is an error message.\n     * True if the message came from stderr, false if from stdout.\n     */\n    val isError: Boolean = false,\n)\n\n/**\n * Result of code execution.\n *\n * Represents a single output result from code execution, which may include\n * text content, formatting information, and timing data.\n */\nclass ExecutionResult(\n    /**\n     * The UTF-8 encoded text content of the execution result.\n     * Contains the actual output data from the executed code.\n     */\n    val text: String? = null,\n    /**\n     * Timestamp when this result was generated.\n     * Unix timestamp in milliseconds indicating when the result was created.\n     */\n    var timestamp: Long,\n    /**\n     * Other result content in UTF-8 encoded format\n     */\n    val extraProperties: Map<String, String> = emptyMap(),\n)\n\n/**\n * Error information when code execution fails.\n *\n * Contains detailed error information following standard error reporting format,\n * including error type, message, timing, and stack trace for debugging purposes.\n *\n * @property name The error name/type (e.g., \"SyntaxError\", \"RuntimeError\", \"TypeError\")\n * @property value The error message or description explaining what went wrong\n * @property timestamp Unix timestamp in milliseconds when the error occurred\n * @property traceback List of traceback lines showing the complete error stack trace\n */\nclass ExecutionError(\n    val name: String,\n    val value: String,\n    val timestamp: Long,\n    val traceback: List<String> = emptyList(),\n)\n\n/**\n * Execution complete event.\n *\n * Represents the completion of a code execution,\n * including timing information about when the execution finished.\n */\nclass ExecutionComplete(\n    /**\n     * Timestamp when the execution completed.\n     * Unix timestamp in milliseconds indicating when the execution finished.\n     */\n    val timestamp: Long,\n    /**\n     * Execution time in mills\n     */\n    val executionTimeInMillis: Long,\n)\n\n/**\n * Execution init event.\n *\n * Represents the initialization of a code execution.\n */\nclass ExecutionInit(\n    /**\n     * Execution id\n     */\n    var id: String,\n    /**\n     * Timestamp when the execution started.\n     */\n    var timestamp: Long,\n)\n\nfun interface OutputHandler<T> {\n    fun handle(output: T)\n}\n\n/**\n * Handlers model for code execution output processing.\n */\nclass ExecutionHandlers private constructor(\n    /**\n     * Handler for standard output messages.\n     * Called whenever text is written to stdout during execution.\n     */\n    val onStdout: OutputHandler<OutputMessage>? = null,\n    /**\n     * Handler for standard error messages.\n     * Called whenever text is written to stderr during execution.\n     */\n    val onStderr: OutputHandler<OutputMessage>? = null,\n    /**\n     * Handler for execution results.\n     * Called when structured results are generated from code execution.\n     */\n    val onResult: OutputHandler<ExecutionResult>? = null,\n    /**\n     * Handler for execution completion events.\n     * Called when code execution finishes, regardless of success or failure.\n     */\n    val onExecutionComplete: OutputHandler<ExecutionComplete>? = null,\n    /**\n     * Handler for execution errors.\n     * Called when an error occurs during code execution.\n     */\n    val onError: OutputHandler<ExecutionError>? = null,\n    /**\n     * Handler for execution initialization events.\n     * Called when code execution starts.\n     */\n    val onInit: OutputHandler<ExecutionInit>? = null,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var onStdout: OutputHandler<OutputMessage>? = null\n        private var onStderr: OutputHandler<OutputMessage>? = null\n        private var onResult: OutputHandler<ExecutionResult>? = null\n        private var onExecutionComplete: OutputHandler<ExecutionComplete>? = null\n        private var onError: OutputHandler<ExecutionError>? = null\n        private var onInit: OutputHandler<ExecutionInit>? = null\n\n        fun onStdout(handler: OutputHandler<OutputMessage>): Builder {\n            this.onStdout = handler\n            return this\n        }\n\n        fun onStderr(handler: OutputHandler<OutputMessage>): Builder {\n            this.onStderr = handler\n            return this\n        }\n\n        fun onResult(handler: OutputHandler<ExecutionResult>): Builder {\n            this.onResult = handler\n            return this\n        }\n\n        fun onExecutionComplete(handler: OutputHandler<ExecutionComplete>): Builder {\n            this.onExecutionComplete = handler\n            return this\n        }\n\n        fun onError(handler: OutputHandler<ExecutionError>): Builder {\n            this.onError = handler\n            return this\n        }\n\n        fun onInit(handler: OutputHandler<ExecutionInit>): Builder {\n            this.onInit = handler\n            return this\n        }\n\n        fun build(): ExecutionHandlers {\n            return ExecutionHandlers(\n                onStdout = onStdout,\n                onStderr = onStderr,\n                onResult = onResult,\n                onExecutionComplete = onExecutionComplete,\n                onError = onError,\n                onInit = onInit,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/executions/RunCommandRequest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.execd.executions\n\nimport kotlin.time.Duration\n\n/**\n * Parameters for command execution.\n *\n * @property command The command content to execute\n * @property background Whether to run in background (detached)\n * @property workingDirectory Directory to execute command in\n * @property timeout Maximum execution time; server will terminate when reached.  Null means the server will not enforce any timeout.\n * @property uid Unix user ID used to run the command process\n * @property gid Unix group ID used to run the command process. Requires uid.\n * @property envs Environment variables injected into the command process\n * @property handlers Optional execution handlers\n */\nclass RunCommandRequest private constructor(\n    val command: String,\n    val background: Boolean,\n    val workingDirectory: String?,\n    val timeout: Duration?,\n    val uid: Int?,\n    val gid: Int?,\n    val envs: Map<String, String>,\n    val handlers: ExecutionHandlers?,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var command: String? = null\n        private var background: Boolean = false\n        private var workingDirectory: String? = null\n        private var timeout: Duration? = null\n        private var uid: Int? = null\n        private var gid: Int? = null\n        private val envs: MutableMap<String, String> = mutableMapOf()\n        private var handlers: ExecutionHandlers? = null\n\n        fun command(command: String): Builder {\n            require(command.isNotBlank()) { \"Command cannot be blank\" }\n            this.command = command\n            return this\n        }\n\n        fun background(background: Boolean): Builder {\n            this.background = background\n            return this\n        }\n\n        fun workingDirectory(workingDirectory: String?): Builder {\n            this.workingDirectory = workingDirectory\n            return this\n        }\n\n        /**\n         * Maximum execution time; server will terminate the command when reached.\n         * If omitted, the server will not enforce any timeout.\n         */\n        fun timeout(timeout: Duration?): Builder {\n            this.timeout = timeout\n            return this\n        }\n\n        fun uid(uid: Int?): Builder {\n            require(uid == null || uid >= 0) { \"Uid must be >= 0\" }\n            this.uid = uid\n            return this\n        }\n\n        fun gid(gid: Int?): Builder {\n            require(gid == null || gid >= 0) { \"Gid must be >= 0\" }\n            this.gid = gid\n            return this\n        }\n\n        fun env(\n            key: String,\n            value: String,\n        ): Builder {\n            require(key.isNotBlank()) { \"Environment variable key cannot be blank\" }\n            this.envs[key] = value\n            return this\n        }\n\n        fun envs(envs: Map<String, String>): Builder {\n            envs.keys.forEach { key ->\n                require(key.isNotBlank()) { \"Environment variable key cannot be blank\" }\n            }\n            this.envs.putAll(envs)\n            return this\n        }\n\n        fun handlers(handlers: ExecutionHandlers?): Builder {\n            this.handlers = handlers\n            return this\n        }\n\n        fun build(): RunCommandRequest {\n            val commandValue = command ?: throw IllegalArgumentException(\"Command must be specified\")\n            require(gid == null || uid != null) { \"Uid is required when gid is provided\" }\n            return RunCommandRequest(\n                command = commandValue,\n                background = background,\n                workingDirectory = workingDirectory,\n                timeout = timeout,\n                uid = uid,\n                gid = gid,\n                envs = envs.toMap(),\n                handlers = handlers,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/filesystem/FilesystemModels.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem\n\nimport java.time.OffsetDateTime\n\n/**\n * Metadata information for a file or directory entry.\n *\n * Contains complete filesystem metadata including path, permissions, ownership,\n * size, and timestamp information for files and directories in the sandbox.\n *\n * @property path Absolute path of the file or directory\n * @property mode Unix file mode/permissions as integer (e.g., 644 for rw-r--r--)\n * @property owner Owner username of the file or directory\n * @property group Group name of the file or directory\n * @property size Size of the file in bytes (0 for directories)\n * @property modifiedAt Timestamp when the entry was last modified\n * @property createdAt Timestamp when the entry was created\n */\nclass EntryInfo(\n    val path: String,\n    val mode: Int,\n    val owner: String,\n    val group: String,\n    val size: Long,\n    val modifiedAt: OffsetDateTime,\n    val createdAt: OffsetDateTime,\n)\n\n/**\n * Request to write content to a file.\n *\n * Creates or overwrites a file with the specified content, permissions, and ownership.\n * Supports both text and binary data through flexible data parameter.\n *\n * @property path Destination file path where content will be written\n * @property data Content to write - can be String or ByteArray\n * @property mode Unix file permissions as integer (default: 755)\n * @property owner Owner username to set (null to use default in sandbox)\n * @property group Group name to set (null to use default in sandbox)\n * @property encoding Character encoding for String data (default: UTF-8)\n */\nclass WriteEntry private constructor(\n    val path: String,\n    val data: Any?,\n    val mode: Int,\n    val owner: String?,\n    val group: String?,\n    val encoding: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var path: String? = null\n        private var data: Any? = null\n        private var mode: Int = 755\n        private var owner: String? = null\n        private var group: String? = null\n        private var encoding: String = \"UTF-8\"\n\n        fun path(path: String): Builder {\n            require(path.isNotBlank()) { \"Path cannot be blank\" }\n            this.path = path\n            return this\n        }\n\n        fun data(data: Any): Builder {\n            this.data = data\n            return this\n        }\n\n        fun mode(mode: Int): Builder {\n            require(mode >= 0) { \"Mode must be non-negative\" }\n            this.mode = mode\n            return this\n        }\n\n        fun owner(owner: String?): Builder {\n            this.owner = owner\n            return this\n        }\n\n        fun group(group: String?): Builder {\n            this.group = group\n            return this\n        }\n\n        fun encoding(encoding: String): Builder {\n            require(encoding.isNotBlank()) { \"Encoding cannot be blank\" }\n            this.encoding = encoding\n            return this\n        }\n\n        fun build(): WriteEntry {\n            return WriteEntry(\n                path = path ?: throw IllegalArgumentException(\"Path must be specified\"),\n                data = data,\n                mode = mode,\n                owner = owner,\n                group = group,\n                encoding = encoding,\n            )\n        }\n    }\n}\n\n/**\n * Request to move/rename a file or directory.\n *\n * Moves a file or directory from one location to another within the sandbox filesystem.\n * Can be used for both renaming (same directory) and moving (different directory).\n *\n * @property src Source path of the file or directory to move\n * @property dest Destination path where the file or directory should be moved\n */\nclass MoveEntry private constructor(\n    val src: String,\n    val dest: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var src: String? = null\n        private var dest: String? = null\n\n        fun src(src: String): Builder {\n            require(src.isNotBlank()) { \"Source path cannot be blank\" }\n            this.src = src\n            return this\n        }\n\n        fun dest(dest: String): Builder {\n            require(dest.isNotBlank()) { \"Destination path cannot be blank\" }\n            this.dest = dest\n            return this\n        }\n\n        fun build(): MoveEntry {\n            val srcValue = src ?: throw IllegalArgumentException(\"Source path must be specified\")\n            val destValue = dest ?: throw IllegalArgumentException(\"Destination path must be specified\")\n            return MoveEntry(\n                src = srcValue,\n                dest = destValue,\n            )\n        }\n    }\n}\n\n/**\n * Request to set permissions/ownership of a file or directory.\n *\n * Updates the permissions and/or ownership of an existing file or directory\n * without modifying its content. Only specified properties will be changed.\n *\n * @property path Target path of the file or directory to modify\n * @property owner New owner username (null to keep current owner)\n * @property group New group name (null to keep current group)\n * @property mode New Unix file permissions as integer (default: 755)\n */\nclass SetPermissionEntry private constructor(\n    val path: String,\n    val owner: String?,\n    val group: String?,\n    val mode: Int,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var path: String? = null\n        private var owner: String? = null\n        private var group: String? = null\n        private var mode: Int = 755\n\n        fun path(path: String): Builder {\n            require(path.isNotBlank()) { \"Path cannot be blank\" }\n            this.path = path\n            return this\n        }\n\n        fun owner(owner: String?): Builder {\n            this.owner = owner\n            return this\n        }\n\n        fun group(group: String?): Builder {\n            this.group = group\n            return this\n        }\n\n        fun mode(mode: Int): Builder {\n            require(mode >= 0) { \"Mode must be non-negative\" }\n            this.mode = mode\n            return this\n        }\n\n        fun build(): SetPermissionEntry {\n            val pathValue = path ?: throw IllegalArgumentException(\"Path must be specified\")\n            return SetPermissionEntry(\n                path = pathValue,\n                owner = owner,\n                group = group,\n                mode = mode,\n            )\n        }\n    }\n}\n\n/**\n * Request to replace content within a file.\n *\n * Performs string replacement within a file by finding exact matches of the old content\n * and replacing them with new content. Only affects string matches, preserving the rest.\n *\n * @property path Target file path containing content to replace\n * @property oldContent Exact string content to find and replace\n * @property newContent Replacement string content to substitute\n */\nclass ContentReplaceEntry private constructor(\n    val path: String,\n    val oldContent: String,\n    val newContent: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var path: String? = null\n        private var oldContent: String? = null\n        private var newContent: String? = null\n\n        fun path(path: String): Builder {\n            require(path.isNotBlank()) { \"Path cannot be blank\" }\n            this.path = path\n            return this\n        }\n\n        fun oldContent(oldContent: String): Builder {\n            this.oldContent = oldContent\n            return this\n        }\n\n        fun newContent(newContent: String): Builder {\n            this.newContent = newContent\n            return this\n        }\n\n        fun build(): ContentReplaceEntry {\n            val pathValue = path ?: throw IllegalArgumentException(\"Path must be specified\")\n            val oldContentValue = oldContent ?: throw IllegalArgumentException(\"Old content must be specified\")\n            val newContentValue = newContent ?: throw IllegalArgumentException(\"New content must be specified\")\n            return ContentReplaceEntry(\n                path = pathValue,\n                oldContent = oldContentValue,\n                newContent = newContentValue,\n            )\n        }\n    }\n}\n\n/**\n * Request to search for files matching a pattern.\n *\n * Searches the filesystem starting from the specified path to find files\n * that match the given pattern. Used for file discovery and filtering.\n *\n * @property path Starting directory path for the search\n * @property pattern Search pattern (supports glob patterns like *.kt, *.txt)\n */\nclass SearchEntry private constructor(\n    val path: String,\n    val pattern: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var path: String? = null\n        private var pattern: String? = null\n\n        fun path(path: String): Builder {\n            require(path.isNotBlank()) { \"Path cannot be blank\" }\n            this.path = path\n            return this\n        }\n\n        fun pattern(pattern: String): Builder {\n            require(pattern.isNotBlank()) { \"Pattern cannot be blank\" }\n            this.pattern = pattern\n            return this\n        }\n\n        fun build(): SearchEntry {\n            val pathValue = path ?: throw IllegalArgumentException(\"Path must be specified\")\n            val patternValue = pattern ?: throw IllegalArgumentException(\"Pattern must be specified\")\n            return SearchEntry(\n                path = pathValue,\n                pattern = patternValue,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models.sandboxes\n\nimport java.time.OffsetDateTime\n\n/**\n * High-level lifecycle state of the sandbox.\n *\n * Common state values:\n * - Pending: Sandbox is being provisioned\n * - Running: Sandbox is running and ready to accept requests\n * - Pausing: Sandbox is in the process of pausing\n * - Paused: Sandbox has been paused while retaining its state\n * - Stopping: Sandbox is being terminated\n * - Terminated: Sandbox has been successfully terminated\n * - Failed: Sandbox encountered a critical error\n *\n * State transitions:\n * - Pending → Running (after creation completes)\n * - Running → Pausing (when pause is requested)\n * - Pausing → Paused (pause operation completes)\n * - Paused → Running (when resume is requested)\n * - Running/Paused → Stopping (when kill is requested or TTL expires)\n * - Stopping → Terminated (kill/timeout operation completes)\n * - Pending/Running/Paused → Failed (on error)\n *\n * Note: New state values may be added in future versions.\n * Clients should handle unknown state values gracefully.\n */\nobject SandboxState {\n    const val PENDING = \"Pending\"\n    const val RUNNING = \"Running\"\n    const val PAUSING = \"Pausing\"\n    const val PAUSED = \"Paused\"\n    const val STOPPING = \"Stopping\"\n    const val TERMINATED = \"Terminated\"\n    const val FAILED = \"Failed\"\n    const val UNKNOWN = \"Unknown\"\n}\n\n/**\n * Filter criteria for listing sandboxes.\n *\n * @property states Filter by sandbox states (e.g., RUNNING, PAUSED)\n * @property metadata Filter by metadata key-value pairs\n * @property pageSize Number of items per page\n * @property page Page number (0-indexed)\n */\nclass SandboxFilter private constructor(\n    val states: List<String>?,\n    val metadata: Map<String, String>?,\n    val pageSize: Int?,\n    val page: Int?,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var states: List<String>? = null\n        private var metadata: Map<String, String>? = null\n        private var pageSize: Int? = null\n        private var page: Int? = null\n\n        fun states(states: List<String>): Builder {\n            this.states = states\n            return this\n        }\n\n        fun states(vararg states: String): Builder {\n            this.states = states.toList()\n            return this\n        }\n\n        fun metadata(metadata: Map<String, String>): Builder {\n            this.metadata = metadata\n            return this\n        }\n\n        fun metadata(configure: MutableMap<String, String>.() -> Unit): Builder {\n            val map = mutableMapOf<String, String>()\n            map.configure()\n            this.metadata = map\n            return this\n        }\n\n        fun pageSize(pageSize: Int): Builder {\n            require(pageSize > 0) { \"Page size must be positive\" }\n            this.pageSize = pageSize\n            return this\n        }\n\n        fun page(page: Int): Builder {\n            require(page > 0) { \"Page must be positive\" }\n            this.page = page\n            return this\n        }\n\n        fun build(): SandboxFilter {\n            return SandboxFilter(\n                states = states,\n                metadata = metadata,\n                pageSize = pageSize,\n                page = page,\n            )\n        }\n    }\n}\n\n/**\n * Specification for a sandbox container image.\n *\n * @property image The image reference (e.g., \"ubuntu:22.04\", \"python:3.11\")\n * @property auth Authentication credentials for private registries\n */\nclass SandboxImageSpec private constructor(\n    val image: String,\n    val auth: SandboxImageAuth?,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var image: String? = null\n        private var auth: SandboxImageAuth? = null\n\n        fun image(image: String): Builder {\n            require(image.isNotBlank()) { \"Image cannot be blank\" }\n            this.image = image\n            return this\n        }\n\n        fun auth(auth: SandboxImageAuth): Builder {\n            this.auth = auth\n            return this\n        }\n\n        fun auth(\n            username: String,\n            password: String,\n        ): Builder {\n            this.auth =\n                SandboxImageAuth.builder()\n                    .username(username)\n                    .password(password)\n                    .build()\n            return this\n        }\n\n        fun build(): SandboxImageSpec {\n            val imageValue = image ?: throw IllegalArgumentException(\"Image must be specified\")\n            return SandboxImageSpec(\n                image = imageValue,\n                auth = auth,\n            )\n        }\n    }\n}\n\n/**\n * Authentication credentials for container registries.\n *\n * @property username Registry username\n * @property password Registry password or access token\n */\nclass SandboxImageAuth private constructor(\n    val username: String,\n    val password: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var username: String? = null\n        private var password: String? = null\n\n        fun username(username: String): Builder {\n            require(username.isNotBlank()) { \"Username cannot be blank\" }\n            this.username = username\n            return this\n        }\n\n        fun password(password: String): Builder {\n            require(password.isNotBlank()) { \"Password cannot be blank\" }\n            this.password = password\n            return this\n        }\n\n        fun build(): SandboxImageAuth {\n            val usernameValue = username ?: throw IllegalArgumentException(\"Username must be specified\")\n            val passwordValue = password ?: throw IllegalArgumentException(\"Password must be specified\")\n            return SandboxImageAuth(\n                username = usernameValue,\n                password = passwordValue,\n            )\n        }\n    }\n}\n\n/**\n * Egress rule for matching network targets.\n *\n * @property action Whether to allow or deny matching targets.\n * @property target FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\")\n */\nclass NetworkRule private constructor(\n    val action: Action,\n    val target: String,\n) {\n    enum class Action {\n        ALLOW,\n        DENY,\n    }\n\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var action: Action? = null\n        private var target: String? = null\n\n        fun action(action: Action): Builder {\n            this.action = action\n            return this\n        }\n\n        fun target(target: String): Builder {\n            require(target.isNotBlank()) { \"Target cannot be blank\" }\n            this.target = target\n            return this\n        }\n\n        fun build(): NetworkRule {\n            val actionValue = action ?: throw IllegalArgumentException(\"Action must be specified\")\n            val targetValue = target ?: throw IllegalArgumentException(\"Target must be specified\")\n            return NetworkRule(\n                action = actionValue,\n                target = targetValue,\n            )\n        }\n    }\n}\n\n/**\n * Egress network policy matching the sidecar `/policy` request body.\n *\n * @property defaultAction Default action when no egress rule matches. Defaults to \"deny\".\n * @property egress Egress rules evaluated in order\n */\nclass NetworkPolicy private constructor(\n    val defaultAction: DefaultAction?,\n    val egress: List<NetworkRule>?,\n) {\n    enum class DefaultAction {\n        ALLOW,\n        DENY,\n    }\n\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var defaultAction: DefaultAction = DefaultAction.DENY\n        private val egress = mutableListOf<NetworkRule>()\n\n        fun defaultAction(action: DefaultAction): Builder {\n            this.defaultAction = action\n            return this\n        }\n\n        fun addEgress(rule: NetworkRule): Builder {\n            egress.add(rule)\n            return this\n        }\n\n        fun egress(rules: List<NetworkRule>): Builder {\n            egress.clear()\n            egress.addAll(rules)\n            return this\n        }\n\n        fun build(): NetworkPolicy {\n            return NetworkPolicy(\n                defaultAction = defaultAction,\n                egress = if (egress.isEmpty()) null else egress.toList(),\n            )\n        }\n    }\n}\n\n// ============================================================================\n// Volume Models\n// ============================================================================\n\n/**\n * Host path bind mount backend.\n *\n * Maps a directory on the host filesystem into the container.\n * Only available when the runtime supports host mounts.\n *\n * @property path Absolute path on the host filesystem to mount\n */\nclass Host private constructor(\n    val path: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n\n        @JvmStatic\n        fun of(path: String): Host = builder().path(path).build()\n    }\n\n    class Builder {\n        private var path: String? = null\n\n        fun path(path: String): Builder {\n            require(path.startsWith(\"/\")) { \"Host path must be an absolute path starting with '/'\" }\n            this.path = path\n            return this\n        }\n\n        fun build(): Host {\n            val pathValue = path ?: throw IllegalArgumentException(\"Path must be specified\")\n            return Host(path = pathValue)\n        }\n    }\n}\n\n/**\n * Kubernetes PersistentVolumeClaim mount backend.\n *\n * References an existing PVC in the same namespace as the sandbox pod.\n * Only available in Kubernetes runtime.\n *\n * @property claimName Name of the PersistentVolumeClaim in the same namespace\n */\nclass PVC private constructor(\n    val claimName: String,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n\n        @JvmStatic\n        fun of(claimName: String): PVC = builder().claimName(claimName).build()\n    }\n\n    class Builder {\n        private var claimName: String? = null\n\n        fun claimName(claimName: String): Builder {\n            require(claimName.isNotBlank()) { \"Claim name cannot be blank\" }\n            this.claimName = claimName\n            return this\n        }\n\n        fun build(): PVC {\n            val claimNameValue = claimName ?: throw IllegalArgumentException(\"Claim name must be specified\")\n            return PVC(claimName = claimNameValue)\n        }\n    }\n}\n\n/**\n * Storage mount definition for a sandbox.\n *\n * Each volume entry contains:\n * - A unique name identifier\n * - Exactly one backend (host, pvc) with backend-specific fields\n * - Common mount settings (mountPath, readOnly, subPath)\n *\n * Example usage:\n * ```kotlin\n * // Host path mount (read-write by default)\n * val volume = Volume.builder()\n *     .name(\"workdir\")\n *     .host(Host.of(\"/data/opensandbox\"))\n *     .mountPath(\"/mnt/work\")\n *     .build()\n *\n * // PVC mount (read-only)\n * val volume = Volume.builder()\n *     .name(\"models\")\n *     .pvc(PVC.of(\"shared-models-pvc\"))\n *     .mountPath(\"/mnt/models\")\n *     .readOnly(true)\n *     .build()\n * ```\n *\n * @property name Unique identifier for the volume within the sandbox\n * @property host Host path bind mount backend (mutually exclusive with pvc)\n * @property pvc Kubernetes PVC mount backend (mutually exclusive with host)\n * @property mountPath Absolute path inside the container where the volume is mounted\n * @property readOnly If true, the volume is mounted as read-only. Defaults to false (read-write).\n * @property subPath Optional subdirectory under the backend path to mount\n */\nclass Volume private constructor(\n    val name: String,\n    val host: Host?,\n    val pvc: PVC?,\n    val mountPath: String,\n    val readOnly: Boolean,\n    val subPath: String?,\n) {\n    companion object {\n        @JvmStatic\n        fun builder(): Builder = Builder()\n    }\n\n    class Builder {\n        private var name: String? = null\n        private var host: Host? = null\n        private var pvc: PVC? = null\n        private var mountPath: String? = null\n        private var readOnly: Boolean = false\n        private var subPath: String? = null\n\n        fun name(name: String): Builder {\n            require(name.isNotBlank()) { \"Volume name cannot be blank\" }\n            this.name = name\n            return this\n        }\n\n        fun host(host: Host): Builder {\n            this.host = host\n            return this\n        }\n\n        fun pvc(pvc: PVC): Builder {\n            this.pvc = pvc\n            return this\n        }\n\n        fun mountPath(mountPath: String): Builder {\n            require(mountPath.startsWith(\"/\")) { \"Mount path must be an absolute path starting with '/'\" }\n            this.mountPath = mountPath\n            return this\n        }\n\n        fun readOnly(readOnly: Boolean): Builder {\n            this.readOnly = readOnly\n            return this\n        }\n\n        fun subPath(subPath: String): Builder {\n            this.subPath = subPath\n            return this\n        }\n\n        fun build(): Volume {\n            val nameValue = name ?: throw IllegalArgumentException(\"Name must be specified\")\n            val mountPathValue = mountPath ?: throw IllegalArgumentException(\"Mount path must be specified\")\n\n            // Validate exactly one backend is specified\n            val backendsSpecified = listOfNotNull(host, pvc).size\n            if (backendsSpecified == 0) {\n                throw IllegalArgumentException(\"Exactly one backend (host, pvc) must be specified, but none was provided\")\n            }\n            if (backendsSpecified > 1) {\n                throw IllegalArgumentException(\"Exactly one backend (host, pvc) must be specified, but multiple were provided\")\n            }\n\n            return Volume(\n                name = nameValue,\n                host = host,\n                pvc = pvc,\n                mountPath = mountPathValue,\n                readOnly = readOnly,\n                subPath = subPath,\n            )\n        }\n    }\n}\n\n/**\n * Detailed information about a sandbox instance.\n *\n * @property id Unique identifier of the sandbox\n * @property status Current status of the sandbox\n * @property entrypoint Command line arguments used to start the sandbox\n * @property expiresAt Timestamp when the sandbox is scheduled for automatic termination. Null means manual cleanup mode.\n * @property createdAt Timestamp when the sandbox was created\n * @property image Image specification used to create this sandbox\n * @property metadata Custom metadata attached to the sandbox\n */\nclass SandboxInfo(\n    val id: String,\n    val status: SandboxStatus,\n    val entrypoint: List<String>,\n    val expiresAt: OffsetDateTime?,\n    val createdAt: OffsetDateTime,\n    val image: SandboxImageSpec,\n    val metadata: Map<String, String>? = null,\n)\n\n/**\n * Status information for a sandbox.\n *\n * @property state Current state (e.g., RUNNING, PENDING, PAUSED, TERMINATED)\n * @property reason Short reason code for the current state\n * @property message Human-readable message explaining the status\n * @property lastTransitionAt Timestamp of the last state transition\n */\nclass SandboxStatus(\n    val state: String,\n    val reason: String?,\n    val message: String?,\n    val lastTransitionAt: java.time.OffsetDateTime?,\n)\n\n/**\n * Response returned when a sandbox is created.\n *\n * @property id Unique identifier of the newly created sandbox\n */\nclass SandboxCreateResponse(\n    val id: String,\n)\n\n/**\n * Response returned when a sandbox is renewed\n *\n * @property expiresAt new expire time after renewal\n */\nclass SandboxRenewResponse(\n    val expiresAt: java.time.OffsetDateTime,\n)\n\n/**\n * Connection endpoint information for a sandbox.\n *\n * @property endpoint Sandbox endpoint\n * @property headers Headers that must be included on every request targeting this endpoint (e.g. when the server requires them for routing or auth). Empty if not required.\n */\nclass SandboxEndpoint(\n    val endpoint: String,\n    val headers: Map<String, String> = emptyMap(),\n)\n\n/**\n * A paginated list of sandbox information.\n *\n * @property sandboxInfos List of sandbox details for the current page\n * @property pagination Pagination metadata\n */\nclass PagedSandboxInfos(\n    val sandboxInfos: List<SandboxInfo>,\n    val pagination: PaginationInfo,\n)\n\n/**\n * Pagination metadata.\n *\n * @property page Current page number (0-indexed)\n * @property pageSize Number of items per page\n * @property totalItems Total number of items across all pages\n * @property totalPages Total number of pages\n * @property hasNextPage True if there is a next page available\n */\nclass PaginationInfo(\n    val page: Int,\n    val pageSize: Int,\n    val totalItems: Int,\n    val totalPages: Int,\n    val hasNextPage: Boolean,\n)\n\n/**\n * Real-time resource usage metrics for a sandbox.\n *\n * @property cpuCount Number of CPU cores available/allocated\n * @property cpuUsedPercentage Current CPU usage as a percentage (0.0 - 100.0)\n * @property memoryTotalInMiB Total memory available in Mebibytes\n * @property memoryUsedInMiB Memory currently used in Mebibytes\n * @property timestamp Timestamp of the metric collection (Unix epoch milliseconds)\n */\nclass SandboxMetrics(\n    val cpuCount: Float,\n    val cpuUsedPercentage: Float,\n    val memoryTotalInMiB: Float,\n    val memoryUsedInMiB: Float,\n    val timestamp: Long,\n)\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Commands.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.CommandLogs\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.CommandStatus\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest\n\n/**\n * Command execution operations for sandbox environments.\n *\n * This service provides secure command execution capabilities within sandbox\n * environments, with support for streaming output, timeout handling, and\n * session management.\n */\ninterface Commands {\n    /**\n     * Executes a shell command in the sandbox environment.\n     *\n     * The command can be executed in foreground (streaming) or background mode\n     * based on the request configuration.\n     *\n     * @param request Configuration for the command execution including command text,\n     *                working directory, and timeout settings\n     * @return An [Execution] handle representing the running command instance\n     */\n    fun run(request: RunCommandRequest): Execution\n\n    /**\n     * Convenience overload for simple command execution.\n     *\n     * Equivalent to:\n     * `run(RunCommandRequest.builder().command(command).build())`\n     */\n    fun run(command: String): Execution {\n        return run(RunCommandRequest.builder().command(command).build())\n    }\n\n    /**\n     * Interrupts and terminates a running command execution.\n     *\n     * This sends a termination signal (usually SIGTERM/SIGKILL) to the process\n     * associated with the given execution ID.\n     *\n     * @param executionId Unique identifier of the execution to interrupt\n     */\n    fun interrupt(executionId: String)\n\n    /**\n     * Get the current running status for a command.\n     *\n     * @param executionId Unique identifier of the execution to query\n     * @return Command status information\n     */\n    fun getCommandStatus(executionId: String): CommandStatus\n\n    /**\n     * Get background command logs (non-streamed).\n     *\n     * @param executionId Unique identifier of the execution to query\n     * @param cursor Optional line cursor for incremental reads\n     * @return Command logs content and tail cursor\n     */\n    fun getBackgroundCommandLogs(\n        executionId: String,\n        cursor: Long? = null,\n    ): CommandLogs\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Egress.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\n\ninterface Egress {\n    fun getPolicy(): NetworkPolicy\n\n    fun patchRules(rules: List<NetworkRule>)\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Filesystem.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry\nimport java.io.InputStream\nimport java.util.Collections\n\n/**\n * Filesystem operations for sandbox environments.\n *\n * This service provides comprehensive file system management capabilities\n * within sandbox environments, including file operations, directory management,\n * and metadata handling with proper security controls.\n */\ninterface Filesystem {\n    /**\n     * Reads the content of a file as a string with specified encoding.\n     *\n     * @param path The absolute or relative path to the file to read\n     * @param encoding Character encoding for the file content (default: UTF-8)\n     * @param range HTTP byte range to read (e.g., \"bytes=0-1023\").\n     * @return The file content as a string\n     * @throws SandboxException if the operation fails\n     */\n    fun readFile(\n        path: String,\n        encoding: String = \"UTF-8\",\n        range: String? = null,\n    ): String\n\n    /**\n     * Convenience overload for reading a file as a string using UTF-8.\n     *\n     * Equivalent to: `readFile(path, \"UTF-8\", null)`\n     *\n     * @param path The absolute or relative path to the file to read\n     * @return The file content as a UTF-8 string\n     */\n    fun readFile(path: String): String {\n        return readFile(path, \"UTF-8\", null)\n    }\n\n    /**\n     * Reads the content of a file as a byte array.\n     *\n     * @param path The absolute or relative path to the file to read\n     * @param range HTTP byte range to read (e.g., \"bytes=0-1023\").\n     * @return The file content as a byte array\n     * @throws SandboxException if the operation fails\n     */\n    fun readByteArray(\n        path: String,\n        range: String? = null,\n    ): ByteArray\n\n    /**\n     * Convenience overload for reading a file as a byte array.\n     *\n     * Equivalent to: `readByteArray(path, null)`\n     *\n     * @param path The absolute or relative path to the file to read\n     * @return The full file content as a byte array\n     */\n    fun readByteArray(path: String): ByteArray {\n        return readByteArray(path, null)\n    }\n\n    /**\n     * Opens a file for reading as an InputStream.\n     *\n     * @param path The absolute or relative path to the file to read\n     * @param range HTTP byte range to read (e.g., \"bytes=0-1023\").\n     * @return An InputStream for reading the file content\n     * @throws SandboxException if the operation fails\n     */\n    fun readStream(\n        path: String,\n        range: String? = null,\n    ): InputStream\n\n    /**\n     * Convenience overload for opening a file stream.\n     *\n     * Equivalent to: `readStream(path, null)`\n     *\n     * @param path The absolute or relative path to the file to read\n     * @return An InputStream for reading the file content\n     */\n    fun readStream(path: String): InputStream {\n        return readStream(path, null)\n    }\n\n    /**\n     * Writes content to files based on the provided write entries.\n     *\n     * @param entries List of WriteEntry objects specifying files to write and their content\n     * @throws SandboxException if the operation fails\n     */\n    fun write(entries: List<WriteEntry>)\n\n    /**\n     * Writes a single file based on the provided [WriteEntry].\n     */\n    fun writeFile(entry: WriteEntry) {\n        write(Collections.singletonList(entry))\n    }\n\n    /**\n     * Convenience overload for writing a single text file with custom options.\n     */\n    fun writeFile(\n        path: String,\n        data: Any,\n    ) {\n        writeFile(\n            WriteEntry\n                .builder()\n                .path(path)\n                .data(data)\n                .build(),\n        )\n    }\n\n    /**\n     * Creates directories based on the provided entries.\n     *\n     * @param entries List of WriteEntry objects specifying directories to create\n     * @throws SandboxException if the operation fails\n     */\n    fun createDirectories(entries: List<WriteEntry>)\n\n    /**\n     * Deletes the specified files.\n     *\n     * @param paths List of file paths to delete\n     * @throws SandboxException if the operation fails\n     */\n    fun deleteFiles(paths: List<String>)\n\n    /**\n     * Deletes the specified directories.\n     *\n     * @param paths List of directory paths to delete\n     * @throws SandboxException if the operation fails\n     */\n    fun deleteDirectories(paths: List<String>)\n\n    /**\n     * Moves files from source to destination paths.\n     *\n     * @param entries List of MoveEntry objects specifying source and destination paths\n     * @throws SandboxException if the operation fails\n     */\n    fun moveFiles(entries: List<MoveEntry>)\n\n    /**\n     * Sets file system permissions for the specified entries.\n     *\n     * @param entries List of SetPermissionEntry objects specifying files and their new permissions\n     * @throws SandboxException if the operation fails\n     */\n    fun setPermissions(entries: List<SetPermissionEntry>)\n\n    /**\n     * Replaces content in files based on search and replace patterns.\n     *\n     * @param entries List of ContentReplaceEntry objects specifying replacement operations\n     * @throws SandboxException if the operation fails\n     */\n    fun replaceContents(entries: List<ContentReplaceEntry>)\n\n    /**\n     * Searches for files and directories based on the specified criteria.\n     *\n     * @param entry SearchEntry object containing search parameters and criteria\n     * @return List of EntryInfo objects containing metadata for matching files/directories\n     * @throws SandboxException if the operation fails\n     */\n    fun search(entry: SearchEntry): List<EntryInfo>\n\n    /**\n     * Retrieves file information for the specified paths.\n     *\n     * @param paths List of file/directory paths to get information for\n     * @return Map where keys are file paths and values are EntryInfo objects containing file metadata\n     * @throws SandboxException if the operation fails\n     */\n    fun readFileInfo(paths: List<String>): Map<String, EntryInfo>\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Health.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\n/**\n * Health monitoring operations for sandbox environments.\n */\ninterface Health {\n    /**\n     * Performs a basic health check on the specified sandbox.\n     *\n     * @param sandboxId Unique identifier of the target sandbox\n     * @return true if sandbox is healthy and responsive, false otherwise\n     */\n    fun ping(sandboxId: String): Boolean\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Metrics.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics\n\n/**\n * Metrics collection and monitoring operations for sandbox environments.\n */\ninterface Metrics {\n    /**\n     * Retrieves current resource utilization metrics for a sandbox.\n     *\n     * @param sandboxId Unique identifier of the target sandbox\n     * @return Current resource utilization snapshot\n     */\n    fun getMetrics(sandboxId: String): SandboxMetrics\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Sandboxes.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.services\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxCreateResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume\nimport java.time.Duration\nimport java.time.OffsetDateTime\n\n/**\n * Core sandbox lifecycle management service.\n *\n * This service provides a clean abstraction over sandbox creation, management,\n * and termination operations, completely isolating business logic from API implementation details.\n */\ninterface Sandboxes {\n    /**\n     * Creates a new sandbox with the specified configuration.\n     *\n     * @param spec Container image specification for provisioning the sandbox\n     * @param entrypoint The command to run as the sandbox's main process (e.g. `[\"python\", \"/app/main.py\"]`)\n     * @param env Environment variables injected into the sandbox runtime\n     * @param metadata User-defined metadata used for management and filtering\n     * @param timeout Sandbox lifetime. Pass null to require explicit cleanup.\n     * @param resource Runtime resource limits (e.g. cpu/memory). Exact semantics are server-defined\n     * @param networkPolicy Optional outbound network policy (egress)\n     * @param extensions Opaque extension parameters passed through to the server as-is. Prefer namespaced keys\n     * @param volumes Optional list of volume mounts for persistent storage\n     * @return Sandbox creation response containing the sandbox id\n     */\n    fun createSandbox(\n        spec: SandboxImageSpec,\n        entrypoint: List<String>,\n        env: Map<String, String>,\n        metadata: Map<String, String>,\n        timeout: Duration?,\n        resource: Map<String, String>,\n        networkPolicy: NetworkPolicy?,\n        extensions: Map<String, String>,\n        volumes: List<Volume>?,\n    ): SandboxCreateResponse\n\n    /**\n     * Retrieves information about an existing sandbox.\n     *\n     * @param sandboxId Unique identifier of the sandbox\n     * @return Current sandbox information\n     */\n    fun getSandboxInfo(sandboxId: String): SandboxInfo\n\n    /**\n     * Lists sandboxes with optional filtering.\n     *\n     * @param filter Optional filter criteria\n     * @return List of sandbox information matching the filter\n     */\n    fun listSandboxes(filter: SandboxFilter): PagedSandboxInfos\n\n    /**\n     * Get sandbox endpoint\n     *\n     * @param sandboxId sandbox id\n     * @param port endpoint port number\n     * @return Target sandbox endpoint\n     */\n    fun getSandboxEndpoint(\n        sandboxId: String,\n        port: Int,\n    ): SandboxEndpoint\n\n    /**\n     * Get sandbox endpoint\n     *\n     * @param sandboxId sandbox id\n     * @param port endpoint port number\n     * @param useServerProxy whether to use server proxy for endpoint (default false)\n     * @return Target sandbox endpoint\n     */\n    fun getSandboxEndpoint(\n        sandboxId: String,\n        port: Int,\n        useServerProxy: Boolean,\n    ): SandboxEndpoint\n\n    /**\n     * Pauses a running sandbox, preserving its state.\n     *\n     * @param sandboxId Unique identifier of the sandbox\n     */\n    fun pauseSandbox(sandboxId: String)\n\n    /**\n     * Resumes a paused sandbox.\n     *\n     * @param sandboxId Unique identifier of the sandbox\n     */\n    fun resumeSandbox(sandboxId: String)\n\n    /**\n     * Renew the expiration time of a sandbox.\n     *\n     * @param sandboxId Unique identifier of the sandbox\n     * @param newExpirationTime New expiration timestamp\n     *\n     * @return Sandbox renew response with new expire info\n     */\n    fun renewSandboxExpiration(\n        sandboxId: String,\n        newExpirationTime: OffsetDateTime,\n    ): SandboxRenewResponse\n\n    /**\n     * Terminates a sandbox and releases all associated resources.\n     *\n     * @param sandboxId Unique identifier of the sandbox\n     */\n    fun killSandbox(sandboxId: String)\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExceptionConverter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\nimport com.alibaba.opensandbox.sandbox.api.infrastructure.ClientError\nimport com.alibaba.opensandbox.sandbox.api.infrastructure.ClientException\nimport com.alibaba.opensandbox.sandbox.api.infrastructure.ServerError\nimport com.alibaba.opensandbox.sandbox.api.infrastructure.ServerException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxInternalException\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.JsonElement\nimport kotlinx.serialization.json.decodeFromJsonElement\nimport kotlinx.serialization.json.encodeToJsonElement\nimport java.io.IOException\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ClientError as ExecdClientError\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ClientException as ExecdClientException\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerError as ExecdServerError\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerException as ExecdServerException\n\nfun Exception.toSandboxException(): SandboxException {\n    return when (this) {\n        is SandboxException -> this\n        is ClientException, is ServerException,\n        is ExecdClientException, is ExecdServerException,\n        -> this.toApiException()\n        is IOException ->\n            SandboxInternalException(\n                message = \"Network connectivity error: ${this.message}\",\n                cause = this,\n            )\n        is IllegalStateException, is IllegalArgumentException ->\n            SandboxInternalException(\n                message = \"SDK internal usage error: ${this.message}\",\n                cause = this,\n            )\n        is UnsupportedOperationException ->\n            SandboxInternalException(\n                message = \"Operation not supported: ${this.message}\",\n                cause = this,\n            )\n        else ->\n            SandboxInternalException(\n                message = \"Unexpected SDK error occurred: ${this.message}\",\n                cause = this,\n            )\n    }\n}\n\nprivate fun Exception.toApiException(): SandboxApiException {\n    val (statusCode, rawResponse) =\n        when (this) {\n            is ClientException -> this.statusCode to this.response\n            is ServerException -> this.statusCode to this.response\n            is ExecdClientException -> this.statusCode to this.response\n            is ExecdServerException -> this.statusCode to this.response\n            else -> 0 to null\n        }\n\n    val requestId =\n        when (rawResponse) {\n            is ClientError<*> -> rawResponse.headers.extractRequestId()\n            is ServerError<*> -> rawResponse.headers.extractRequestId()\n            is ExecdClientError<*> -> rawResponse.headers.extractRequestId()\n            is ExecdServerError<*> -> rawResponse.headers.extractRequestId()\n            else -> null\n        }\n\n    val errorBody =\n        when (rawResponse) {\n            is ClientError<*> -> rawResponse.body\n            is ExecdServerError<*> -> rawResponse.body\n            is ServerError<*> -> rawResponse.body\n            is ExecdClientError<*> -> rawResponse.body\n            else -> null\n        }\n\n    val sandboxError =\n        parseSandboxError(errorBody) ?: if (errorBody is String) {\n            SandboxError(UNEXPECTED_RESPONSE, errorBody)\n        } else {\n            SandboxError(UNEXPECTED_RESPONSE)\n        }\n\n    return SandboxApiException(\n        message = this.message,\n        statusCode = statusCode,\n        cause = this,\n        error = sandboxError,\n        requestId = requestId,\n    )\n}\n\nprivate fun Map<String, List<String>>.extractRequestId(): String? {\n    return entries.firstOrNull { (key, _) ->\n        key.equals(\"X-Request-ID\", ignoreCase = true)\n    }?.value?.firstOrNull()?.takeIf { it.isNotBlank() }\n}\n\nfun parseSandboxError(body: Any?): SandboxError? {\n    if (body == null) return null\n\n    return runCatching {\n        val jsonElement: JsonElement =\n            when (body) {\n                is String -> jsonParser.parseToJsonElement(body)\n                else -> jsonParser.encodeToJsonElement(body)\n            }\n\n        val generic = jsonParser.decodeFromJsonElement<GenericErrorBody>(jsonElement)\n\n        if (!generic.code.isNullOrBlank()) {\n            SandboxError(code = generic.code, message = generic.message)\n        } else {\n            null\n        }\n    }.getOrNull()\n}\n\n@Serializable\nprivate data class GenericErrorBody(\n    val code: String? = null,\n    val message: String? = null,\n)\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionConverter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.CommandStatus\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest\nimport com.alibaba.opensandbox.sandbox.api.models.execd.CommandStatusResponse as ApiCommandStatusResponse\nimport com.alibaba.opensandbox.sandbox.api.models.execd.RunCommandRequest as ApiRunCommandRequest\n\nobject ExecutionConverter {\n    fun RunCommandRequest.toApiRunCommandRequest(): ApiRunCommandRequest {\n        return ApiRunCommandRequest(\n            command = command,\n            background = background,\n            cwd = workingDirectory,\n            timeout = timeout?.inWholeMilliseconds,\n            uid = uid,\n            gid = gid,\n            envs = envs,\n        )\n    }\n\n    fun ApiCommandStatusResponse.toCommandStatus(): CommandStatus {\n        return CommandStatus(\n            id = id,\n            content = content,\n            running = running,\n            exitCode = exitCode,\n            error = error,\n            startedAt = startedAt,\n            finishedAt = finishedAt,\n        )\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/ExecutionEventDispatcher.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\nimport com.alibaba.opensandbox.sandbox.api.models.execd.EventNode\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionComplete\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionError\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionInit\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionResult\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.OutputMessage\n\nclass ExecutionEventDispatcher(\n    private val execution: Execution,\n    private val handlers: ExecutionHandlers? = null,\n) {\n    fun dispatch(eventNode: EventNode) {\n        val type = eventNode.type\n        val timestamp = eventNode.timestamp\n        when (type) {\n            \"stdout\" -> handleStdout(eventNode, timestamp)\n            \"stderr\" -> handleStderr(eventNode, timestamp)\n            \"result\" -> handleResult(eventNode, timestamp)\n            \"error\" -> handleError(eventNode, timestamp)\n            \"execution_complete\" -> handleExecutionComplete(eventNode, timestamp)\n            \"init\" -> handleInit(eventNode, timestamp)\n            \"execution_count\" -> execution.executionCount = eventNode.executionCount\n        }\n    }\n\n    private fun handleInit(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val init =\n            ExecutionInit(\n                id = eventNode.text ?: \"\",\n                timestamp = timestamp,\n            )\n        execution.id = init.id\n        handlers?.onInit?.handle(init)\n    }\n\n    private fun handleStdout(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val stdoutText = eventNode.text ?: \"\"\n        val stdoutMessage = OutputMessage(stdoutText, timestamp, false)\n        execution.logs.addStdout(stdoutMessage)\n        handlers?.onStdout?.handle(stdoutMessage)\n    }\n\n    private fun handleStderr(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val stderrText = eventNode.text ?: \"\"\n        val stderrMessage = OutputMessage(stderrText, timestamp, true)\n        execution.logs.addStderr(stderrMessage)\n        handlers?.onStderr?.handle(stderrMessage)\n    }\n\n    private fun handleResult(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val resultText = eventNode.results?.getText() ?: \"\"\n        val result =\n            ExecutionResult(resultText, timestamp).apply {\n                this.timestamp = timestamp\n            }\n        execution.addResult(result)\n        handlers?.onResult?.handle(result)\n    }\n\n    private fun handleError(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val errorData = eventNode.error!!\n        val error =\n            ExecutionError(\n                name = errorData.name ?: \"\",\n                value = errorData.value ?: \"\",\n                traceback = errorData.traceback,\n                timestamp = timestamp,\n            )\n        execution.error = error\n        handlers?.onError?.handle(error)\n    }\n\n    private fun handleExecutionComplete(\n        eventNode: EventNode,\n        timestamp: Long,\n    ) {\n        val complete =\n            ExecutionComplete(\n                executionTimeInMillis = eventNode.executionTimeInMillis ?: 0L,\n                timestamp = timestamp,\n            )\n        handlers?.onExecutionComplete?.handle(complete)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/FilesystemConverter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry\nimport com.alibaba.opensandbox.sandbox.api.models.execd.FileInfo as ApiFileInfo\nimport com.alibaba.opensandbox.sandbox.api.models.execd.Permission as ApiPermission\nimport com.alibaba.opensandbox.sandbox.api.models.execd.RenameFileItem as ApiRenameFileItem\nimport com.alibaba.opensandbox.sandbox.api.models.execd.ReplaceFileContentItem as ApiReplaceFileContentItem\n\n/**\n * Converter between domain models and API models for filesystem operations.\n *\n * @author ninan\n * @since 2025/12/2\n */\nobject FilesystemConverter {\n    /**\n     * Converts API FileInfo to domain EntryInfo.\n     */\n    fun ApiFileInfo.toEntryInfo(): EntryInfo {\n        return EntryInfo(\n            path = this.path,\n            mode = this.mode,\n            owner = this.owner,\n            group = this.group,\n            createdAt = this.createdAt,\n            modifiedAt = this.modifiedAt,\n            size = this.propertySize,\n        )\n    }\n\n    /**\n     * Converts domain SetPermissionEntry to API Permission.\n     */\n    fun SetPermissionEntry.toApiPermission(): ApiPermission {\n        return ApiPermission(\n            owner = this.owner,\n            group = this.group,\n            mode = this.mode,\n        )\n    }\n\n    /**\n     * Converts domain MoveEntry to API RenameFileItem.\n     */\n    fun MoveEntry.toApiRenameFileItem(): ApiRenameFileItem {\n        return ApiRenameFileItem(\n            src = this.src,\n            dest = this.dest,\n        )\n    }\n\n    /**\n     * Converts domain ContentReplaceEntry to API ReplaceFileContentItem.\n     */\n    fun ContentReplaceEntry.toApiReplaceFileContentItem(): ApiReplaceFileContentItem {\n        return ApiReplaceFileContentItem(\n            old = this.oldContent,\n            new = this.newContent,\n        )\n    }\n\n    /**\n     * Converts list of domain MoveEntry to list of API RenameFileItem.\n     */\n    fun List<MoveEntry>.toApiRenameFileItems(): List<ApiRenameFileItem> {\n        return this.map { it.toApiRenameFileItem() }\n    }\n\n    /**\n     * Converts list of domain SetPermissionEntry to map of path to API Permission.\n     */\n    fun List<SetPermissionEntry>.toApiPermissionMap(): Map<String, ApiPermission> {\n        return this.associate { entry ->\n            entry.path to entry.toApiPermission()\n        }\n    }\n\n    /**\n     * Converts list of domain ContentReplaceEntry to map of path to API ReplaceFileContentItem.\n     */\n    fun List<ContentReplaceEntry>.toApiReplaceFileContentMap(): Map<String, ApiReplaceFileContentItem> {\n        return this.associate { entry ->\n            entry.path to entry.toApiReplaceFileContentItem()\n        }\n    }\n\n    /**\n     * Converts map of path to API FileInfo to map of path to domain EntryInfo.\n     */\n    fun Map<String, ApiFileInfo>.toEntryInfoMap(): Map<String, EntryInfo> {\n        return this.mapValues { (_, apiFileInfo) ->\n            apiFileInfo.toEntryInfo()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\n// API Models\nimport com.alibaba.opensandbox.sandbox.api.models.CreateSandboxRequest\nimport com.alibaba.opensandbox.sandbox.api.models.CreateSandboxResponse\nimport com.alibaba.opensandbox.sandbox.api.models.Endpoint\nimport com.alibaba.opensandbox.sandbox.api.models.ImageSpec\nimport com.alibaba.opensandbox.sandbox.api.models.ImageSpecAuth\nimport com.alibaba.opensandbox.sandbox.api.models.ListSandboxesResponse\nimport com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationRequest\nimport com.alibaba.opensandbox.sandbox.api.models.RenewSandboxExpirationResponse\nimport com.alibaba.opensandbox.sandbox.api.models.execd.Metrics\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Host\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PVC\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxCreateResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageAuth\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume\nimport java.time.Duration\nimport java.time.OffsetDateTime\nimport com.alibaba.opensandbox.sandbox.api.models.Host as ApiHost\nimport com.alibaba.opensandbox.sandbox.api.models.NetworkPolicy as ApiNetworkPolicy\nimport com.alibaba.opensandbox.sandbox.api.models.NetworkRule as ApiNetworkRule\nimport com.alibaba.opensandbox.sandbox.api.models.PVC as ApiPVC\nimport com.alibaba.opensandbox.sandbox.api.models.PaginationInfo as ApiPaginationInfo\nimport com.alibaba.opensandbox.sandbox.api.models.Sandbox as ApiSandbox\nimport com.alibaba.opensandbox.sandbox.api.models.SandboxStatus as ApiSandboxStatus\nimport com.alibaba.opensandbox.sandbox.api.models.Volume as ApiVolume\nimport com.alibaba.opensandbox.sandbox.api.models.egress.NetworkPolicy as ApiEgressNetworkPolicy\nimport com.alibaba.opensandbox.sandbox.api.models.egress.NetworkRule as ApiEgressNetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus as DomainSandboxStatus\n\ninternal object SandboxModelConverter {\n    /**\n     * Converts Domain ImageSpec -> API ImageSpec\n     */\n    fun SandboxImageSpec.toApiImageSpec(): ImageSpec {\n        return ImageSpec(\n            uri = this.image,\n            auth =\n                this.auth?.let {\n                    ImageSpecAuth(\n                        username = it.username,\n                        password = it.password,\n                    )\n                },\n        )\n    }\n\n    /**\n     * Converts Time -> API renew Request\n     */\n    fun OffsetDateTime.toApiRenewRequest(): RenewSandboxExpirationRequest {\n        return RenewSandboxExpirationRequest(\n            expiresAt = this,\n        )\n    }\n\n    /**\n     * Converts Domain NetworkPolicy -> API NetworkPolicy\n     */\n    fun NetworkPolicy.toApiNetworkPolicy(): ApiNetworkPolicy {\n        val apiDefaultAction =\n            defaultAction?.let { action ->\n                when (action) {\n                    NetworkPolicy.DefaultAction.ALLOW -> ApiNetworkPolicy.DefaultAction.allow\n                    NetworkPolicy.DefaultAction.DENY -> ApiNetworkPolicy.DefaultAction.deny\n                }\n            }\n        val apiEgress =\n            egress?.map { rule ->\n                ApiNetworkRule(\n                    action =\n                        when (rule.action) {\n                            NetworkRule.Action.ALLOW -> ApiNetworkRule.Action.allow\n                            NetworkRule.Action.DENY -> ApiNetworkRule.Action.deny\n                        },\n                    target = rule.target,\n                )\n            }\n        return ApiNetworkPolicy(\n            defaultAction = apiDefaultAction,\n            egress = apiEgress,\n        )\n    }\n\n    fun NetworkRule.toApiNetworkRule(): ApiNetworkRule {\n        val action =\n            when (this.action) {\n                NetworkRule.Action.ALLOW -> ApiNetworkRule.Action.allow\n                NetworkRule.Action.DENY -> ApiNetworkRule.Action.deny\n            }\n        return ApiNetworkRule(action = action, target = this.target)\n    }\n\n    fun NetworkRule.toApiEgressNetworkRule(): ApiEgressNetworkRule {\n        val action =\n            when (this.action) {\n                NetworkRule.Action.ALLOW -> ApiEgressNetworkRule.Action.allow\n                NetworkRule.Action.DENY -> ApiEgressNetworkRule.Action.deny\n            }\n        return ApiEgressNetworkRule(action = action, target = this.target)\n    }\n\n    fun ApiNetworkRule.toDomainNetworkRule(): NetworkRule {\n        val action =\n            when (this.action) {\n                ApiNetworkRule.Action.allow -> NetworkRule.Action.ALLOW\n                ApiNetworkRule.Action.deny -> NetworkRule.Action.DENY\n            }\n        return NetworkRule\n            .builder()\n            .action(action)\n            .target(this.target)\n            .build()\n    }\n\n    fun ApiNetworkPolicy.toDomainNetworkPolicy(): NetworkPolicy {\n        val defaultAction =\n            when (this.defaultAction) {\n                ApiNetworkPolicy.DefaultAction.allow -> NetworkPolicy.DefaultAction.ALLOW\n                ApiNetworkPolicy.DefaultAction.deny, null -> NetworkPolicy.DefaultAction.DENY\n            }\n        return NetworkPolicy\n            .builder()\n            .defaultAction(defaultAction)\n            .egress(this.egress?.map { it.toDomainNetworkRule() } ?: emptyList())\n            .build()\n    }\n\n    fun ApiEgressNetworkRule.toDomainEgressNetworkRule(): NetworkRule {\n        val action =\n            when (this.action) {\n                ApiEgressNetworkRule.Action.allow -> NetworkRule.Action.ALLOW\n                ApiEgressNetworkRule.Action.deny -> NetworkRule.Action.DENY\n            }\n        return NetworkRule\n            .builder()\n            .action(action)\n            .target(this.target)\n            .build()\n    }\n\n    fun ApiEgressNetworkPolicy.toDomainEgressNetworkPolicy(): NetworkPolicy {\n        val defaultAction =\n            when (this.defaultAction) {\n                ApiEgressNetworkPolicy.DefaultAction.allow -> NetworkPolicy.DefaultAction.ALLOW\n                ApiEgressNetworkPolicy.DefaultAction.deny, null -> NetworkPolicy.DefaultAction.DENY\n            }\n        return NetworkPolicy\n            .builder()\n            .defaultAction(defaultAction)\n            .egress(this.egress?.map { it.toDomainEgressNetworkRule() } ?: emptyList())\n            .build()\n    }\n\n    /**\n     * Converts Domain Host -> API Host\n     */\n    fun Host.toApiHost(): ApiHost {\n        return ApiHost(path = this.path)\n    }\n\n    /**\n     * Converts Domain PVC -> API PVC\n     */\n    fun PVC.toApiPVC(): ApiPVC {\n        return ApiPVC(claimName = this.claimName)\n    }\n\n    /**\n     * Converts Domain Volume -> API Volume\n     */\n    fun Volume.toApiVolume(): ApiVolume {\n        return ApiVolume(\n            name = this.name,\n            mountPath = this.mountPath,\n            readOnly = this.readOnly,\n            host = this.host?.toApiHost(),\n            pvc = this.pvc?.toApiPVC(),\n            subPath = this.subPath,\n        )\n    }\n\n    fun toApiCreateSandboxRequest(\n        spec: SandboxImageSpec,\n        entrypoint: List<String>,\n        env: Map<String, String>,\n        metadata: Map<String, String>,\n        timeout: Duration?,\n        resource: Map<String, String>,\n        networkPolicy: NetworkPolicy?,\n        extensions: Map<String, String>,\n        volumes: List<Volume>?,\n    ): CreateSandboxRequest {\n        return CreateSandboxRequest(\n            image = spec.toApiImageSpec(),\n            entrypoint = entrypoint,\n            timeout = timeout?.seconds?.toInt(),\n            env = env,\n            metadata = metadata,\n            resourceLimits = resource,\n            networkPolicy = networkPolicy?.toApiNetworkPolicy(),\n            extensions = extensions,\n            volumes = volumes?.map { it.toApiVolume() },\n        )\n    }\n\n    /**\n     * API Sandbox -> Domain SandboxInfo\n     */\n    fun ApiSandbox.toSandboxInfo(): SandboxInfo {\n        return SandboxInfo(\n            id = this.id,\n            entrypoint = this.entrypoint,\n            expiresAt = this.expiresAt,\n            createdAt = this.createdAt,\n            image = this.image.toImageSpec(),\n            status = this.status.toSandboxStatus(),\n            metadata = metadata,\n        )\n    }\n\n    /**\n     * API ImageSpec -> Domain ImageSpec\n     */\n    fun ImageSpec.toImageSpec(): SandboxImageSpec {\n        val builder =\n            SandboxImageSpec.builder()\n                .image(uri)\n\n        auth?.let { authInfo ->\n            val sandboxAuth =\n                SandboxImageAuth.builder()\n                    .username(authInfo.username.orEmpty())\n                    .password(authInfo.password.orEmpty())\n                    .build()\n            builder.auth(sandboxAuth)\n        }\n\n        return builder.build()\n    }\n\n    /**\n     * API Status -> Domain Status\n     */\n    fun ApiSandboxStatus.toSandboxStatus(): DomainSandboxStatus {\n        return DomainSandboxStatus(\n            state = this.state,\n            reason = this.reason,\n            message = this.message,\n            lastTransitionAt = this.lastTransitionAt,\n        )\n    }\n\n    /**\n     * API Endpoint -> Domain Endpoint\n     */\n    fun Endpoint.toSandboxEndpoint(): SandboxEndpoint {\n        return SandboxEndpoint(this.endpoint, this.headers ?: emptyMap())\n    }\n\n    /**\n     * API Create Response -> Domain Create Response\n     */\n    fun CreateSandboxResponse.toSandboxCreateResponse(): SandboxCreateResponse {\n        return SandboxCreateResponse(\n            id = this.id,\n        )\n    }\n\n    fun ApiPaginationInfo.toPaginationInfo(): PaginationInfo {\n        return PaginationInfo(\n            page = this.page,\n            pageSize = this.pageSize,\n            totalItems = this.totalItems,\n            totalPages = this.totalPages,\n            hasNextPage = this.hasNextPage,\n        )\n    }\n\n    /**\n     * API List Response -> Domain Paged Infos\n     */\n    fun ListSandboxesResponse.toPagedSandboxInfos(): PagedSandboxInfos {\n        return PagedSandboxInfos(\n            items.map { it.toSandboxInfo() },\n            pagination.toPaginationInfo(),\n        )\n    }\n\n    fun Metrics.toSandboxMetrics(): SandboxMetrics {\n        return SandboxMetrics(\n            cpuCount = this.cpuCount,\n            cpuUsedPercentage = cpuUsedPct,\n            memoryTotalInMiB = memTotalMib,\n            memoryUsedInMiB = memUsedMib,\n            timestamp = this.timestamp,\n        )\n    }\n\n    fun RenewSandboxExpirationResponse.toSandboxRenewResponse(): SandboxRenewResponse {\n        return SandboxRenewResponse(\n            expiresAt = this.expiresAt,\n        )\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/Serializer.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter\n\nimport kotlinx.serialization.KSerializer\nimport kotlinx.serialization.descriptors.elementNames\nimport kotlinx.serialization.encoding.Decoder\nimport kotlinx.serialization.encoding.Encoder\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.JsonDecoder\nimport kotlinx.serialization.json.JsonElement\nimport kotlinx.serialization.json.JsonEncoder\nimport kotlinx.serialization.json.JsonObject\nimport kotlinx.serialization.json.jsonObject\n\nval jsonParser =\n    Json {\n        ignoreUnknownKeys = true\n        isLenient = true\n        encodeDefaults = true\n        coerceInputValues = true\n    }\n\nabstract class AbstractUnknownPropertiesSerializer<T>(\n    private val delegate: KSerializer<T>,\n) : KSerializer<T> {\n    override val descriptor = delegate.descriptor\n\n    abstract fun T.withUnknownProperties(unknown: Map<String, JsonElement>): T\n\n    abstract fun T.getUnknownProperties(): Map<String, JsonElement>\n\n    override fun deserialize(decoder: Decoder): T {\n        require(decoder is JsonDecoder)\n\n        val jsonObject = decoder.decodeJsonElement().jsonObject\n\n        val knownKeys = delegate.descriptor.elementNames.toSet()\n\n        val unknownProperties = jsonObject.filterKeys { it !in knownKeys }\n\n        val cleanJsonObject = JsonObject(jsonObject.filterKeys { it in knownKeys })\n        val standardObject = decoder.json.decodeFromJsonElement(delegate, cleanJsonObject)\n\n        return standardObject.withUnknownProperties(unknownProperties)\n    }\n\n    override fun serialize(\n        encoder: Encoder,\n        value: T,\n    ) {\n        require(encoder is JsonEncoder)\n\n        val standardJsonElement = encoder.json.encodeToJsonElement(delegate, value)\n        val standardJsonObject = standardJsonElement.jsonObject\n\n        val unknownProperties = value.getUnknownProperties()\n\n        val mergedMap = standardJsonObject.toMutableMap()\n        mergedMap.putAll(unknownProperties)\n\n        encoder.encodeJsonElement(JsonObject(mergedMap))\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.execd.CommandApi\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ClientError\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ClientException\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ResponseType\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerError\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.ServerException\nimport com.alibaba.opensandbox.sandbox.api.execd.infrastructure.Success\nimport com.alibaba.opensandbox.sandbox.api.models.execd.EventNode\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.CommandLogs\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.CommandStatus\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.Execution\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.services.Commands\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.ExecutionConverter.toApiRunCommandRequest\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.ExecutionConverter.toCommandStatus\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.ExecutionEventDispatcher\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.jsonParser\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport okhttp3.Headers.Companion.toHeaders\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.slf4j.LoggerFactory\n\n/**\n * Implementation of [Commands] that adapts OpenAPI-generated [CommandApi].\n *\n * This adapter handles command execution within sandboxes, providing both\n * synchronous and streaming execution modes with proper session management.\n */\ninternal class CommandsAdapter(\n    private val httpClientProvider: HttpClientProvider,\n    private val execdEndpoint: SandboxEndpoint,\n) : Commands {\n    companion object {\n        private const val RUN_COMMAND_PATH = \"/command\"\n    }\n\n    private val logger = LoggerFactory.getLogger(CommandsAdapter::class.java)\n    private val api =\n        CommandApi(\n            \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}\",\n            httpClientProvider.httpClient.newBuilder()\n                .addInterceptor { chain ->\n                    val requestBuilder = chain.request().newBuilder()\n                    execdEndpoint.headers.forEach { (key, value) ->\n                        requestBuilder.header(key, value)\n                    }\n                    chain.proceed(requestBuilder.build())\n                }\n                .build(),\n        )\n\n    override fun run(request: RunCommandRequest): Execution {\n        if (request.command.isEmpty()) {\n            throw InvalidArgumentException(\"Command cannot be empty\")\n        }\n        try {\n            val httpRequest =\n                Request.Builder()\n                    .url(\"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}$RUN_COMMAND_PATH\")\n                    .post(\n                        jsonParser.encodeToString(request.toApiRunCommandRequest()).toRequestBody(\"application/json\".toMediaType()),\n                    )\n                    .headers(execdEndpoint.headers.toHeaders())\n                    .build()\n\n            val execution = Execution()\n\n            httpClientProvider.sseClient.newCall(httpRequest).execute().use { response ->\n                if (!response.isSuccessful) {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to run commands. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                }\n\n                response.body?.byteStream()?.bufferedReader(Charsets.UTF_8)?.use { reader ->\n                    val dispatcher = ExecutionEventDispatcher(execution, request.handlers)\n                    reader.lineSequence()\n                        .filter(String::isNotBlank)\n                        .forEach { line ->\n                            try {\n                                val eventNode = jsonParser.decodeFromString<EventNode>(line)\n                                dispatcher.dispatch(eventNode)\n                            } catch (e: Exception) {\n                                logger.error(\"Failed to parse SSE line: {}\", line, e)\n                            }\n                        }\n                }\n            }\n            return execution\n        } catch (e: Exception) {\n            logger.error(\"Failed to run command (length: {})\", request.command.length, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun interrupt(executionId: String) {\n        try {\n            api.interruptCommand(executionId)\n        } catch (e: Exception) {\n            logger.error(\"Failed to interrupt command\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun getCommandStatus(executionId: String): CommandStatus {\n        return try {\n            val status = api.getCommandStatus(executionId)\n            status.toCommandStatus()\n        } catch (e: Exception) {\n            logger.error(\"Failed to get command status\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun getBackgroundCommandLogs(\n        executionId: String,\n        cursor: Long?,\n    ): CommandLogs {\n        return try {\n            val localVarResponse = api.getBackgroundCommandLogsWithHttpInfo(executionId, cursor)\n            val content =\n                when (localVarResponse.responseType) {\n                    ResponseType.Success -> (localVarResponse as Success<*>).data as String\n                    ResponseType.Informational ->\n                        throw UnsupportedOperationException(\"Client does not support Informational responses.\")\n                    ResponseType.Redirection ->\n                        throw UnsupportedOperationException(\"Client does not support Redirection responses.\")\n                    ResponseType.ClientError -> {\n                        val localVarError = localVarResponse as ClientError<*>\n                        throw ClientException(\n                            \"Client error : ${localVarError.statusCode} ${localVarError.message.orEmpty()}\",\n                            localVarError.statusCode,\n                            localVarResponse,\n                        )\n                    }\n                    ResponseType.ServerError -> {\n                        val localVarError = localVarResponse as ServerError<*>\n                        throw ServerException(\n                            \"Server error : ${localVarError.statusCode} ${localVarError.message.orEmpty()} ${localVarError.body}\",\n                            localVarError.statusCode,\n                            localVarResponse,\n                        )\n                    }\n                }\n            val cursorHeader =\n                localVarResponse.headers[\"EXECD-COMMANDS-TAIL-CURSOR\"]?.firstOrNull()\n            val nextCursor = cursorHeader?.toLongOrNull()\n            CommandLogs(content = content, cursor = nextCursor)\n        } catch (e: Exception) {\n            logger.error(\"Failed to get command logs\", e)\n            throw e.toSandboxException()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/EgressAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.egress.PolicyApi\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.services.Egress\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toApiEgressNetworkRule\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toDomainEgressNetworkPolicy\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport org.slf4j.LoggerFactory\n\ninternal class EgressAdapter(\n    private val httpClientProvider: HttpClientProvider,\n    private val egressEndpoint: SandboxEndpoint,\n) : Egress {\n    private val logger = LoggerFactory.getLogger(EgressAdapter::class.java)\n    private val api =\n        PolicyApi(\n            \"${httpClientProvider.config.protocol}://${egressEndpoint.endpoint}\",\n            httpClientProvider.httpClient.newBuilder()\n                .addInterceptor { chain ->\n                    val requestBuilder = chain.request().newBuilder()\n                    egressEndpoint.headers.forEach { (key, value) ->\n                        requestBuilder.header(key, value)\n                    }\n                    chain.proceed(requestBuilder.build())\n                }\n                .build(),\n        )\n\n    override fun getPolicy(): NetworkPolicy {\n        return try {\n            val policy =\n                api.policyGet().policy\n                    ?: throw IllegalStateException(\"Egress policy response did not contain policy\")\n            policy.toDomainEgressNetworkPolicy()\n        } catch (e: Exception) {\n            logger.error(\"Failed to fetch egress policy from endpoint {}\", egressEndpoint.endpoint, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun patchRules(rules: List<NetworkRule>) {\n        try {\n            api.policyPatch(rules.map { it.toApiEgressNetworkRule() })\n        } catch (e: Exception) {\n            logger.error(\"Failed to patch egress policy via endpoint {}\", egressEndpoint.endpoint, e)\n            throw e.toSandboxException()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.execd.FilesystemApi\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError.Companion.UNEXPECTED_RESPONSE\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.services.Filesystem\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toApiPermissionMap\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toApiRenameFileItems\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toApiReplaceFileContentMap\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toEntryInfo\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.FilesystemConverter.toEntryInfoMap\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.parseSandboxError\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport kotlinx.serialization.json.buildJsonObject\nimport kotlinx.serialization.json.put\nimport okhttp3.Headers.Companion.toHeaders\nimport okhttp3.HttpUrl.Companion.toHttpUrl\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.MediaType.Companion.toMediaTypeOrNull\nimport okhttp3.MultipartBody\nimport okhttp3.Request\nimport okhttp3.RequestBody\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okio.BufferedSink\nimport okio.source\nimport org.slf4j.LoggerFactory\nimport java.io.InputStream\nimport java.nio.charset.Charset\n\n/**\n * Implementation of [Filesystem] that adapts OpenAPI-generated [FilesystemApi].\n *\n * This adapter provides comprehensive file system management capabilities for sandboxes,\n * handling all file operations through the translation layer with proper error handling\n * and validation.\n */\ninternal class FilesystemAdapter(\n    private val httpClientProvider: HttpClientProvider,\n    private val execdEndpoint: SandboxEndpoint,\n) : Filesystem {\n    companion object {\n        private const val FILESYSTEM_UPLOAD_PATH = \"/files/upload\"\n        private const val FILESYSTEM_DOWNLOAD_PATH = \"/files/download\"\n    }\n\n    private val logger = LoggerFactory.getLogger(FilesystemAdapter::class.java)\n    private val api =\n        FilesystemApi(\n            \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}\",\n            httpClientProvider.httpClient.newBuilder()\n                .addInterceptor { chain ->\n                    val requestBuilder = chain.request().newBuilder()\n                    execdEndpoint.headers.forEach { (key, value) ->\n                        requestBuilder.header(key, value)\n                    }\n                    chain.proceed(requestBuilder.build())\n                }\n                .build(),\n        )\n\n    override fun readFile(\n        path: String,\n        encoding: String,\n        range: String?,\n    ): String {\n        try {\n            val request = buildDownloadRequest(path, range)\n            httpClientProvider.httpClient.newCall(request).execute().use { response ->\n                if (!response.isSuccessful) {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to read file. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                }\n\n                val charset = getCharsetFromEncoding(encoding)\n                return response.body?.source()?.readString(charset) ?: \"\"\n            }\n        } catch (e: Exception) {\n            logger.error(\"Failed to read file with encoding $encoding: $path\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun readByteArray(\n        path: String,\n        range: String?,\n    ): ByteArray {\n        try {\n            val request = buildDownloadRequest(path, range)\n            httpClientProvider.httpClient.newCall(request).execute().use { response ->\n                if (!response.isSuccessful) {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to read file. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                }\n                return response.body?.bytes() ?: ByteArray(0)\n            }\n        } catch (e: Exception) {\n            logger.error(\"Failed to read file as byte array: $path\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun readStream(\n        path: String,\n        range: String?,\n    ): InputStream {\n        try {\n            val request = buildDownloadRequest(path, range)\n            val response = httpClientProvider.httpClient.newCall(request).execute()\n\n            if (!response.isSuccessful) {\n                try {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to read file. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                } catch (e: Exception) {\n                    response.close()\n                    throw e\n                }\n            }\n\n            return response.body?.byteStream()\n                ?: throw IllegalStateException(\"Response body is null\")\n        } catch (e: Exception) {\n            logger.error(\"Failed to read file as stream: $path\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun write(entries: List<WriteEntry>) {\n        if (entries.isEmpty()) {\n            return\n        }\n\n        try {\n            val builder = MultipartBody.Builder().setType(MultipartBody.FORM)\n            entries.forEach { entry ->\n                val path = entry.path\n                val data = entry.data\n                requireNotNull(path) { \"File path cannot be null\" }\n                requireNotNull(data) { \"File data cannot be null\" }\n                val metadataJsonObject =\n                    buildJsonObject {\n                        put(\"path\", path)\n                        put(\"owner\", entry.owner)\n                        put(\"group\", entry.group)\n                        put(\"mode\", entry.mode)\n                    }\n\n                val metadataJson = metadataJsonObject.toString()\n\n                builder.addFormDataPart(\n                    \"metadata\",\n                    \"metadata\",\n                    metadataJson.toRequestBody(\"application/json\".toMediaType()),\n                )\n\n                val fileBody =\n                    when (data) {\n                        is ByteArray -> data.toRequestBody(\"application/octet-stream\".toMediaType())\n                        is String -> {\n                            val charset = getCharsetFromEncoding(entry.encoding)\n                            data.toRequestBody(\"text/plain; charset=${charset.name()}\".toMediaType())\n                        }\n                        is InputStream ->\n                            object : RequestBody() {\n                                override fun contentType() = \"application/octet-stream\".toMediaTypeOrNull()\n\n                                override fun contentLength() = -1L\n\n                                override fun writeTo(sink: BufferedSink) {\n                                    data.source().use { source -> sink.writeAll(source) }\n                                }\n                            }\n                        else -> throw IllegalArgumentException(\"Unsupported file data type: ${data::class.java}\")\n                    }\n\n                builder.addFormDataPart(\"file\", path, fileBody)\n            }\n\n            val request =\n                Request.Builder()\n                    .url(\"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}$FILESYSTEM_UPLOAD_PATH\")\n                    .headers(execdEndpoint.headers.toHeaders())\n                    .post(builder.build())\n                    .build()\n\n            httpClientProvider.httpClient.newCall(request).execute().use { response ->\n                if (!response.isSuccessful) {\n                    val errorBodyString = response.body?.string()\n                    val sandboxError = parseSandboxError(errorBodyString)\n                    val message = \"Failed to write files. Status code: ${response.code}, Body: $errorBodyString\"\n                    throw SandboxApiException(\n                        message = message,\n                        statusCode = response.code,\n                        error = sandboxError ?: SandboxError(UNEXPECTED_RESPONSE),\n                        requestId = response.header(\"X-Request-ID\"),\n                    )\n                }\n            }\n        } catch (e: Exception) {\n            logger.error(\"Failed to write {} files\", entries.size, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun createDirectories(entries: List<WriteEntry>) {\n        return try {\n            val permissionMap =\n                entries.associate { entry ->\n                    entry.path to\n                        com.alibaba.opensandbox.sandbox.api.models.execd.Permission(\n                            mode = entry.mode,\n                            group = entry.group,\n                            owner = entry.owner,\n                        )\n                }\n            api.makeDirs(permissionMap)\n        } catch (e: Exception) {\n            logger.error(\"Failed to create directories\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun deleteFiles(paths: List<String>) {\n        return try {\n            api.removeFiles(paths)\n        } catch (e: Exception) {\n            logger.error(\"Failed to delete {} files\", paths.size, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun deleteDirectories(paths: List<String>) {\n        return try {\n            api.removeDirs(paths)\n        } catch (e: Exception) {\n            logger.error(\"Failed to delete {} directories\", paths.size, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun moveFiles(entries: List<MoveEntry>) {\n        return try {\n            val renameItems = entries.toApiRenameFileItems()\n            api.renameFiles(renameItems)\n        } catch (e: Exception) {\n            logger.error(\"Failed to move files\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun setPermissions(entries: List<SetPermissionEntry>) {\n        return try {\n            val permissionMap = entries.toApiPermissionMap()\n            api.chmodFiles(permissionMap)\n        } catch (e: Exception) {\n            logger.error(\"Failed to set permissions\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun replaceContents(entries: List<ContentReplaceEntry>) {\n        return try {\n            val replaceMap = entries.toApiReplaceFileContentMap()\n            api.replaceContent(replaceMap)\n        } catch (e: Exception) {\n            logger.error(\"Failed to replace contents\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun search(entry: SearchEntry): List<EntryInfo> {\n        return try {\n            val response = api.searchFiles(entry.path, entry.pattern)\n            response.map { it -> it.toEntryInfo() }\n        } catch (e: Exception) {\n            logger.error(\"Failed to search files\", e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun readFileInfo(paths: List<String>): Map<String, EntryInfo> {\n        return try {\n            val response = api.getFilesInfo(paths)\n            response.toEntryInfoMap()\n        } catch (e: Exception) {\n            logger.error(\"Failed to get file info for {} paths\", paths.size, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    private fun getCharsetFromEncoding(encoding: String): Charset {\n        try {\n            return charset(encoding)\n        } catch (e: IllegalArgumentException) {\n            logger.error(\"Invalid encoding {}\", encoding, e)\n            throw InvalidArgumentException(\"Invalid encoding $encoding\", e)\n        }\n    }\n\n    private fun buildDownloadRequest(\n        path: String,\n        range: String?,\n    ): Request {\n        val baseUrlString = \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}$FILESYSTEM_DOWNLOAD_PATH\"\n        val httpUrl =\n            baseUrlString.toHttpUrl()\n                .newBuilder()\n                .addQueryParameter(\"path\", path)\n                .build()\n\n        val requestBuilder =\n            Request.Builder()\n                .url(httpUrl)\n                .headers(execdEndpoint.headers.toHeaders())\n                .get()\n\n        if (range != null) {\n            requestBuilder.header(\"Range\", range)\n        }\n\n        return requestBuilder.build()\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/HealthAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.execd.HealthApi\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.services.Health\nimport org.slf4j.LoggerFactory\n\n/**\n * Implementation of [Health] that adapts OpenAPI-generated [HealthApi].\n */\ninternal class HealthAdapter(\n    private val httpClientProvider: HttpClientProvider,\n    private val execdEndpoint: SandboxEndpoint,\n) : Health {\n    private val logger = LoggerFactory.getLogger(HealthAdapter::class.java)\n    private val api =\n        HealthApi(\n            \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}\",\n            httpClientProvider.httpClient.newBuilder()\n                .addInterceptor { chain ->\n                    val requestBuilder = chain.request().newBuilder()\n                    execdEndpoint.headers.forEach { (key, value) ->\n                        requestBuilder.header(key, value)\n                    }\n                    chain.proceed(requestBuilder.build())\n                }\n                .build(),\n        )\n\n    override fun ping(sandboxId: String): Boolean {\n        logger.debug(\"Checking health for sandbox: {}\", sandboxId)\n\n        return try {\n            api.ping()\n            logger.debug(\"Health check successful for sandbox {}\", sandboxId)\n            true\n        } catch (e: Exception) {\n            logger.debug(\"Health check failed for sandbox: {}\", sandboxId, e)\n            false\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/MetricsAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.execd.MetricApi\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics\nimport com.alibaba.opensandbox.sandbox.domain.services.Metrics\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxMetrics\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport org.slf4j.LoggerFactory\n\n/**\n * Implementation of [Metrics] that adapts OpenAPI-generated [MetricApi].\n */\ninternal class MetricsAdapter(\n    private val httpClientProvider: HttpClientProvider,\n    private val execdEndpoint: SandboxEndpoint,\n) : Metrics {\n    private val logger = LoggerFactory.getLogger(MetricsAdapter::class.java)\n    private val api =\n        MetricApi(\n            \"${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}\",\n            httpClientProvider.httpClient.newBuilder()\n                .addInterceptor { chain ->\n                    val requestBuilder = chain.request().newBuilder()\n                    execdEndpoint.headers.forEach { (key, value) ->\n                        requestBuilder.header(key, value)\n                    }\n                    chain.proceed(requestBuilder.build())\n                }\n                .build(),\n        )\n\n    override fun getMetrics(sandboxId: String): SandboxMetrics {\n        logger.debug(\"Retrieving sandbox metrics for {}\", sandboxId)\n        return try {\n            api.getMetrics().toSandboxMetrics()\n        } catch (e: Exception) {\n            throw e.toSandboxException()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapter.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.api.SandboxesApi\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxCreateResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toApiRenewRequest\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toPagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxCreateResponse\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxInfo\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.SandboxModelConverter.toSandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException\nimport org.slf4j.LoggerFactory\nimport java.time.Duration\nimport java.time.OffsetDateTime\n\n/**\n * Implementation of [Sandboxes] that adapts OpenAPI-generated [SandboxesApi].\n *\n * This adapter provides a clean abstraction layer between business logic and\n * the auto-generated API client, handling all model conversions and error mapping.\n */\ninternal class SandboxesAdapter(\n    private val provider: HttpClientProvider,\n) : Sandboxes {\n    private val logger = LoggerFactory.getLogger(SandboxesAdapter::class.java)\n\n    private val api = SandboxesApi(provider.config.getBaseUrl(), provider.authenticatedClient)\n\n    override fun createSandbox(\n        spec: SandboxImageSpec,\n        entrypoint: List<String>,\n        env: Map<String, String>,\n        metadata: Map<String, String>,\n        timeout: Duration?,\n        resource: Map<String, String>,\n        networkPolicy: NetworkPolicy?,\n        extensions: Map<String, String>,\n        volumes: List<Volume>?,\n    ): SandboxCreateResponse {\n        logger.info(\"Creating sandbox with image: {}\", spec.image)\n\n        return try {\n            val createRequest =\n                SandboxModelConverter.toApiCreateSandboxRequest(\n                    spec = spec,\n                    entrypoint = entrypoint,\n                    env = env,\n                    metadata = metadata,\n                    timeout = timeout,\n                    resource = resource,\n                    networkPolicy = networkPolicy,\n                    extensions = extensions,\n                    volumes = volumes,\n                )\n            val apiResponse = api.sandboxesPost(createRequest)\n            val response = apiResponse.toSandboxCreateResponse()\n\n            logger.info(\"Successfully created sandbox: {}\", response.id)\n\n            response\n        } catch (e: Exception) {\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun getSandboxInfo(sandboxId: String): SandboxInfo {\n        logger.debug(\"Retrieving sandbox information: {}\", sandboxId)\n\n        return try {\n            api.sandboxesSandboxIdGet(sandboxId).toSandboxInfo()\n        } catch (e: Exception) {\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun listSandboxes(filter: SandboxFilter): PagedSandboxInfos {\n        logger.debug(\"Listing sandboxes with filter: {}\", filter)\n        val metadataQuery: String? =\n            filter.metadata?.entries?.joinToString(\"&\") { \"${it.key}=${it.value}\" }\n        return try {\n            api.sandboxesGet(filter.states, metadataQuery, filter.page, filter.pageSize).toPagedSandboxInfos()\n        } catch (e: Exception) {\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun getSandboxEndpoint(\n        sandboxId: String,\n        port: Int,\n    ): SandboxEndpoint {\n        return getSandboxEndpoint(sandboxId, port, false)\n    }\n\n    override fun getSandboxEndpoint(\n        sandboxId: String,\n        port: Int,\n        useServerProxy: Boolean,\n    ): SandboxEndpoint {\n        logger.debug(\"Retrieving sandbox endpoint: {}, port {}\", sandboxId, port)\n        return try {\n            api.sandboxesSandboxIdEndpointsPortGet(sandboxId, port, useServerProxy).toSandboxEndpoint()\n        } catch (e: Exception) {\n            logger.error(\"Failed to retrieve sandbox endpoint for sandbox {}\", sandboxId, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun pauseSandbox(sandboxId: String) {\n        logger.info(\"Pausing sandbox: {}\", sandboxId)\n\n        try {\n            api.sandboxesSandboxIdPausePost(sandboxId)\n            logger.info(\"Initiated pause for sandbox: {}\", sandboxId)\n        } catch (e: Exception) {\n            logger.error(\"Failed to initiate pause sandbox: {}\", sandboxId, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun resumeSandbox(sandboxId: String) {\n        logger.info(\"Resuming sandbox: {}\", sandboxId)\n\n        try {\n            api.sandboxesSandboxIdResumePost(sandboxId)\n            logger.info(\"Initiated resume for sandbox: {}\", sandboxId)\n        } catch (e: Exception) {\n            logger.error(\"Failed initiate resume sandbox: {}\", sandboxId, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun renewSandboxExpiration(\n        sandboxId: String,\n        newExpirationTime: OffsetDateTime,\n    ): SandboxRenewResponse {\n        logger.info(\"Renew sandbox {} expiration to {}\", sandboxId, newExpirationTime)\n\n        return try {\n            val response =\n                api.sandboxesSandboxIdRenewExpirationPost(\n                    sandboxId,\n                    newExpirationTime.toApiRenewRequest(),\n                ).toSandboxRenewResponse()\n\n            logger.info(\"Successfully renewed sandbox {} expiration\", sandboxId)\n\n            response\n        } catch (e: Exception) {\n            logger.error(\"Failed to renew sandbox {} expiration\", sandboxId, e)\n            throw e.toSandboxException()\n        }\n    }\n\n    override fun killSandbox(sandboxId: String) {\n        logger.info(\"Terminating sandbox: {}\", sandboxId)\n\n        return try {\n            api.sandboxesSandboxIdDelete(sandboxId)\n            logger.info(\"Successfully terminated sandbox: {}\", sandboxId)\n        } catch (e: Exception) {\n            logger.error(\"Failed to terminate sandbox: {}\", sandboxId, e)\n            throw e.toSandboxException()\n        }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.factory\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.services.Commands\nimport com.alibaba.opensandbox.sandbox.domain.services.Egress\nimport com.alibaba.opensandbox.sandbox.domain.services.Filesystem\nimport com.alibaba.opensandbox.sandbox.domain.services.Health\nimport com.alibaba.opensandbox.sandbox.domain.services.Metrics\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.CommandsAdapter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.EgressAdapter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.FilesystemAdapter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.HealthAdapter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.MetricsAdapter\nimport com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.SandboxesAdapter\n\n/**\n * Factory responsible for creating adapter instances.\n *\n * This factory encapsulates the instantiation logic of specific adapters,\n * decoupling the Sandbox domain object from infrastructure implementation details.\n */\ninternal class AdapterFactory(\n    private val httpClientProvider: HttpClientProvider,\n) {\n    fun createSandboxes(): Sandboxes {\n        return SandboxesAdapter(httpClientProvider)\n    }\n\n    fun createFilesystem(endpoint: SandboxEndpoint): Filesystem {\n        return FilesystemAdapter(httpClientProvider, endpoint)\n    }\n\n    fun createCommands(endpoint: SandboxEndpoint): Commands {\n        return CommandsAdapter(httpClientProvider, endpoint)\n    }\n\n    fun createEgress(endpoint: SandboxEndpoint): Egress {\n        return EgressAdapter(httpClientProvider, endpoint)\n    }\n\n    fun createMetrics(endpoint: SandboxEndpoint): Metrics {\n        return MetricsAdapter(httpClientProvider, endpoint)\n    }\n\n    fun createHealth(endpoint: SandboxEndpoint): Health {\n        return HealthAdapter(httpClientProvider, endpoint)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport io.mockk.Runs\nimport io.mockk.every\nimport io.mockk.impl.annotations.MockK\nimport io.mockk.junit5.MockKExtension\nimport io.mockk.just\nimport io.mockk.mockk\nimport io.mockk.verify\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertSame\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.extension.ExtendWith\nimport java.time.Duration\nimport java.time.OffsetDateTime\n\n@ExtendWith(MockKExtension::class)\nclass SandboxManagerTest {\n    @MockK\n    lateinit var sandboxService: Sandboxes\n\n    @MockK\n    lateinit var httpClientProvider: HttpClientProvider\n\n    private lateinit var sandboxManager: SandboxManager\n\n    @BeforeEach\n    fun setUp() {\n        sandboxManager = SandboxManager(sandboxService, httpClientProvider)\n    }\n\n    @Test\n    fun `listSandboxInfos should return sandboxes from service`() {\n        val filter = SandboxFilter.builder().states(\"RUNNING\").build()\n        val pagination =\n            PaginationInfo(\n                page = 1,\n                pageSize = 10,\n                totalItems = 2,\n                totalPages = 1,\n                hasNextPage = false,\n            )\n        val expectedInfos =\n            PagedSandboxInfos(\n                sandboxInfos = listOf(mockk(), mockk()),\n                pagination = pagination,\n            )\n\n        every { sandboxService.listSandboxes(filter) } returns expectedInfos\n\n        val result = sandboxManager.listSandboxInfos(filter)\n\n        assertEquals(expectedInfos, result)\n        verify { sandboxService.listSandboxes(filter) }\n    }\n\n    @Test\n    fun `getSandboxInfo should return info from service`() {\n        val sandboxId = \"sandbox-id\"\n        val status =\n            SandboxStatus(\n                state = SandboxState.RUNNING,\n                reason = null,\n                message = null,\n                lastTransitionAt = OffsetDateTime.now(),\n            )\n        val imageSpec = SandboxImageSpec.builder().image(\"ubuntu\").build()\n        val expectedInfo =\n            SandboxInfo(\n                id = sandboxId,\n                status = status,\n                entrypoint = listOf(\"/bin/bash\"),\n                createdAt = OffsetDateTime.now(),\n                expiresAt = OffsetDateTime.now().plusHours(1),\n                image = imageSpec,\n                metadata = emptyMap(),\n            )\n\n        every { sandboxService.getSandboxInfo(sandboxId) } returns expectedInfo\n\n        val result = sandboxManager.getSandboxInfo(sandboxId)\n\n        assertEquals(expectedInfo, result)\n        verify { sandboxService.getSandboxInfo(sandboxId) }\n    }\n\n    @Test\n    fun `killSandbox should call service`() {\n        val sandboxId = \"sandbox-id\"\n        every { sandboxService.killSandbox(sandboxId) } just Runs\n\n        sandboxManager.killSandbox(sandboxId)\n\n        verify { sandboxService.killSandbox(sandboxId) }\n    }\n\n    @Test\n    fun `renewSandbox should call service`() {\n        val sandboxId = \"sandbox-id\"\n        val timeout = Duration.ofMinutes(30)\n        val expectedRenew = mockk<SandboxRenewResponse>()\n\n        every { sandboxService.renewSandboxExpiration(sandboxId, any()) } returns expectedRenew\n\n        val actualRenew = sandboxManager.renewSandbox(sandboxId, timeout)\n\n        assertSame(expectedRenew, actualRenew)\n    }\n\n    @Test\n    fun `pauseSandbox should call service`() {\n        val sandboxId = \"sandbox-id\"\n        every { sandboxService.pauseSandbox(sandboxId) } just Runs\n\n        sandboxManager.pauseSandbox(sandboxId)\n\n        verify { sandboxService.pauseSandbox(sandboxId) }\n    }\n\n    @Test\n    fun `resumeSandbox should call service`() {\n        val sandboxId = \"sandbox-id\"\n        every { sandboxService.resumeSandbox(sandboxId) } just Runs\n\n        sandboxManager.resumeSandbox(sandboxId)\n\n        verify { sandboxService.resumeSandbox(sandboxId) }\n    }\n\n    @Test\n    fun `close should close httpClientProvider`() {\n        every { httpClientProvider.close() } just Runs\n\n        sandboxManager.close()\n\n        verify { httpClientProvider.close() }\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox\n\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse\nimport com.alibaba.opensandbox.sandbox.domain.services.Commands\nimport com.alibaba.opensandbox.sandbox.domain.services.Egress\nimport com.alibaba.opensandbox.sandbox.domain.services.Filesystem\nimport com.alibaba.opensandbox.sandbox.domain.services.Health\nimport com.alibaba.opensandbox.sandbox.domain.services.Metrics\nimport com.alibaba.opensandbox.sandbox.domain.services.Sandboxes\nimport io.mockk.Runs\nimport io.mockk.every\nimport io.mockk.impl.annotations.MockK\nimport io.mockk.junit5.MockKExtension\nimport io.mockk.just\nimport io.mockk.mockk\nimport io.mockk.verify\nimport org.junit.jupiter.api.Assertions.assertFalse\nimport org.junit.jupiter.api.Assertions.assertNull\nimport org.junit.jupiter.api.Assertions.assertSame\nimport org.junit.jupiter.api.Assertions.assertThrows\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.extension.ExtendWith\nimport java.time.Duration\n\n@ExtendWith(MockKExtension::class)\nclass SandboxTest {\n    @MockK\n    lateinit var sandboxService: Sandboxes\n\n    @MockK\n    lateinit var fileSystemService: Filesystem\n\n    @MockK\n    lateinit var commandService: Commands\n\n    @MockK\n    lateinit var healthService: Health\n\n    @MockK\n    lateinit var metricsService: Metrics\n\n    @MockK\n    lateinit var egressService: Egress\n\n    @MockK\n    lateinit var httpClientProvider: HttpClientProvider\n\n    private lateinit var sandbox: Sandbox\n    private val sandboxId = \"sandbox-id\"\n\n    @BeforeEach\n    fun setUp() {\n        every {\n            httpClientProvider.config\n        } returns\n            ConnectionConfig.builder()\n                .domain(\"localhost:8080\")\n                .useServerProxy(false)\n                .build()\n\n        sandbox =\n            Sandbox(\n                id = sandboxId,\n                sandboxService = sandboxService,\n                fileSystemService = fileSystemService,\n                commandService = commandService,\n                healthService = healthService,\n                metricsService = metricsService,\n                egressService = egressService,\n                customHealthCheck = null,\n                httpClientProvider = httpClientProvider,\n            )\n    }\n\n    @Test\n    fun `files should return filesystem service`() {\n        assertSame(fileSystemService, sandbox.files())\n    }\n\n    @Test\n    fun `commands should return command service`() {\n        assertSame(commandService, sandbox.commands())\n    }\n\n    @Test\n    fun `metrics should return metrics service`() {\n        assertSame(metricsService, sandbox.metrics())\n    }\n\n    @Test\n    fun `httpClientProvider should return http client provider`() {\n        assertSame(httpClientProvider, sandbox.httpClientProvider())\n    }\n\n    @Test\n    fun `getInfo should delegate to sandboxService`() {\n        val expectedInfo = mockk<SandboxInfo>()\n        every { sandboxService.getSandboxInfo(sandboxId) } returns expectedInfo\n\n        val result = sandbox.getInfo()\n\n        assertSame(expectedInfo, result)\n        verify { sandboxService.getSandboxInfo(sandboxId) }\n    }\n\n    @Test\n    fun `getEndpoint should delegate to sandboxService`() {\n        val port = 8080\n        val expectedEndpoint = mockk<SandboxEndpoint>()\n        val connectionConfig = ConnectionConfig.builder().build()\n        every { httpClientProvider.config } returns connectionConfig\n        every { sandboxService.getSandboxEndpoint(sandboxId, port, false) } returns expectedEndpoint\n\n        val result = sandbox.getEndpoint(port)\n\n        assertSame(expectedEndpoint, result)\n        verify { sandboxService.getSandboxEndpoint(sandboxId, port, false) }\n    }\n\n    @Test\n    fun `getMetrics should delegate to metricsService`() {\n        val expectedMetrics = mockk<SandboxMetrics>()\n        every { metricsService.getMetrics(sandboxId) } returns expectedMetrics\n\n        val result = sandbox.getMetrics()\n\n        assertSame(expectedMetrics, result)\n        verify { metricsService.getMetrics(sandboxId) }\n    }\n\n    @Test\n    fun `renew should delegate to sandboxService`() {\n        val timeout = Duration.ofMinutes(10)\n        val expectedRenew = mockk<SandboxRenewResponse>()\n        every { sandboxService.renewSandboxExpiration(sandboxId, any()) } returns expectedRenew\n\n        val actualRenew = sandbox.renew(timeout)\n\n        assertSame(expectedRenew, actualRenew)\n    }\n\n    @Test\n    fun `getEgressPolicy should delegate to egressService`() {\n        val expectedPolicy = mockk<NetworkPolicy>()\n        every { egressService.getPolicy() } returns expectedPolicy\n\n        val result = sandbox.getEgressPolicy()\n\n        assertSame(expectedPolicy, result)\n        verify { egressService.getPolicy() }\n    }\n\n    @Test\n    fun `patchEgressRules should delegate to egressService`() {\n        val rules = listOf(mockk<NetworkRule>())\n        every { egressService.patchRules(rules) } just Runs\n\n        sandbox.patchEgressRules(rules)\n\n        verify { egressService.patchRules(rules) }\n    }\n\n    @Test\n    fun `builder manualCleanup should clear timeout`() {\n        val builder =\n            Sandbox.builder()\n                .image(\"python:3.12\")\n                .timeout(Duration.ofMinutes(5))\n                .manualCleanup()\n\n        val timeoutField = builder.javaClass.getDeclaredField(\"timeout\")\n        timeoutField.isAccessible = true\n\n        assertNull(timeoutField.get(builder))\n    }\n\n    @Test\n    fun `pause should delegate to sandboxService`() {\n        every { sandboxService.pauseSandbox(sandboxId) } just Runs\n\n        sandbox.pause()\n\n        verify { sandboxService.pauseSandbox(sandboxId) }\n    }\n\n    @Test\n    fun `kill should delegate to sandboxService`() {\n        every { sandboxService.killSandbox(sandboxId) } just Runs\n\n        sandbox.kill()\n\n        verify { sandboxService.killSandbox(sandboxId) }\n    }\n\n    @Test\n    fun `close should close httpClientProvider`() {\n        every { httpClientProvider.close() } just Runs\n\n        sandbox.close()\n\n        verify { httpClientProvider.close() }\n    }\n\n    @Test\n    fun `isHealthy should return true when healthService returns true`() {\n        every { healthService.ping(sandboxId) } returns true\n\n        assertTrue(sandbox.isHealthy())\n        verify { healthService.ping(sandboxId) }\n    }\n\n    @Test\n    fun `isHealthy should return false when healthService returns false`() {\n        every { healthService.ping(sandboxId) } returns false\n\n        assertFalse(sandbox.isHealthy())\n        verify { healthService.ping(sandboxId) }\n    }\n\n    @Test\n    fun `checkReady should return when healthy`() {\n        every { healthService.ping(sandboxId) } returns true\n\n        sandbox.checkReady(Duration.ofSeconds(1), Duration.ofMillis(10))\n\n        verify { healthService.ping(sandboxId) }\n    }\n\n    @Test\n    fun `checkReady should throw exception when timeout`() {\n        every { healthService.ping(sandboxId) } returns false\n\n        assertThrows(SandboxReadyTimeoutException::class.java) {\n            sandbox.checkReady(Duration.ofMillis(100), Duration.ofMillis(10))\n        }\n    }\n\n    @Test\n    fun `checkReady timeout should include connection context and bridge hint`() {\n        every { healthService.ping(sandboxId) } throws RuntimeException(\"connect ECONNREFUSED\")\n\n        val ex =\n            assertThrows(SandboxReadyTimeoutException::class.java) {\n                sandbox.checkReady(Duration.ofMillis(100), Duration.ofMillis(10))\n            }\n\n        assertTrue(ex.message!!.contains(\"Connection context: domain=localhost:8080, useServerProxy=false\"))\n        assertTrue(ex.message!!.contains(\"useServerProxy=true\"))\n        assertTrue(ex.message!!.contains(\"[docker].host_ip\"))\n        assertTrue(ex.message!!.contains(\"Last error: connect ECONNREFUSED\"))\n    }\n\n    @Test\n    fun `checkReady timeout should omit host_ip hint when server proxy is enabled`() {\n        val proxyEnabledConfig =\n            ConnectionConfig.builder()\n                .domain(\"localhost:8080\")\n                .useServerProxy(true)\n                .build()\n        every { httpClientProvider.config } returns proxyEnabledConfig\n        every { healthService.ping(sandboxId) } returns false\n\n        val ex =\n            assertThrows(SandboxReadyTimeoutException::class.java) {\n                sandbox.checkReady(Duration.ofMillis(100), Duration.ofMillis(10))\n            }\n\n        assertTrue(ex.message!!.contains(\"useServerProxy=true\"))\n        assertFalse(ex.message!!.contains(\"[docker].host_ip\"))\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxExceptionCompatibilityTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.exceptions\n\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertNull\nimport org.junit.jupiter.api.Test\n\nclass SandboxExceptionCompatibilityTest {\n    @Test\n    fun `base exception should keep legacy constructor signature`() {\n        val ex = SandboxException(\"boom\", null, SandboxError(\"INTERNAL_UNKNOWN_ERROR\"))\n\n        assertEquals(\"boom\", ex.message)\n        assertNull(ex.requestId)\n    }\n\n    @Test\n    fun `api exception should keep legacy constructor signature`() {\n        val ex = SandboxApiException(\"boom\", null, 500, SandboxError(\"UNEXPECTED_RESPONSE\"))\n\n        assertEquals(\"boom\", ex.message)\n        assertEquals(500, ex.statusCode)\n        assertNull(ex.requestId)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/VolumeModelsTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.domain.models\n\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Host\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PVC\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.Volume\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertFalse\nimport org.junit.jupiter.api.Assertions.assertNotNull\nimport org.junit.jupiter.api.Assertions.assertNull\nimport org.junit.jupiter.api.Assertions.assertThrows\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\n\nclass VolumeModelsTest {\n    @Test\n    fun `Host should require absolute path`() {\n        val backend = Host.of(\"/data/shared\")\n        assertEquals(\"/data/shared\", backend.path)\n    }\n\n    @Test\n    fun `Host should reject relative path`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Host.of(\"relative/path\")\n        }\n    }\n\n    @Test\n    fun `PVC should accept valid claim name`() {\n        val backend = PVC.of(\"my-pvc\")\n        assertEquals(\"my-pvc\", backend.claimName)\n    }\n\n    @Test\n    fun `PVC should reject blank claim name`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            PVC.of(\"   \")\n        }\n    }\n\n    @Test\n    fun `Volume with host backend should be created correctly`() {\n        val volume =\n            Volume.builder()\n                .name(\"data\")\n                .host(Host.of(\"/data/shared\"))\n                .mountPath(\"/mnt/data\")\n                .build()\n\n        assertEquals(\"data\", volume.name)\n        assertNotNull(volume.host)\n        assertEquals(\"/data/shared\", volume.host?.path)\n        assertNull(volume.pvc)\n        assertEquals(\"/mnt/data\", volume.mountPath)\n        assertFalse(volume.readOnly) // default is read-write\n        assertNull(volume.subPath)\n    }\n\n    @Test\n    fun `Volume with PVC backend should be created correctly`() {\n        val volume =\n            Volume.builder()\n                .name(\"models\")\n                .pvc(PVC.of(\"shared-models\"))\n                .mountPath(\"/mnt/models\")\n                .readOnly(true)\n                .subPath(\"v1\")\n                .build()\n\n        assertEquals(\"models\", volume.name)\n        assertNull(volume.host)\n        assertNotNull(volume.pvc)\n        assertEquals(\"shared-models\", volume.pvc?.claimName)\n        assertEquals(\"/mnt/models\", volume.mountPath)\n        assertTrue(volume.readOnly)\n        assertEquals(\"v1\", volume.subPath)\n    }\n\n    @Test\n    fun `Volume should reject blank name`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .name(\"   \")\n                .host(Host.of(\"/data\"))\n                .mountPath(\"/mnt\")\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume should require absolute mount path`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .name(\"test\")\n                .host(Host.of(\"/data\"))\n                .mountPath(\"relative/path\")\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume should reject no backend specified`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .name(\"test\")\n                .mountPath(\"/mnt\")\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume should reject multiple backends specified`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .name(\"test\")\n                .host(Host.of(\"/data\"))\n                .pvc(PVC.of(\"my-pvc\"))\n                .mountPath(\"/mnt\")\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume should require name`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .host(Host.of(\"/data\"))\n                .mountPath(\"/mnt\")\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume should require mount path`() {\n        assertThrows(IllegalArgumentException::class.java) {\n            Volume.builder()\n                .name(\"test\")\n                .host(Host.of(\"/data\"))\n                .build()\n        }\n    }\n\n    @Test\n    fun `Volume readOnly defaults to false`() {\n        val volume =\n            Volume.builder()\n                .name(\"test\")\n                .host(Host.of(\"/data\"))\n                .mountPath(\"/mnt\")\n                .build()\n\n        assertFalse(volume.readOnly)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.ExecutionHandlers\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.RunCommandRequest\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.booleanOrNull\nimport kotlinx.serialization.json.intOrNull\nimport kotlinx.serialization.json.jsonObject\nimport kotlinx.serialization.json.jsonPrimitive\nimport okhttp3.mockwebserver.MockResponse\nimport okhttp3.mockwebserver.MockWebServer\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertThrows\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.util.concurrent.CountDownLatch\nimport java.util.concurrent.TimeUnit\n\nclass CommandsAdapterTest {\n    // CommandsAdapter unit tests\n    private lateinit var mockWebServer: MockWebServer\n    private lateinit var commandsAdapter: CommandsAdapter\n    private lateinit var httpClientProvider: HttpClientProvider\n\n    @BeforeEach\n    fun setUp() {\n        mockWebServer = MockWebServer()\n        mockWebServer.start()\n\n        // We need to parse the port from MockWebServer to simulate the Execd endpoint\n        val host = mockWebServer.hostName\n        val port = mockWebServer.port\n        val endpoint = SandboxEndpoint(\"$host:$port\")\n\n        val config =\n            ConnectionConfig.builder()\n                .domain(\"$host:$port\")\n                .protocol(\"http\")\n                .build()\n\n        httpClientProvider = HttpClientProvider(config)\n        commandsAdapter = CommandsAdapter(httpClientProvider, endpoint)\n    }\n\n    @AfterEach\n    fun tearDown() {\n        mockWebServer.shutdown()\n        httpClientProvider.close()\n    }\n\n    @Test\n    fun `run should stream events correctly`() {\n        // SSE format: event nodes are JSON objects separated by newlines\n        val event1 = \"\"\"{\"type\":\"stdout\",\"text\":\"Hello\",\"timestamp\":1672531200000}\"\"\"\n        val event2 = \"\"\"{\"type\":\"execution_complete\",\"execution_time\":100,\"timestamp\":1672531201000}\"\"\"\n\n        val responseBody = \"$event1\\n$event2\\n\"\n\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(200)\n                .setBody(responseBody),\n        )\n\n        val receivedOutput = StringBuilder()\n        val latch = CountDownLatch(1)\n        var executionTime = -1L\n\n        val handlers =\n            ExecutionHandlers.builder()\n                .onStdout { msg -> receivedOutput.append(msg.text) }\n                .onExecutionComplete { complete ->\n                    executionTime = complete.executionTimeInMillis\n                    latch.countDown()\n                }\n                .build()\n\n        val request =\n            RunCommandRequest.builder()\n                .command(\"echo Hello\")\n                .uid(1000)\n                .gid(1000)\n                .env(\"APP_ENV\", \"test\")\n                .env(\"LOG_LEVEL\", \"debug\")\n                .handlers(handlers)\n                .build()\n\n        commandsAdapter.run(request)\n\n        assertTrue(latch.await(2, TimeUnit.SECONDS), \"Timed out waiting for completion event\")\n        assertEquals(\"Hello\", receivedOutput.toString())\n        assertEquals(100L, executionTime)\n\n        val recordedRequest = mockWebServer.takeRequest()\n        assertEquals(\"/command\", recordedRequest.path)\n        assertEquals(\"POST\", recordedRequest.method)\n        val requestBodyJson = Json.parseToJsonElement(recordedRequest.body.readUtf8()).jsonObject\n        assertEquals(\"echo Hello\", requestBodyJson[\"command\"]?.jsonPrimitive?.content)\n        assertEquals(1000, requestBodyJson[\"uid\"]?.jsonPrimitive?.intOrNull)\n        assertEquals(1000, requestBodyJson[\"gid\"]?.jsonPrimitive?.intOrNull)\n        val envs = requestBodyJson[\"envs\"]?.jsonObject\n        assertEquals(\"test\", envs?.get(\"APP_ENV\")?.jsonPrimitive?.content)\n        assertEquals(\"debug\", envs?.get(\"LOG_LEVEL\")?.jsonPrimitive?.content)\n        // Builder defaults background to false; request body always includes it\n        assertEquals(false, requestBodyJson[\"background\"]?.jsonPrimitive?.booleanOrNull)\n    }\n\n    @Test\n    fun `run command builder should require uid when gid is provided`() {\n        assertThrows<IllegalArgumentException> {\n            RunCommandRequest.builder()\n                .command(\"id\")\n                .gid(1000)\n                .build()\n        }\n    }\n\n    @Test\n    fun `run should expose request id on api exception`() {\n        mockWebServer.enqueue(\n            MockResponse()\n                .setResponseCode(500)\n                .addHeader(\"X-Request-ID\", \"req-kotlin-123\")\n                .setBody(\"\"\"{\"code\":\"INTERNAL_ERROR\",\"message\":\"boom\"}\"\"\"),\n        )\n\n        val request = RunCommandRequest.builder().command(\"echo Hello\").build()\n        val ex = assertThrows(SandboxApiException::class.java) { commandsAdapter.run(request) }\n\n        assertEquals(500, ex.statusCode)\n        assertEquals(\"req-kotlin-123\", ex.requestId)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.infrastructure.adapters.service\n\nimport com.alibaba.opensandbox.sandbox.HttpClientProvider\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkPolicy\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.NetworkRule\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxFilter\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.jsonArray\nimport kotlinx.serialization.json.jsonObject\nimport kotlinx.serialization.json.jsonPrimitive\nimport okhttp3.mockwebserver.MockResponse\nimport okhttp3.mockwebserver.MockWebServer\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertNotNull\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport java.time.Duration\n\nclass SandboxesAdapterTest {\n    private lateinit var mockWebServer: MockWebServer\n    private lateinit var sandboxesAdapter: SandboxesAdapter\n    private lateinit var httpClientProvider: HttpClientProvider\n\n    @BeforeEach\n    fun setUp() {\n        mockWebServer = MockWebServer()\n        mockWebServer.start()\n\n        val host = mockWebServer.hostName\n        val port = mockWebServer.port\n        val config =\n            ConnectionConfig.builder()\n                .domain(\"$host:$port\")\n                .protocol(\"http\")\n                .build()\n\n        httpClientProvider = HttpClientProvider(config)\n        sandboxesAdapter = SandboxesAdapter(httpClientProvider)\n    }\n\n    @AfterEach\n    fun tearDown() {\n        mockWebServer.shutdown()\n        httpClientProvider.close()\n    }\n\n    @Test\n    fun `createSandbox should send correct request and parse response`() {\n        // Mock response\n        val responseBody =\n            \"\"\"\n            {\n                \"id\": \"550e8400-e29b-41d4-a716-446655440000\",\n                \"status\": { \"state\": \"Running\" },\n                \"expiresAt\": \"2023-01-01T11:00:00Z\",\n                \"createdAt\": \"2023-01-01T10:00:00Z\",\n                \"entrypoint\": [\"bash\"]\n            }\n            \"\"\".trimIndent()\n        mockWebServer.enqueue(MockResponse().setBody(responseBody).setResponseCode(201))\n\n        // Execute\n        val spec = SandboxImageSpec.builder().image(\"ubuntu:latest\").build()\n        val extensions = mapOf(\"storage.id\" to \"abc123\", \"debug\" to \"true\")\n        val networkPolicy =\n            NetworkPolicy.builder()\n                .defaultAction(NetworkPolicy.DefaultAction.DENY)\n                .addEgress(\n                    NetworkRule.builder()\n                        .action(NetworkRule.Action.ALLOW)\n                        .target(\"pypi.org\")\n                        .build(),\n                )\n                .build()\n        val result =\n            sandboxesAdapter.createSandbox(\n                spec = spec,\n                entrypoint = listOf(\"bash\"),\n                env = mapOf(\"KEY\" to \"VALUE\"),\n                metadata = mapOf(\"meta\" to \"data\"),\n                timeout = Duration.ofSeconds(600),\n                resource = mapOf(\"cpu\" to \"1\"),\n                networkPolicy = networkPolicy,\n                extensions = extensions,\n                volumes = null,\n            )\n\n        // Verify request\n        val request = mockWebServer.takeRequest()\n        assertEquals(\"POST\", request.method)\n        assertEquals(\"/v1/sandboxes\", request.path)\n        val requestBody = request.body.readUtf8()\n        assertTrue(requestBody.isNotBlank(), \"request body should not be blank\")\n\n        val payload = Json.parseToJsonElement(requestBody).jsonObject\n        val gotExtensions = payload[\"extensions\"]?.jsonObject\n        assertNotNull(gotExtensions, \"extensions should be present in createSandbox request\")\n        assertEquals(\"abc123\", gotExtensions!![\"storage.id\"]!!.jsonPrimitive.content)\n        assertEquals(\"true\", gotExtensions[\"debug\"]!!.jsonPrimitive.content)\n        val gotNetworkPolicy = payload[\"networkPolicy\"]?.jsonObject\n        assertNotNull(gotNetworkPolicy, \"networkPolicy should be present in createSandbox request\")\n        val gotDefaultAction = gotNetworkPolicy!![\"defaultAction\"]\n        assertNotNull(gotDefaultAction, \"defaultAction should be present in networkPolicy\")\n        assertEquals(\"deny\", gotDefaultAction!!.jsonPrimitive.content)\n        val egressArray = gotNetworkPolicy[\"egress\"]!!.jsonArray\n        assertEquals(1, egressArray.size)\n        val rule = egressArray[0].jsonObject\n        assertEquals(\"allow\", rule[\"action\"]!!.jsonPrimitive.content)\n        assertEquals(\"pypi.org\", rule[\"target\"]!!.jsonPrimitive.content)\n\n        // Verify response\n        assertEquals(\"550e8400-e29b-41d4-a716-446655440000\", result.id)\n    }\n\n    @Test\n    fun `createSandbox should accept null expiresAt for manual cleanup response`() {\n        val responseBody =\n            \"\"\"\n            {\n                \"id\": \"manual-sbx\",\n                \"status\": { \"state\": \"Running\" },\n                \"expiresAt\": null,\n                \"createdAt\": \"2023-01-01T10:00:00Z\",\n                \"entrypoint\": [\"bash\"]\n            }\n            \"\"\".trimIndent()\n        mockWebServer.enqueue(MockResponse().setBody(responseBody).setResponseCode(201))\n\n        val spec = SandboxImageSpec.builder().image(\"ubuntu:latest\").build()\n        val result =\n            sandboxesAdapter.createSandbox(\n                spec = spec,\n                entrypoint = listOf(\"bash\"),\n                env = emptyMap(),\n                metadata = emptyMap(),\n                timeout = null,\n                resource = mapOf(\"cpu\" to \"1\"),\n                networkPolicy = null,\n                extensions = emptyMap(),\n                volumes = null,\n            )\n\n        assertEquals(\"manual-sbx\", result.id)\n    }\n\n    @Test\n    fun `getSandboxInfo should parse response correctly`() {\n        val sandboxId = \"sandbox-id\"\n        val responseBody =\n            \"\"\"\n            {\n                \"id\": \"$sandboxId\",\n                \"status\": {\n                    \"state\": \"Running\",\n                    \"reason\": null,\n                    \"message\": null,\n                    \"lastTransitionAt\": \"2023-01-01T10:00:00Z\"\n                },\n                \"entrypoint\": [\"/bin/bash\"],\n                \"expiresAt\": \"2023-01-01T11:00:00Z\",\n                \"createdAt\": \"2023-01-01T10:00:00Z\",\n                \"image\": {\n                    \"uri\": \"ubuntu:latest\"\n                },\n                \"metadata\": {}\n            }\n            \"\"\".trimIndent()\n\n        mockWebServer.enqueue(MockResponse().setBody(responseBody))\n\n        val result = sandboxesAdapter.getSandboxInfo(sandboxId)\n\n        assertEquals(sandboxId, result.id)\n        assertEquals(SandboxState.RUNNING, result.status.state)\n        assertEquals(\"ubuntu:latest\", result.image.image)\n\n        val request = mockWebServer.takeRequest()\n        assertEquals(\"/v1/sandboxes/$sandboxId\", request.path)\n    }\n\n    @Test\n    fun `getSandboxInfo should parse null expiresAt for manual cleanup`() {\n        val sandboxId = \"manual-sandbox\"\n        val responseBody =\n            \"\"\"\n            {\n                \"id\": \"$sandboxId\",\n                \"status\": {\n                    \"state\": \"Running\",\n                    \"reason\": null,\n                    \"message\": null,\n                    \"lastTransitionAt\": \"2023-01-01T10:00:00Z\"\n                },\n                \"entrypoint\": [\"/bin/bash\"],\n                \"expiresAt\": null,\n                \"createdAt\": \"2023-01-01T10:00:00Z\",\n                \"image\": {\n                    \"uri\": \"ubuntu:latest\"\n                },\n                \"metadata\": {}\n            }\n            \"\"\".trimIndent()\n\n        mockWebServer.enqueue(MockResponse().setBody(responseBody))\n\n        val result = sandboxesAdapter.getSandboxInfo(sandboxId)\n\n        assertEquals(sandboxId, result.id)\n        assertEquals(null, result.expiresAt)\n    }\n\n    @Test\n    fun `listSandboxes should construct query params correctly`() {\n        val responseBody =\n            \"\"\"\n            {\n                \"items\": [],\n                \"pagination\": {\n                    \"page\": 0,\n                    \"pageSize\": 10,\n                    \"totalItems\": 0,\n                    \"totalPages\": 0,\n                    \"hasNextPage\": false\n                }\n            }\n            \"\"\".trimIndent()\n\n        mockWebServer.enqueue(MockResponse().setBody(responseBody))\n\n        val filter =\n            SandboxFilter.builder()\n                .states(\"RUNNING\", \"PENDING\")\n                .metadata(mapOf(\"key\" to \"value\"))\n                .page(1)\n                .pageSize(20)\n                .build()\n\n        sandboxesAdapter.listSandboxes(filter)\n\n        val request = mockWebServer.takeRequest()\n        val url = request.requestUrl\n        assertNotNull(url)\n        assertEquals(\"RUNNING\", url!!.queryParameter(\"state\"))\n        assertEquals(\"PENDING\", url.queryParameterValues(\"state\")[1])\n        assertEquals(\"key=value\", url.queryParameter(\"metadata\"))\n        assertEquals(\"1\", url.queryParameter(\"page\"))\n        assertEquals(\"20\", url.queryParameter(\"pageSize\"))\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox-api/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport org.openapitools.generator.gradle.plugin.tasks.GenerateTask\n\nplugins {\n    alias(libs.plugins.openapi.generator)\n}\n\nrepositories {\n    mavenCentral()\n}\n\ndependencies {\n    implementation(libs.okhttp)\n    implementation(libs.bundles.serialization)\n}\n\nfun GenerateTask.configureCommonOptions() {\n    generatorName.set(\"kotlin\")\n    library.set(\"jvm-okhttp4\")\n\n    typeMappings.set(\n        mapOf(\n            \"object\" to \"kotlinx.serialization.json.JsonElement\",\n            \"Object\" to \"kotlinx.serialization.json.JsonElement\",\n            \"java.lang.Object\" to \"kotlinx.serialization.json.JsonElement\",\n            \"Any\" to \"kotlinx.serialization.json.JsonElement\",\n            \"kotlin.Any\" to \"kotlinx.serialization.json.JsonElement\",\n            \"binary\" to \"java.io.InputStream\",\n            \"file\" to \"java.io.InputStream\",\n        ),\n    )\n\n    importMappings.set(\n        mapOf(\n            \"JsonElement\" to \"kotlinx.serialization.json.JsonElement\",\n        ),\n    )\n\n    configOptions.set(\n        mapOf(\n            \"jvm8\" to \"true\",\n            \"coroutine\" to \"false\",\n            \"dateLibrary\" to \"java8\",\n            \"serializationLibrary\" to \"kotlinx_serialization\",\n            \"documentationProvider\" to \"kdoc\",\n            \"useKtor\" to \"false\",\n            \"omitGradleWrapper\" to \"true\",\n        ),\n    )\n\n    globalProperties.set(\n        mapOf(\n            \"apiTests\" to \"false\",\n            \"modelTests\" to \"false\",\n        ),\n    )\n}\n\nval generateSandboxLifecycleApi =\n    tasks.register<GenerateTask>(\"generateSandboxLifecycleApi\") {\n        configureCommonOptions()\n\n        inputSpec.set(\n            rootProject.projectDir.parentFile.parentFile.parentFile\n                .resolve(\"specs/sandbox-lifecycle.yml\").absolutePath,\n        )\n        outputDir.set(layout.buildDirectory.dir(\"generated/api/lifecycle\").get().asFile.absolutePath)\n        packageName.set(\"com.alibaba.opensandbox.sandbox.api\")\n        apiPackage.set(\"com.alibaba.opensandbox.sandbox.api\")\n        modelPackage.set(\"com.alibaba.opensandbox.sandbox.api.models\")\n    }\n\nval generateExecdApi =\n    tasks.register<GenerateTask>(\"generateExecdApi\") {\n        configureCommonOptions()\n\n        inputSpec.set(rootProject.projectDir.parentFile.parentFile.parentFile.resolve(\"specs/execd-api.yaml\").absolutePath)\n        outputDir.set(layout.buildDirectory.dir(\"generated/api/execd\").get().asFile.absolutePath)\n        packageName.set(\"com.alibaba.opensandbox.sandbox.api.execd\")\n        apiPackage.set(\"com.alibaba.opensandbox.sandbox.api.execd\")\n        modelPackage.set(\"com.alibaba.opensandbox.sandbox.api.models.execd\")\n    }\n\nval generateEgressApi =\n    tasks.register<GenerateTask>(\"generateEgressApi\") {\n        configureCommonOptions()\n\n        inputSpec.set(rootProject.projectDir.parentFile.parentFile.parentFile.resolve(\"specs/egress-api.yaml\").absolutePath)\n        outputDir.set(layout.buildDirectory.dir(\"generated/api/egress\").get().asFile.absolutePath)\n        packageName.set(\"com.alibaba.opensandbox.sandbox.api.egress\")\n        apiPackage.set(\"com.alibaba.opensandbox.sandbox.api.egress\")\n        modelPackage.set(\"com.alibaba.opensandbox.sandbox.api.models.egress\")\n    }\n\nval lifecycleSrc = generateSandboxLifecycleApi.map { file(it.outputDir).resolve(\"src/main/kotlin\") }\nval execdSrc = generateExecdApi.map { file(it.outputDir).resolve(\"src/main/kotlin\") }\nval egressSrc = generateEgressApi.map { file(it.outputDir).resolve(\"src/main/kotlin\") }\nsourceSets {\n    main {\n        java.srcDirs(lifecycleSrc, execdSrc, egressSrc)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox-api/src/main/kotlin/com/alibaba/opensandbox/sandbox/api/models/execd/ExecutionModels.kt",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.sandbox.api.models.execd\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.JsonObject\nimport kotlinx.serialization.json.contentOrNull\nimport kotlinx.serialization.json.jsonPrimitive\n\n@Serializable\ndata class EventNode(\n    val type: String,\n    val timestamp: Long,\n    val text: String? = null,\n    val results: ResultData? = null,\n    @SerialName(\"execution_time\")\n    val executionTimeInMillis: Long? = null,\n    @SerialName(\"execution_count\")\n    val executionCount: Long? = null,\n    val error: ErrorData? = null,\n)\n\n@Serializable\n@JvmInline\nvalue class ResultData(val raw: JsonObject) {\n    fun getText(): String? {\n        return raw[\"text\"]?.jsonPrimitive?.contentOrNull\n    }\n\n    fun getStringResult(key: String): String? = raw[key]?.jsonPrimitive?.contentOrNull\n}\n\n@Serializable\ndata class ErrorData(\n    @SerialName(\"ename\")\n    val name: String? = null,\n    @SerialName(\"evalue\")\n    val value: String? = null,\n    @SerialName(\"traceback\")\n    val traceback: List<String> = emptyList(),\n)\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox-api/src/main/kotlin/com/alibaba/opensandbox/sandbox/api/openapitools.json",
    "content": "{\n  \"$schema\": \"./node_modules/@openapitools/openapi-generator-cli/config.schema.json\",\n  \"spaces\": 2,\n  \"generator-cli\": {\n    \"version\": \"7.17.0\"\n  }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/sandbox-bom/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    `java-platform`\n}\n\ndependencies {\n    constraints {\n        api(project(\":sandbox\"))\n        api(project(\":sandbox-api\"))\n\n        api(libs.kotlin.stdlib)\n        api(libs.okhttp)\n        api(libs.okhttp.logging)\n        api(libs.kotlinx.serialization.json)\n        api(libs.slf4j.api)\n    }\n}\n"
  },
  {
    "path": "sdks/sandbox/kotlin/settings.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nrootProject.name = \"sandbox-parent\"\n\nplugins {\n    id(\"org.gradle.toolchains.foojay-resolver-convention\") version(\"1.0.0\")\n}\n\ninclude(\":sandbox\")\ninclude(\":sandbox-api\")\ninclude(\":sandbox-bom\")\n"
  },
  {
    "path": "sdks/sandbox/python/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "sdks/sandbox/python/Makefile",
    "content": ".PHONY: help install dev-install format lint type-check test test-cov clean docs build publish\n\n# Default target\nhelp: ## Show this help message\n\t@echo \"Available commands:\"\n\t@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = \":.*?## \"}; {printf \"\\033[36m%-15s\\033[0m %s\\n\", $$1, $$2}'\n\ninstall: ## Install package dependencies\n\tuv sync\n\ndev-install: generate-api ## Install package with development dependencies\n\tuv sync --all-extras\n\nformat: ## Format code with black and isort\n\tuv run black .\n\tuv run isort .\n\nlint: ## Run linting with ruff\n\tuv run ruff check .\n\ntype-check: ## Run type checking with pyright\n\tuv run pyright\n\ntest: ## Run tests\n\tuv run pytest\n\ntest-cov: ## Run tests with coverage\n\tuv run pytest --cov=src/opensandbox --cov-report=html --cov-report=term\n\nclean: ## Clean build artifacts\n\trm -rf build/\n\trm -rf dist/\n\trm -rf *.egg-info/\n\trm -rf .pytest_cache/\n\trm -rf .coverage\n\trm -rf htmlcov/\n\tfind . -type d -name __pycache__ -exec rm -rf {} +\n\tfind . -name \"*.pyc\" -delete\n\ndocs: ## Generate documentation\n\tcd docs && uv run sphinx-build -b html . _build/html\n\nbuild: generate-api ## Build package with API generation\n\tuv build\n\npublish: ## Publish to PyPI (requires authentication)\n\tuv publish\n\n# Development workflow targets\ncheck: format lint type-check ## Run all code quality checks\n\nci: generate-api dev-install check test ## Run CI pipeline locally\n\ngenerate-api: ## Generate API clients from OpenAPI specs (using openapi-python-client)\n\tuv run python scripts/generate_api.py\n\nclean-api: ## Clean generated API client code\n\trm -rf src/opensandbox/api/execd/\n\trm -rf src/opensandbox/api/egress/\n\trm -rf src/opensandbox/api/lifecycle/\n\n# Docker targets\ndocker-build: ## Build Docker image for development\n\tdocker build -t opensandbox-python-dev .\n\ndocker-test: ## Run tests in Docker container\n\tdocker run --rm -v $(PWD):/app opensandbox-python-dev make test\n"
  },
  {
    "path": "sdks/sandbox/python/README.md",
    "content": "# OpenSandbox SDK for Python\n\nEnglish | [中文](README_zh.md)\n\nA Python SDK for low-level interaction with OpenSandbox. It provides capabilities to create, manage, and interact with secure sandbox environments, including executing shell commands, managing files, and monitoring resources.\n\n## Installation\n\n### pip\n\n```bash\npip install opensandbox\n```\n\n### uv\n\n```bash\nuv add opensandbox\n```\n\n## Quick Start\n\nThe following example shows how to create a sandbox and execute a shell command.\n\n> **Note**: Before running this example, ensure the OpenSandbox service is running. See the root [README.md](../../../README.md) for startup instructions.\n\n```python\nimport asyncio\nfrom opensandbox.sandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import SandboxException\n\nasync def main():\n    # 1. Configure connection\n    config = ConnectionConfig(\n        domain=\"api.opensandbox.io\",\n        api_key=\"your-api-key\"\n    )\n\n    # 2. Create a Sandbox\n    try:\n        sandbox = await Sandbox.create(\n            \"ubuntu\",\n            connection_config=config\n        )\n        async with sandbox:\n\n            # 3. Execute a shell command\n            execution = await sandbox.commands.run(\"echo 'Hello Sandbox!'\")\n\n            # 4. Print output\n            print(execution.logs.stdout[0].text)\n\n            # 5. Cleanup (sandbox.close() called automatically)\n            # Note: kill() must be called explicitly if you want to terminate the remote sandbox instance immediately\n            await sandbox.kill()\n\n    except SandboxException as e:\n        # Handle Sandbox specific exceptions\n        print(f\"Sandbox Error: [{e.error.code}] {e.error.message}\")\n        # Server logs can be correlated by this request id (if available)\n        print(f\"Request ID: {e.request_id}\")\n    except Exception as e:\n        print(f\"Error: {e}\")\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### Synchronous Quick Start\n\nIf you prefer a synchronous API, use `SandboxSync` / `SandboxManagerSync` and `ConnectionConfigSync`:\n\n```python\nfrom datetime import timedelta\n\nimport httpx\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\n\nconfig = ConnectionConfigSync(\n    domain=\"api.opensandbox.io\",\n    api_key=\"your-api-key\",\n    request_timeout=timedelta(seconds=30),\n    transport=httpx.HTTPTransport(limits=httpx.Limits(max_connections=20)),\n)\n\nsandbox = SandboxSync.create(\"ubuntu\", connection_config=config)\nwith sandbox:\n    execution = sandbox.commands.run(\"echo 'Hello Sandbox!'\")\n    print(execution.logs.stdout[0].text)\n    sandbox.kill()\n```\n\n## Usage Examples\n\n### 1. Lifecycle Management\n\nManage the sandbox lifecycle, including renewal, pausing, and resuming.\n\n```python\nfrom datetime import timedelta\n\n# Renew the sandbox\n# This resets the expiration time to (current time + duration)\nawait sandbox.renew(timedelta(minutes=30))\n\n# Pause execution (suspends all processes)\nawait sandbox.pause()\n\n# Resume execution\nsandbox = await Sandbox.resume(\n    sandbox_id=sandbox.id,\n    connection_config=config,\n)\n\n# Get current status\ninfo = await sandbox.get_info()\nprint(f\"State: {info.status.state}\")\nprint(f\"Expires: {info.expires_at}\")  # None when manual cleanup mode is used\n```\n\nCreate a non-expiring sandbox by passing `timeout=None`:\n\n```python\nmanual = await Sandbox.create(\n    \"ubuntu\",\n    connection_config=config,\n    timeout=None,\n)\n```\n\n### 2. Custom Health Check\n\nDefine custom logic to determine if the sandbox is healthy. This overrides the default ping check.\n\n```python\nasync def custom_health_check(sbx: Sandbox) -> bool:\n    try:\n        # 1. Get the external mapped address for port 80\n        endpoint = await sbx.get_endpoint(80)\n\n        # 2. Perform your connection check (e.g. HTTP request, Socket connect)\n        # return await check_connection(endpoint.endpoint)\n        return True\n    except Exception:\n        return False\n\nsandbox = await Sandbox.create(\n    \"nginx:latest\",\n    connection_config=config,\n    health_check=custom_health_check  # Custom check: Wait for port 80 to be accessible\n)\n```\n\n### 3. Command Execution & Streaming\n\nExecute commands and handle output streams in real-time.\n\n```python\nfrom opensandbox.models.execd import ExecutionHandlers, RunCommandOpts\n\n# Define async handlers for streaming output\nasync def handle_stdout(msg):\n    print(f\"STDOUT: {msg.text}\")\n\nasync def handle_stderr(msg):\n    print(f\"STDERR: {msg.text}\")\n\nasync def handle_complete(complete):\n    print(f\"Command finished in {complete.execution_time_in_millis}ms\")\n\n# Create handlers (all handlers must be async)\nhandlers = ExecutionHandlers(\n    on_stdout=handle_stdout,\n    on_stderr=handle_stderr,\n    on_execution_complete=handle_complete\n)\n\n# Execute command with handlers\nresult = await sandbox.commands.run(\n    \"for i in {1..5}; do echo \\\"Count $i\\\"; sleep 0.5; done\",\n    handlers=handlers\n)\n```\n\n### 4. Comprehensive File Operations\n\nManage files and directories, including read, write, list, delete, and search.\n\n```python\nfrom opensandbox.models.filesystem import WriteEntry, SearchEntry\n\n# 1. Write file\nawait sandbox.files.write_files([\n    WriteEntry(\n        path=\"/tmp/hello.txt\",\n        data=\"Hello World\",\n        mode=644\n    )\n])\n\n# 2. Read file\ncontent = await sandbox.files.read_file(\"/tmp/hello.txt\")\nprint(f\"Content: {content}\")\n\n# 3. List/Search files\nfiles = await sandbox.files.search(\n    SearchEntry(\n        path=\"/tmp\",\n        pattern=\"*.txt\"\n    )\n)\nfor f in files:\n    print(f\"Found: {f.path}\")\n\n# 4. Delete file\nawait sandbox.files.delete_files([\"/tmp/hello.txt\"])\n```\n\n### 5. Sandbox Management (Admin)\n\nUse `SandboxManager` for administrative tasks and finding existing sandboxes.\n\n```python\nfrom opensandbox.manager import SandboxManager\nfrom opensandbox.models.sandboxes import SandboxFilter\n\n# Create manager using async context manager\nasync with await SandboxManager.create(connection_config=config) as manager:\n\n    # List running sandboxes\n    sandboxes = await manager.list_sandbox_infos(\n        SandboxFilter(\n            states=[\"RUNNING\"],\n            page_size=10\n        )\n    )\n\n    for info in sandboxes.sandbox_infos:\n        print(f\"Found sandbox: {info.id}\")\n        # Perform admin actions\n        await manager.kill_sandbox(info.id)\n```\n\n## Configuration\n\n### 1. Connection Configuration\n\nThe `ConnectionConfig` class manages API server connection settings.\n\n| Parameter         | Description                                | Default                      | Environment Variable   |\n| ----------------- | ------------------------------------------ | ---------------------------- | ---------------------- |\n| `api_key`         | API Key for authentication                 | Required                     | `OPEN_SANDBOX_API_KEY` |\n| `domain`          | The endpoint domain of the sandbox service | Required (or localhost:8080) | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`        | HTTP protocol (http/https)                 | `http`                       | -                      |\n| `request_timeout` | Timeout for API requests                   | 30 seconds                   | -                      |\n| `debug`           | Enable debug logging for HTTP requests     | `False`                      | -                      |\n| `headers`         | Custom HTTP headers                        | Empty                        | -                      |\n| `transport`       | Shared httpx transport (pool/proxy/retry)  | SDK-created per instance     | -                      |\n| `use_server_proxy` | Use sandbox server as proxy for execd/endpoint requests (e.g. when client cannot reach the sandbox directly) | `False` | -                      |\n\n```python\nfrom datetime import timedelta\n\n# 1. Basic configuration\nconfig = ConnectionConfig(\n    api_key=\"your-key\",\n    domain=\"api.opensandbox.io\",\n    request_timeout=timedelta(seconds=60)\n)\n\n# 2. Advanced: Custom headers and custom transport\n# If you create many Sandbox instances, configuring a shared transport is recommended to optimize resource usage.\n# SDK default keep-alive is 30 seconds for its own transports.\nimport httpx\n\nconfig = ConnectionConfig(\n    api_key=\"your-key\",\n    domain=\"api.opensandbox.io\",\n    headers={\n        \"X-Custom-Header\": \"value\",\n        \"X-Request-ID\": \"trace-123\",\n    },\n    transport=httpx.AsyncHTTPTransport(\n        limits=httpx.Limits(\n            max_connections=100,\n            max_keepalive_connections=50,\n        keepalive_expiry=30.0,\n        )\n    ),\n)\n\n# If you provide a custom transport, you are responsible for closing it:\n# await config.transport.aclose()\n```\n\n### 2. Sandbox Creation Configuration\n\nThe `Sandbox.create()` allows configuring the sandbox environment.\n\n| Parameter       | Description                              | Default                         |\n| --------------- | ---------------------------------------- | ------------------------------- |\n| `image`    | Docker image specification               | Required                        |\n| `timeout`       | Automatic termination timeout            | 10 minutes                      |\n| `entrypoint`    | Container entrypoint command             | `[\"tail\", \"-f\", \"/dev/null\"]`   |\n| `resource`      | CPU and memory limits                    | `{\"cpu\": \"1\", \"memory\": \"2Gi\"}` |\n| `env`           | Environment variables                    | Empty                           |\n| `metadata`      | Custom metadata tags                     | Empty                           |\n| `network_policy` | Optional outbound network policy (egress) | -                             |\n| `ready_timeout` | Max time to wait for sandbox to be ready | 30 seconds                      |\n\nNote: metadata keys under `opensandbox.io/` are reserved for system-managed\nlabels and will be rejected by the server.\n\n```python\nfrom datetime import timedelta\n\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\n\nsandbox = await Sandbox.create(\n    \"python:3.11\",\n    connection_config=config,\n    timeout=timedelta(minutes=30),\n    resource={\"cpu\": \"2\", \"memory\": \"4Gi\"},\n    env={\"PYTHONPATH\": \"/app\"},\n    metadata={\"project\": \"demo\"},\n    network_policy=NetworkPolicy(\n        defaultAction=\"deny\",\n        egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n    ),\n)\n```\n\n### 3. Runtime Egress Policy Updates\n\nRuntime egress policy reads and patches are sent directly to the sandbox egress sidecar.\nThe SDK first resolves the sandbox endpoint on port `18080`, then calls the sidecar `/policy` API.\n\nPatch uses merge semantics:\n- Incoming rules take priority over existing rules with the same `target`.\n- Existing rules for other targets remain unchanged.\n- Within a single patch payload, the first rule for a `target` wins.\n- The current `defaultAction` is preserved.\n\n```python\npolicy = await sandbox.get_egress_policy()\n\nawait sandbox.patch_egress_rules(\n    [\n        NetworkRule(action=\"allow\", target=\"www.github.com\"),\n        NetworkRule(action=\"deny\", target=\"pypi.org\"),\n    ]\n)\n```\n"
  },
  {
    "path": "sdks/sandbox/python/README_zh.md",
    "content": "# OpenSandbox SDK for Python\n\n中文 | [English](README.md)\n\n用于与 OpenSandbox 进行底层交互的 Python SDK。它提供了创建、管理和与安全沙箱环境交互的能力，包括执行 Shell 命令、管理文件和监控资源。\n\n## 安装指南\n\n### pip\n\n```bash\npip install opensandbox\n```\n\n### uv\n\n```bash\nuv add opensandbox\n```\n\n## 快速开始\n\n以下示例展示了如何创建一个沙箱并执行 Shell 命令。\n\n> **注意**: 在运行此示例之前，请确保 OpenSandbox 服务已启动。服务启动请参考根目录的 [README_zh.md](../../../docs/README_zh.md)。\n\n```python\nimport asyncio\nfrom opensandbox.sandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import SandboxException\n\nasync def main():\n    # 1. 配置连接信息\n    config = ConnectionConfig(\n        domain=\"api.opensandbox.io\",\n        api_key=\"your-api-key\"\n    )\n\n    # 2. 创建 Sandbox\n    try:\n        sandbox = await Sandbox.create(\n            \"ubuntu\",\n            connection_config=config\n        )\n        async with sandbox:\n\n            # 3. 执行 Shell 命令\n            execution = await sandbox.commands.run(\"echo 'Hello Sandbox!'\")\n\n            # 4. 打印输出\n            print(execution.logs.stdout[0].text)\n\n            # 5. 清理资源 (自动调用 sandbox.close())\n            # 注意: 如果希望立即终止远程沙箱实例，仍需显式调用 kill()\n            await sandbox.kill()\n\n    except SandboxException as e:\n        # 处理 Sandbox 特定异常\n        print(f\"沙箱错误: [{e.error.code}] {e.error.message}\")\n    except Exception as e:\n        print(f\"错误: {e}\")\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n### 同步版本快速开始\n\n如果你更偏好同步 API，可以使用 `SandboxSync` / `SandboxManagerSync` 与 `ConnectionConfigSync`：\n\n```python\nfrom datetime import timedelta\n\nimport httpx\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\n\nconfig = ConnectionConfigSync(\n    domain=\"api.opensandbox.io\",\n    api_key=\"your-api-key\",\n    request_timeout=timedelta(seconds=30),\n    transport=httpx.HTTPTransport(limits=httpx.Limits(max_connections=20)),\n)\n\nsandbox = SandboxSync.create(\"ubuntu\", connection_config=config)\nwith sandbox:\n    execution = sandbox.commands.run(\"echo 'Hello Sandbox!'\")\n    print(execution.logs.stdout[0].text)\n    sandbox.kill()\n```\n\n## 核心功能示例\n\n### 1. 生命周期管理\n\n管理沙箱的生命周期，包括续期、暂停、恢复和状态查询。\n\n```python\nfrom datetime import timedelta\n\n# 续期沙箱\n# 此操作将沙箱的过期时间重置为 (当前时间 + duration)\nawait sandbox.renew(timedelta(minutes=30))\n\n# 暂停执行 (挂起所有进程)\nawait sandbox.pause()\n\n# 恢复执行\nsandbox = await Sandbox.resume(\n    sandbox_id=sandbox.id,\n    connection_config=config,\n)\n\n# 获取当前状态\ninfo = await sandbox.get_info()\nprint(f\"当前状态: {info.status.state}\")\nprint(f\"过期时间: {info.expires_at}\")  # 使用手动清理模式时为 None\n```\n\n通过传入 `timeout=None` 创建一个不会自动过期的沙箱：\n\n```python\nmanual = await Sandbox.create(\n    \"ubuntu\",\n    connection_config=config,\n    timeout=None,\n)\n```\n\n### 2. 自定义健康检查\n\n定义自定义逻辑来判断沙箱是否健康。这可以覆盖默认的 Ping 检查。\n\n```python\nasync def custom_health_check(sbx: Sandbox) -> bool:\n    try:\n        # 1. 获取沙箱 80 端口映射的外部访问地址\n        endpoint = await sbx.get_endpoint(80)\n\n        # 2. 执行你的连接检查逻辑 (例如 HTTP 请求、Socket 连接等)\n        # return await check_connection(endpoint.endpoint)\n        return True\n    except Exception:\n        return False\n\nsandbox = await Sandbox.create(\n    \"nginx:latest\",\n    connection_config=config,\n    health_check=custom_health_check  # 自定义检查：等待 80 端口可访问\n)\n```\n\n### 3. 命令执行与流式响应\n\n执行命令并实时处理输出流。\n\n```python\nfrom opensandbox.models.execd import ExecutionHandlers, RunCommandOpts\n\n# 定义异步处理器用于流式输出\nasync def handle_stdout(msg):\n    print(f\"STDOUT: {msg.text}\")\n\nasync def handle_stderr(msg):\n    print(f\"STDERR: {msg.text}\")\n\nasync def handle_complete(complete):\n    print(f\"命令执行耗时: {complete.execution_time_in_millis}ms\")\n\n# 创建流式输出处理器 (所有处理器必须是异步函数)\nhandlers = ExecutionHandlers(\n    on_stdout=handle_stdout,\n    on_stderr=handle_stderr,\n    on_execution_complete=handle_complete\n)\n\n# 带处理器的命令执行\nresult = await sandbox.commands.run(\n    \"for i in {1..5}; do echo \\\"Count $i\\\"; sleep 0.5; done\"\n    handlers=handlers,\n)\n```\n\n### 4. 全面的文件操作\n\n管理文件和目录，包括读写、列表、删除和搜索。\n\n```python\nfrom opensandbox.models.filesystem import WriteEntry, SearchEntry\n\n# 1. 写入文件\nawait sandbox.files.write_files([\n    WriteEntry(\n        path=\"/tmp/hello.txt\",\n        data=\"Hello World\",\n        mode=644\n    )\n])\n\n# 2. 读取文件\ncontent = await sandbox.files.read_file(\"/tmp/hello.txt\")\nprint(f\"文件内容: {content}\")\n\n# 3. 搜索/列表文件\nfiles = await sandbox.files.search(\n    SearchEntry(\n        path=\"/tmp\",\n        pattern=\"*.txt\"\n    )\n)\nfor f in files:\n    print(f\"找到文件: {f.path}\")\n\n# 4. 删除文件\nawait sandbox.files.delete_files([\"/tmp/hello.txt\"])\n```\n\n### 5. 沙箱管理 (Sandbox Manager)\n\n使用 `SandboxManager` 进行管理操作，如查询现有沙箱列表。\n\n```python\nfrom opensandbox.manager import SandboxManager\nfrom opensandbox.models.sandboxes import SandboxFilter\n\n# 使用异步上下文管理器创建管理器\nasync with await SandboxManager.create(connection_config=config) as manager:\n\n    # 列出运行中的沙箱\n    sandboxes = await manager.list_sandbox_infos(\n        SandboxFilter(\n            states=[\"RUNNING\"],\n            page_size=10\n        )\n    )\n\n    for info in sandboxes.sandbox_infos:\n        print(f\"找到沙箱: {info.id}\")\n        # 执行管理操作\n        await manager.kill_sandbox(info.id)\n```\n\n## 配置说明\n\n### 1. 连接配置 (Connection Configuration)\n\n`ConnectionConfig` 类管理与 API 服务器的连接设置。\n\n| 参数              | 描述                                     | 默认值                   | 环境变量               |\n| ----------------- | ---------------------------------------- | ------------------------ | ---------------------- |\n| `api_key`         | 用于认证的 API Key                       | 必填                     | `OPEN_SANDBOX_API_KEY` |\n| `domain`          | 沙箱服务的端点域名                       | 必填 (或 localhost:8080) | `OPEN_SANDBOX_DOMAIN`  |\n| `protocol`        | HTTP 协议 (http/https)                   | `http`                   | -                      |\n| `request_timeout` | API 请求超时时间                         | 30 秒                    | -                      |\n| `debug`           | 是否开启 HTTP 请求的调试日志             | `False`                  | -                      |\n| `headers`         | 自定义 HTTP 请求头                       | 空                       | -                      |\n| `transport`       | 共享 httpx transport（连接池/代理/重试） | SDK 每实例创建           | -                      |\n| `use_server_proxy` | 是否通过沙箱服务代理访问 execd/endpoint（适用于客户端无法直连沙箱的场景） | `False` | -                      |\n\n```python\nfrom datetime import timedelta\n\n# 1. 基础配置\nconfig = ConnectionConfig(\n    api_key=\"your-key\",\n    domain=\"api.opensandbox.io\",\n    request_timeout=timedelta(seconds=60)\n)\n\n# 2. 进阶配置：自定义请求头和 transport\n# 如果你需要创建大量 Sandbox 实例，建议配置共享 transport 以优化资源使用。\n# SDK 默认连接保活时间为 30 秒。\nimport httpx\n\nconfig = ConnectionConfig(\n    api_key=\"your-key\",\n    domain=\"api.opensandbox.io\",\n    headers={\"X-Custom-Header\": \"value\"},\n    transport=httpx.AsyncHTTPTransport(\n        limits=httpx.Limits(\n            max_connections=100,\n            max_keepalive_connections=50,\n        keepalive_expiry=30.0,\n        )\n    ),\n)\n\n# 如果你传入自定义 transport，需要你自己负责关闭：\n# await config.transport.aclose()\n```\n\n### 2. 沙箱创建配置 (Sandbox Creation Configuration)\n\n`Sandbox.create()` 用于配置沙箱环境。\n\n| 参数            | 描述                 | 默认值                          |\n| --------------- | -------------------- | ------------------------------- |\n| `image`    | Docker 镜像        | 必填                            |\n| `timeout`       | 自动终止的超时时间     | 10 分钟                         |\n| `entrypoint`    | 容器启动入口命令       | `[\"tail\", \"-f\", \"/dev/null\"]`   |\n| `resource`      | CPU 和内存限制        | `{\"cpu\": \"1\", \"memory\": \"2Gi\"}` |\n| `env`           | 环境变量             | 空                              |\n| `metadata`      | 自定义元数据标签       | 空                              |\n| `network_policy` | 可选的出站网络策略（egress） | -                         |\n| `ready_timeout` | 等待沙箱就绪的最大时间 | 30 秒                           |\n\n注意：`opensandbox.io/` 前缀下的 metadata key 属于系统保留标签，服务端会拒绝用户传入。\n\n```python\nfrom datetime import timedelta\n\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\n\nsandbox = await Sandbox.create(\n    \"python:3.11\",\n    connection_config=config,\n    timeout=timedelta(minutes=30),\n    resource={\"cpu\": \"2\", \"memory\": \"4Gi\"},\n    env={\"PYTHONPATH\": \"/app\"},\n    metadata={\"project\": \"demo\"},\n    network_policy=NetworkPolicy(\n        defaultAction=\"deny\",\n        egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n    ),\n)\n```\n\n### 3. 运行时 Egress 策略更新\n\n运行时的 egress 查询和 patch 不再通过 lifecycle API 转发，而是由 SDK 先解析沙箱在 `18080` 端口上的 endpoint，再直接调用 sidecar 的 `/policy` API。\n\n```python\npolicy = await sandbox.get_egress_policy()\n\nawait sandbox.patch_egress_rules(\n    [\n        NetworkRule(action=\"allow\", target=\"www.github.com\"),\n        NetworkRule(action=\"deny\", target=\"pypi.org\"),\n    ]\n)\n```\n"
  },
  {
    "path": "sdks/sandbox/python/pyproject.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"opensandbox\"\ndynamic = [\"version\"]\ndescription = \"OpenSandbox Python SDK - Secure, isolated execution environments\"\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"ninan.nn@alibaba-inc.com\" }\n]\nlicense = { file = \"LICENSE\" }\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nkeywords = [\"sandbox\", \"code-execution\", \"docker\", \"security\", \"sdk\", \"opensandbox\"]\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Software Development :: Libraries\",\n    \"Typing :: Typed\",\n]\ndependencies = [\n    \"pydantic>=2.4.2,<3.0\",\n    \"python-dateutil>=2.8.2,<3.0\",\n    \"attrs>=21.3.0\",\n    \"httpx>=0.27.0,<1.0\",\n]\n\n[project.urls]\nHomepage = \"https://open-sandbox.ai\"\nRepository = \"https://github.com/alibaba/OpenSandbox\"\nDocumentation = \"https://open-sandbox.ai\"\nIssues = \"https://github.com/alibaba/OpenSandbox/issues\"\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.hatch.version.raw-options]\n# This package is in a subdirectory; explicitly point setuptools-scm at the git root.\nroot = \"../../..\"\ntag_regex = \"^python/sandbox/v(?P<version>\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[\\\\.\\\\w\\\\+\\\\-]*)?)$\"\ngit_describe_command = 'git describe --dirty --tags --long --match \"python/sandbox/v*\"'\nfallback_version = \"0.1.0\"\n\n[tool.hatch.build]\ninclude = [\n    \"LICENSE\",\n    \"src/**/py.typed\",\n    \"src/opensandbox\"\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/opensandbox\"]\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 88\nexclude = [\n    \"src/opensandbox/api/**\",\n]\n\n[tool.ruff.lint]\nselect = [\n    \"E\",  # pycodestyle errors\n    \"W\",  # pycodestyle warnings\n    \"F\",  # pyflakes\n    \"I\",  # isort\n    \"B\",  # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"UP\", # pyupgrade\n]\nignore = [\n    \"E501\", # line too long, handled by formatter\n    \"B008\", # do not perform function calls in argument defaults\n    \"C901\", # too complex\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.10\"\npythonPlatform = \"All\"\n\ninclude = [\"src\"]\n\nexclude = [\n    \"**/node_modules\",\n    \"**/__pycache__\",\n    \"src/opensandbox/api/**\",\n]\n\nvenvPath = \".\"\nvenv = \".venv\"\n\nreportMissingImports = true\nreportMissingTypeStubs = false\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = \"-ra -q --strict-markers --strict-config\"\ntestpaths = [\n    \"tests\",\n]\npython_files = [\n    \"test_*.py\",\n    \"*_test.py\",\n]\nasyncio_mode = \"auto\"\n\n[tool.coverage.run]\nsource = [\"src\"]\nbranch = true\n\n[dependency-groups]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"pytest-cov>=4.0.0\",\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.0\",\n    \"openapi-python-client>=0.28.0\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/scripts/generate_api.py",
    "content": "#!/usr/bin/env python3\n\n#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nOpenAPI client generation script for OpenSandbox Python SDK.\n\nThis script generates Python client code from OpenAPI specifications\nusing openapi-python-client, which generates httpx-based async clients\nthat support custom httpx.AsyncClient injection.\n\"\"\"\n\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nAPACHE_2_LICENSE_HEADER = \"\"\"#\\n# Copyright 2026 Alibaba Group Holding Ltd.\\n#\\n# Licensed under the Apache License, Version 2.0 (the \"License\");\\n# you may not use this file except in compliance with the License.\\n# You may obtain a copy of the License at\\n#\\n#     http://www.apache.org/licenses/LICENSE-2.0\\n#\\n# Unless required by applicable law or agreed to in writing, software\\n# distributed under the License is distributed on an \"AS IS\" BASIS,\\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\\n# See the License for the specific language governing permissions and\\n# limitations under the License.\\n#\\n\\n\"\"\"\n\n\ndef run_command(cmd: list[str], description: str) -> subprocess.CompletedProcess:\n    \"\"\"Run a command and handle errors.\"\"\"\n    print(f\"Running: {description}\")\n    print(f\"Command: {' '.join(cmd)}\")\n\n    try:\n        result = subprocess.run(cmd, check=True, capture_output=True, text=True)\n        print(\"✅ Success!\")\n        if result.stdout:\n            print(f\"Output: {result.stdout}\")\n        return result\n    except subprocess.CalledProcessError as e:\n        print(f\"❌ Error: {e}\")\n        if e.stdout:\n            print(f\"Stdout: {e.stdout}\")\n        if e.stderr:\n            print(f\"Stderr: {e.stderr}\")\n        raise\n\n\ndef generate_execd_api_client() -> None:\n    \"\"\"Generate the execd API client from OpenAPI spec.\"\"\"\n    print(\"\\n🔧 Generating execd API client...\")\n\n    spec_path = Path(\"../../../specs/execd-api.yaml\").resolve()\n    output_path = Path(\"src/opensandbox/api/execd\")\n    config_path = Path(\"scripts/openapi_execd_config.yaml\")\n    temp_output = Path(\"temp_execd_client\")\n\n    if not spec_path.exists():\n        print(f\"❌ OpenAPI spec not found at {spec_path}\")\n        print(\"Please ensure the specs directory is available\")\n        return\n\n    # Remove existing generated code\n    if output_path.exists():\n        shutil.rmtree(output_path)\n\n    # Remove temp directory if exists\n    if temp_output.exists():\n        shutil.rmtree(temp_output)\n\n    # Generate using openapi-python-client\n    cmd = [\n        \"openapi-python-client\",\n        \"generate\",\n        \"--path\",\n        str(spec_path),\n        \"--output-path\",\n        str(temp_output),\n        \"--config\",\n        str(config_path),\n        \"--overwrite\",\n    ]\n\n    try:\n        run_command(cmd, \"Generating execd API client\")\n    except subprocess.CalledProcessError:\n        print(\"❌ Failed to generate execd API client\")\n        return\n\n    # Move generated files to correct location\n    # openapi-python-client generates package inside the output directory\n    generated_package = temp_output / \"opensandbox_api_execd\"\n    if generated_package.exists():\n        output_path.parent.mkdir(parents=True, exist_ok=True)\n        shutil.move(str(generated_package), str(output_path))\n        shutil.rmtree(temp_output)\n        print(f\"✅ Moved generated code to {output_path}\")\n    else:\n        # If package name doesn't match, find the generated package\n        for item in temp_output.iterdir():\n            if item.is_dir() and not item.name.startswith(\".\"):\n                output_path.parent.mkdir(parents=True, exist_ok=True)\n                shutil.move(str(item), str(output_path))\n                shutil.rmtree(temp_output)\n                print(f\"✅ Moved generated code from {item} to {output_path}\")\n                break\n\n\ndef generate_egress_api_client() -> None:\n    \"\"\"Generate the egress API client from OpenAPI spec.\"\"\"\n    print(\"\\n🔧 Generating egress API client...\")\n\n    spec_path = Path(\"../../../specs/egress-api.yaml\").resolve()\n    output_path = Path(\"src/opensandbox/api/egress\")\n    config_path = Path(\"scripts/openapi_egress_config.yaml\")\n    temp_output = Path(\"temp_egress_client\")\n\n    if not spec_path.exists():\n        print(f\"❌ OpenAPI spec not found at {spec_path}\")\n        print(\"Please ensure the specs directory is available\")\n        return\n\n    if output_path.exists():\n        shutil.rmtree(output_path)\n\n    if temp_output.exists():\n        shutil.rmtree(temp_output)\n\n    cmd = [\n        \"openapi-python-client\",\n        \"generate\",\n        \"--path\",\n        str(spec_path),\n        \"--output-path\",\n        str(temp_output),\n        \"--config\",\n        str(config_path),\n        \"--overwrite\",\n    ]\n\n    try:\n        run_command(cmd, \"Generating egress API client\")\n    except subprocess.CalledProcessError:\n        print(\"❌ Failed to generate egress API client\")\n        return\n\n    generated_package = temp_output / \"opensandbox_api_egress\"\n    if generated_package.exists():\n        output_path.parent.mkdir(parents=True, exist_ok=True)\n        shutil.move(str(generated_package), str(output_path))\n        shutil.rmtree(temp_output)\n        print(f\"✅ Moved generated code to {output_path}\")\n    else:\n        for item in temp_output.iterdir():\n            if item.is_dir() and not item.name.startswith(\".\"):\n                output_path.parent.mkdir(parents=True, exist_ok=True)\n                shutil.move(str(item), str(output_path))\n                shutil.rmtree(temp_output)\n                print(f\"✅ Moved generated code from {item} to {output_path}\")\n                break\n\n\ndef generate_sandbox_lifecycle_api() -> None:\n    \"\"\"Generate the sandbox lifecycle API client.\"\"\"\n    print(\"\\n🔧 Generating sandbox lifecycle API client...\")\n\n    spec_path = Path(\"../../../specs/sandbox-lifecycle.yml\").resolve()\n    output_path = Path(\"src/opensandbox/api/lifecycle\")\n    config_path = Path(\"scripts/openapi_lifecycle_config.yaml\")\n    temp_output = Path(\"temp_lifecycle_client\")\n\n    if not spec_path.exists():\n        print(f\"❌ OpenAPI spec not found at {spec_path}\")\n        return\n\n    # Remove existing generated code\n    if output_path.exists():\n        shutil.rmtree(output_path)\n\n    # Remove temp directory if exists\n    if temp_output.exists():\n        shutil.rmtree(temp_output)\n\n    # Generate using openapi-python-client\n    cmd = [\n        \"openapi-python-client\",\n        \"generate\",\n        \"--path\",\n        str(spec_path),\n        \"--output-path\",\n        str(temp_output),\n        \"--config\",\n        str(config_path),\n        \"--overwrite\",\n    ]\n\n    try:\n        run_command(cmd, \"Generating sandbox lifecycle API client\")\n    except subprocess.CalledProcessError:\n        print(\"❌ Failed to generate lifecycle API client\")\n        return\n\n    # Move generated files to correct location\n    generated_package = temp_output / \"opensandbox_api_lifecycle\"\n    if generated_package.exists():\n        output_path.parent.mkdir(parents=True, exist_ok=True)\n        shutil.move(str(generated_package), str(output_path))\n        shutil.rmtree(temp_output)\n        print(f\"✅ Moved generated code to {output_path}\")\n    else:\n        # If package name doesn't match, find the generated package\n        for item in temp_output.iterdir():\n            if item.is_dir() and not item.name.startswith(\".\"):\n                output_path.parent.mkdir(parents=True, exist_ok=True)\n                shutil.move(str(item), str(output_path))\n                shutil.rmtree(temp_output)\n                print(f\"✅ Moved generated code from {item} to {output_path}\")\n                break\n\n\ndef add_license_headers(root: Path) -> None:\n    \"\"\"Add Apache-2.0 license header to generated python files (idempotent).\"\"\"\n    if not root.exists():\n        return\n\n    touched = 0\n    skipped = 0\n\n    for file_path in root.rglob(\"*.py\"):\n        content = file_path.read_text(encoding=\"utf-8\")\n\n        # Avoid double-inserting if generation already includes headers.\n        # Keep the check lightweight and tolerant to minor variations.\n        head = \"\\n\".join(content.splitlines()[:50])\n        if \"Licensed under the Apache License, Version 2.0\" in head:\n            skipped += 1\n            continue\n\n        file_path.write_text(APACHE_2_LICENSE_HEADER + content, encoding=\"utf-8\")\n        touched += 1\n\n    print(\n        f\"✅ Added license headers under {root} (updated={touched}, skipped={skipped})\"\n    )\n\n\ndef patch_lifecycle_nullable_nested_models(root: Path) -> None:\n    \"\"\"Patch generated lifecycle models that openapi-python-client does not null-handle.\"\"\"\n    replacements = {\n        root / \"models\" / \"image_spec.py\": [\n            (\n                \"        if isinstance(_auth, Unset):\\n            auth = UNSET\\n\",\n                \"        if isinstance(_auth, Unset) or _auth is None:\\n            auth = UNSET\\n\",\n            )\n        ],\n        root / \"models\" / \"create_sandbox_response.py\": [\n            (\n                \"        if isinstance(_metadata, Unset):\\n            metadata = UNSET\\n\",\n                \"        if isinstance(_metadata, Unset) or _metadata is None:\\n            metadata = UNSET\\n\",\n            )\n        ],\n        root / \"models\" / \"sandbox.py\": [\n            (\n                \"        if isinstance(_metadata, Unset):\\n            metadata = UNSET\\n\",\n                \"        if isinstance(_metadata, Unset) or _metadata is None:\\n            metadata = UNSET\\n\",\n            )\n        ],\n        root / \"models\" / \"sandbox_status.py\": [\n            (\n                \"        if isinstance(_last_transition_at, Unset):\\n            last_transition_at = UNSET\\n\",\n                \"        if isinstance(_last_transition_at, Unset) or _last_transition_at is None:\\n            last_transition_at = UNSET\\n\",\n            )\n        ],\n    }\n\n    patched_files = 0\n    for file_path, file_replacements in replacements.items():\n        if not file_path.exists():\n            continue\n\n        content = file_path.read_text(encoding=\"utf-8\")\n        updated = content\n        for old, new in file_replacements:\n            if old in updated:\n                updated = updated.replace(old, new, 1)\n\n        if updated != content:\n            file_path.write_text(updated, encoding=\"utf-8\")\n            patched_files += 1\n\n    if patched_files:\n        print(f\"✅ Patched nullable lifecycle model handling in {patched_files} files\")\n\n\ndef post_process_generated_code() -> None:\n    \"\"\"Post-process the generated code to ensure proper package structure.\"\"\"\n    print(\"\\n🔧 Post-processing generated code...\")\n\n    # Ensure API directory has __init__.py\n    api_dir = Path(\"src/opensandbox/api\")\n    if api_dir.exists():\n        init_file = api_dir / \"__init__.py\"\n        if not init_file.exists():\n            init_file.write_text(\n                '\"\"\"OpenSandbox API clients generated from OpenAPI specs.\"\"\"\\n'\n            )\n            print(f\"✅ Created {init_file}\")\n\n    # Ensure all generated python files have a license header.\n    add_license_headers(Path(\"src/opensandbox/api/execd\"))\n    add_license_headers(Path(\"src/opensandbox/api/egress\"))\n    add_license_headers(Path(\"src/opensandbox/api/lifecycle\"))\n    add_license_headers(Path(\"src/opensandbox/api\"))\n    patch_lifecycle_nullable_nested_models(Path(\"src/opensandbox/api/lifecycle\"))\n\n\ndef main() -> None:\n    \"\"\"Main function to generate all API clients.\"\"\"\n    print(\"🚀 OpenSandbox Python SDK API Generator\")\n    print(\"=\" * 50)\n    print(\"Using openapi-python-client for httpx-based async clients\")\n    print(\"=\" * 50)\n\n    # Check if openapi-python-client is available\n    try:\n        result = subprocess.run(\n            [\"openapi-python-client\", \"--version\"], check=True, capture_output=True\n        )\n        version = result.stdout.decode().strip() or result.stderr.decode().strip()\n        print(f\"openapi-python-client version: {version}\")\n    except (subprocess.CalledProcessError, FileNotFoundError):\n        print(\"❌ openapi-python-client not found!\")\n        print(\"Please install it with: pip install openapi-python-client\")\n        print(\"Or: uv add --dev openapi-python-client\")\n        sys.exit(1)\n\n    # Create API directories\n    Path(\"src/opensandbox/api\").mkdir(parents=True, exist_ok=True)\n\n    # Generate API clients\n    generate_execd_api_client()\n    generate_egress_api_client()\n    generate_sandbox_lifecycle_api()\n\n    # Post-process\n    post_process_generated_code()\n\n    print(\"\\n✅ API client generation completed!\")\n    print(\"Generated clients:\")\n    print(\"  - src/opensandbox/api/execd/\")\n    print(\"  - src/opensandbox/api/egress/\")\n    print(\"  - src/opensandbox/api/lifecycle/\")\n    print(\"\\nThe generated clients support custom httpx.AsyncClient injection:\")\n    print(\"  from opensandbox.api.execd import Client, AuthenticatedClient\")\n    print(\n        '  client = AuthenticatedClient(base_url=\"...\", token=\"...\", httpx_client=custom_client)'\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "sdks/sandbox/python/scripts/openapi_egress_config.yaml",
    "content": "# openapi-python-client configuration for egress API\n# This generates a httpx-based async client that supports custom httpx.AsyncClient injection\n\nproject_name_override: opensandbox_api_egress\npackage_name_override: opensandbox_api_egress\n\nuse_path_prefix_for_title_model_names: true\n"
  },
  {
    "path": "sdks/sandbox/python/scripts/openapi_execd_config.yaml",
    "content": "# openapi-python-client configuration for execd API\n# This generates a httpx-based async client that supports custom httpx.AsyncClient injection\n\n# Package name without hyphens (will be the module directory name)\nproject_name_override: opensandbox_api_execd\npackage_name_override: opensandbox_api_execd\n\n# Use modern Python features\nuse_path_prefix_for_title_model_names: true\n\n# Generate both sync and async clients (async is default, sync is opt-in)\n# The generated client will support passing a custom httpx.AsyncClient\n"
  },
  {
    "path": "sdks/sandbox/python/scripts/openapi_lifecycle_config.yaml",
    "content": "# openapi-python-client configuration for lifecycle API\n# This generates a httpx-based async client that supports custom httpx.AsyncClient injection\n\n# Package name without hyphens (will be the module directory name)\nproject_name_override: opensandbox_api_lifecycle\npackage_name_override: opensandbox_api_lifecycle\n\n# Use modern Python features\nuse_path_prefix_for_title_model_names: true\n\n# Generate both sync and async clients (async is default, sync is opt-in)\n# The generated client will support passing a custom httpx.AsyncClient\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nOpenSandbox Python SDK\n\nSecure, isolated execution environments for code and applications.\n\n## Basic Usage\n\n```python\nimport asyncio\nfrom opensandbox import Sandbox\nfrom opensandbox.models.execd import RunCommandOpts\nfrom opensandbox.models.sandboxes import SandboxImageSpec\n\nasync def main():\n    # Create a sandbox instance.\n    #\n    # Note on lifecycle:\n    # - Exiting the context manager will call `sandbox.close()` (local HTTP resources only).\n    # - You must still call `sandbox.kill()` to terminate the remote sandbox instance.\n    async with await Sandbox.create(\"python:3.11\") as sandbox:\n        # Write a file\n        await sandbox.files.write_file(\"hello.py\", \"print('Hello World')\")\n\n        # Execute a command\n        result = await sandbox.commands.run(\"python hello.py\")\n        print(result.logs.stdout[0].text)  # Hello World\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n## Advanced Usage\n\n```python\nfrom datetime import timedelta\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.execd import RunCommandOpts\nfrom opensandbox.models.sandboxes import SandboxImageSpec, SandboxImageAuth\n\nasync def main():\n    config = ConnectionConfig(\n        api_key=\"your-api-key\",\n        domain=\"api.opensandbox.io\"\n    )\n\n    # With private registry auth\n    image_spec = SandboxImageSpec(\n        \"my-registry.com/python:3.11\",\n        auth=SandboxImageAuth(username=\"user\", password=\"secret\")\n    )\n\n    sandbox = await Sandbox.create(\n        image_spec,\n        timeout=timedelta(minutes=30),\n        env={\"PYTHONPATH\": \"/workspace\"},\n        connection_config=config,\n    )\n\n    try:\n        # File operations\n        await sandbox.files.write_file(\"script.py\", \"print('Hello OpenSandbox!')\")\n\n        # Command execution\n        result = await sandbox.commands.run(\"python script.py\")\n        print(result.logs.stdout[0].text)\n\n        # Get metrics\n        metrics = await sandbox.get_metrics()\n        print(f\"Memory usage: {metrics.memory_used_in_mib}MB\")\n\n    finally:\n        await sandbox.kill()\n        await sandbox.close()\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nFor advanced code execution with persistent contexts, see the separate\n`opensandbox-code-interpreter` package.\n\"\"\"\n\nfrom importlib.metadata import PackageNotFoundError\nfrom importlib.metadata import version as _pkg_version\n\nfrom opensandbox.manager import SandboxManager\nfrom opensandbox.sandbox import Sandbox\nfrom opensandbox.sync import SandboxManagerSync, SandboxSync\n\ntry:\n    __version__ = _pkg_version(\"opensandbox\")\nexcept PackageNotFoundError:  # pragma: no cover\n    # Fallback for editable/uninstalled source checkouts.\n    __version__ = \"0.0.0\"\n\n__all__ = [\n    \"Sandbox\",\n    \"SandboxManager\",\n    \"SandboxSync\",\n    \"SandboxManagerSync\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nAdapter layer for OpenSandbox SDK.\n\nImplements the service protocols using HTTP API calls.\n\"\"\"\n\nfrom opensandbox.adapters.command_adapter import CommandsAdapter\nfrom opensandbox.adapters.egress_adapter import EgressAdapter\nfrom opensandbox.adapters.factory import AdapterFactory\nfrom opensandbox.adapters.filesystem_adapter import FilesystemAdapter\nfrom opensandbox.adapters.health_adapter import HealthAdapter\nfrom opensandbox.adapters.metrics_adapter import MetricsAdapter\nfrom opensandbox.adapters.sandboxes_adapter import SandboxesAdapter\n\n__all__ = [\n    \"AdapterFactory\",\n    \"SandboxesAdapter\",\n    \"FilesystemAdapter\",\n    \"CommandsAdapter\",\n    \"EgressAdapter\",\n    \"HealthAdapter\",\n    \"MetricsAdapter\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nCommand service adapter implementation.\n\nImplementation of Commands that adapts openapi-python-client generated CommandApi.\nThis adapter handles command execution within sandboxes, providing both\nsynchronous and streaming execution modes with proper session management.\n\"\"\"\n\nimport json\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.command_model_converter import (\n    to_command_status,\n)\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.execution_converter import (\n    ExecutionConverter,\n)\nfrom opensandbox.adapters.converter.execution_event_dispatcher import (\n    ExecutionEventDispatcher,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n)\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.execd import (\n    CommandLogs,\n    CommandStatus,\n    Execution,\n    ExecutionHandlers,\n    RunCommandOpts,\n)\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.services.command import Commands\n\nlogger = logging.getLogger(__name__)\n\n\nclass CommandsAdapter(Commands):\n    \"\"\"\n    Implementation of Commands that adapts openapi-python-client generated CommandApi.\n\n    This adapter handles command execution within sandboxes, providing both\n    synchronous and streaming execution modes with proper session management.\n\n    The adapter uses direct httpx streaming for command execution to handle\n    Server-Sent Events (SSE) properly, while using the generated API client\n    for simpler operations like interrupt.\n    \"\"\"\n\n    RUN_COMMAND_PATH = \"/command\"\n    INTERRUPT_COMMAND_PATH = \"/command/{execution_id}/interrupt\"\n\n    def __init__(\n        self,\n        connection_config: ConnectionConfig,\n        execd_endpoint: SandboxEndpoint,\n    ) -> None:\n        \"\"\"\n        Initialize the command service adapter.\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n            execd_endpoint: Endpoint for execd service\n        \"\"\"\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        protocol = self.connection_config.protocol\n        base_url = f\"{protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        # Execd API does not require authentication\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n\n        # Inject httpx client (adapter-owned)\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n        # SSE client (read timeout disabled); endpoint headers already in headers\n        sse_headers = {\n            **headers,\n            \"Accept\": \"text/event-stream\",\n            \"Cache-Control\": \"no-cache\",\n        }\n        self._sse_client = httpx.AsyncClient(\n            headers=sse_headers,\n            timeout=httpx.Timeout(\n                connect=timeout_seconds,\n                read=None,\n                write=timeout_seconds,\n                pool=None,\n            ),\n            transport=self.connection_config.transport,\n        )\n\n    async def _get_client(self):\n        \"\"\"Return the client for execd API (no auth required).\"\"\"\n        return self._client\n\n    def _get_execd_url(self, path: str) -> str:\n        \"\"\"Build URL for execd endpoint.\"\"\"\n        protocol = self.connection_config.protocol\n        return f\"{protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    async def _get_sse_client(self) -> httpx.AsyncClient:\n        \"\"\"Return SSE client (read timeout disabled) for execd streaming.\"\"\"\n        return self._sse_client\n\n    async def run(\n        self,\n        command: str,\n        *,\n        opts: RunCommandOpts | None = None,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution:\n        \"\"\"Execute a shell command within the sandbox.\n\n        This method uses direct httpx streaming to handle SSE responses\n        from the execd service.\n        \"\"\"\n        if not command.strip():\n            raise InvalidArgumentException(\"Command cannot be empty\")\n\n        try:\n            # Convert domain model to API model\n            opts = opts or RunCommandOpts()\n            json_body = ExecutionConverter.to_api_run_command_json(command, opts)\n\n            # Prepare URL\n            url = self._get_execd_url(self.RUN_COMMAND_PATH)\n\n            execution = Execution(\n                id=None,\n                execution_count=None,\n                result=[],\n                error=None,\n            )\n\n            # Use SSE client for streaming responses (read timeout disabled)\n            client = await self._get_sse_client()\n\n            # Use streaming request for SSE\n            async with client.stream(\"POST\", url, json=json_body) as response:\n                if response.status_code != 200:\n                    await response.aread()\n                    error_body = response.text\n                    logger.error(\n                        f\"Failed to run command. Status: {response.status_code}, Body: {error_body}\"\n                    )\n                    raise SandboxApiException(\n                        message=f\"Failed to run command. Status code: {response.status_code}\",\n                        status_code=response.status_code,\n                        request_id=extract_request_id(response.headers),\n                    )\n\n                dispatcher = ExecutionEventDispatcher(execution, handlers)\n\n                async for line in response.aiter_lines():\n                    if not line.strip():\n                        continue\n\n                    # Handle potential SSE format \"data: ...\"\n                    data = line\n                    if data.startswith(\"data:\"):\n                        data = data[5:].strip()\n\n                    try:\n                        event_dict = json.loads(data)\n                        event_node = EventNode(**event_dict)\n                        await dispatcher.dispatch(event_node)\n                    except Exception as e:\n                        logger.error(f\"Failed to parse SSE line: {line}\", exc_info=e)\n\n            return execution\n\n        except Exception as e:\n            logger.error(\n                \"Failed to run command (length: %s)\",\n                len(command),\n                exc_info=e,\n            )\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def interrupt(self, execution_id: str) -> None:\n        \"\"\"Interrupt a running command execution.\"\"\"\n        try:\n            from opensandbox.api.execd.api.command import interrupt_command\n\n            client = await self._get_client()\n            response_obj = await interrupt_command.asyncio_detailed(\n                client=client,\n                id=execution_id,\n            )\n\n            handle_api_error(response_obj, \"Interrupt command\")\n\n        except Exception as e:\n            logger.error(\"Failed to interrupt command\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_command_status(self, execution_id: str) -> CommandStatus:\n        \"\"\"Get the current running status for a command.\"\"\"\n        try:\n            from opensandbox.adapters.converter.response_handler import require_parsed\n            from opensandbox.api.execd.api.command import get_command_status\n            from opensandbox.api.execd.models import CommandStatusResponse\n\n            client = await self._get_client()\n            response_obj = await get_command_status.asyncio_detailed(\n                client=client,\n                id=execution_id,\n            )\n\n            handle_api_error(response_obj, \"Get command status\")\n            parsed = require_parsed(response_obj, CommandStatusResponse, \"Get command status\")\n            return to_command_status(parsed)\n        except Exception as e:\n            logger.error(\"Failed to get command status\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_background_command_logs(\n        self, execution_id: str, cursor: int | None = None\n    ) -> CommandLogs:\n        \"\"\"Get background command logs (non-streamed).\"\"\"\n        try:\n            from opensandbox.adapters.converter.response_handler import require_parsed\n            from opensandbox.api.execd.api.command import get_background_command_logs\n\n            client = await self._get_client()\n            from opensandbox.api.execd.types import UNSET\n\n            response_obj = await get_background_command_logs.asyncio_detailed(\n                client=client,\n                id=execution_id,\n                cursor=cursor if cursor is not None else UNSET,\n            )\n\n            handle_api_error(response_obj, \"Get command logs\")\n            content = require_parsed(response_obj, str, \"Get command logs\")\n            cursor_header = response_obj.headers.get(\"EXECD-COMMANDS-TAIL-CURSOR\")\n            next_cursor = None\n            if cursor_header:\n                try:\n                    next_cursor = int(cursor_header)\n                except ValueError:\n                    next_cursor = None\n            return CommandLogs(content=content, cursor=next_cursor)\n        except Exception as e:\n            logger.error(\"Failed to get command logs\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nModel converter utilities for API/domain model mapping.\n\nThis package provides:\n- ExceptionConverter: Convert various exceptions to SandboxException\n- ResponseHandler: Unified API response handling\n- SandboxModelConverter: Convert between API and domain models\n- FilesystemModelConverter: Convert filesystem-related models\n- MetricsModelConverter: Convert metrics-related models\n- ExecutionConverter: Convert execution-related models\n\"\"\"\n\nfrom opensandbox.adapters.converter.command_model_converter import (\n    to_command_status,\n)\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n    parse_sandbox_error,\n)\nfrom opensandbox.adapters.converter.filesystem_model_converter import (\n    FilesystemModelConverter,\n)\nfrom opensandbox.adapters.converter.metrics_model_converter import (\n    MetricsModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n)\nfrom opensandbox.adapters.converter.sandbox_model_converter import (\n    SandboxModelConverter,\n)\n\n__all__ = [\n    \"ExceptionConverter\",\n    \"parse_sandbox_error\",\n    \"FilesystemModelConverter\",\n    \"MetricsModelConverter\",\n    \"to_command_status\",\n    \"SandboxModelConverter\",\n    \"handle_api_error\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/command_model_converter.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"\nConverters for execd command-related models.\n\"\"\"\n\nfrom typing import TypeVar, cast\n\nfrom opensandbox.api.execd.models import CommandStatusResponse\nfrom opensandbox.api.execd.types import Unset\nfrom opensandbox.models.execd import CommandStatus\n\nT = TypeVar(\"T\")\n\n\ndef _unwrap_optional(value: Unset | T) -> T | None:\n    if isinstance(value, Unset):\n        return None\n    return cast(T, value)\n\n\ndef to_command_status(raw: CommandStatusResponse) -> CommandStatus:\n    \"\"\"\n    Convert OpenAPI CommandStatusResponse to SDK CommandStatus.\n    \"\"\"\n\n    return CommandStatus(\n        id=_unwrap_optional(raw.id),\n        content=_unwrap_optional(raw.content),\n        running=_unwrap_optional(raw.running),\n        exit_code=_unwrap_optional(raw.exit_code),\n        error=_unwrap_optional(raw.error),\n        started_at=_unwrap_optional(raw.started_at),\n        finished_at=_unwrap_optional(raw.finished_at),\n    )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/event_node.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nEventNode model for parsing Server-Sent Events from execd.\n\"\"\"\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass EventNodeError(BaseModel):\n    \"\"\"Error details in an event node.\"\"\"\n\n    name: str | None = Field(default=None, alias=\"ename\")\n    value: str | None = Field(default=None, alias=\"evalue\")\n    traceback: list[str] = Field(default_factory=list)\n\n\nclass EventNodeResults(BaseModel):\n    \"\"\"Results container in an event node.\"\"\"\n\n    text: str | None = Field(default=None, alias=\"text\")\n\n    def get_text(self) -> str:\n        \"\"\"Get the text representation of the result.\"\"\"\n        return self.text or \"\"\n\n    model_config = ConfigDict(extra=\"allow\")  # Allow other mime types\n\n\nclass EventNode(BaseModel):\n    \"\"\"\n    Represents a single event from the server stream.\n    Corresponds to ServerStreamEvent in OpenAPI spec.\n    \"\"\"\n\n    type: str\n    text: str | None = None\n    execution_count: int | None = Field(default=None, alias=\"execution_count\")\n    execution_time_in_millis: int | None = Field(default=None, alias=\"execution_time\")\n    timestamp: int\n    results: EventNodeResults | None = None\n    error: EventNodeError | None = None\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/exception_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nException converter utilities.\n\nProvides conversion functions from API exceptions to domain exceptions,\nsimilar to the Kotlin SDK ExceptionConverter pattern.\n\nThis module handles:\n1. Converting openapi-python-client generated exceptions\n2. Converting httpx HTTP errors\n3. Converting network/IO errors\n4. Parsing error response bodies to extract SandboxError information\n\"\"\"\n\nimport json\nimport logging\nfrom typing import Any\n\nfrom httpx import (\n    ConnectError,\n    HTTPStatusError,\n    NetworkError,\n    ReadTimeout,\n    TimeoutException,\n    WriteTimeout,\n)\n\nfrom opensandbox.api.execd.errors import UnexpectedStatus as ExecdUnexpectedStatus\nfrom opensandbox.api.lifecycle.errors import (\n    UnexpectedStatus as LifecycleUnexpectedStatus,\n)\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxApiException,\n    SandboxError,\n    SandboxException,\n    SandboxInternalException,\n)\n\nlogger = logging.getLogger(__name__)\n\nUNEXPECTED_STATUS_TYPES = (LifecycleUnexpectedStatus, ExecdUnexpectedStatus)\nHTTPX_NETWORK_ERROR_TYPES = (\n    ConnectError,\n    TimeoutException,\n    NetworkError,\n    ReadTimeout,\n    WriteTimeout,\n)\n\n\nclass ExceptionConverter:\n    \"\"\"\n    Exception converter utilities following Kotlin SDK patterns.\n\n    Provides static methods to convert various exceptions to sandbox exceptions,\n    including proper parsing of error response bodies.\n    \"\"\"\n\n    @staticmethod\n    def to_sandbox_exception(e: Exception) -> SandboxException:\n        \"\"\"\n        Convert any exception to a SandboxException.\n\n        Following Kotlin SDK pattern:\n        - SandboxException -> return as-is\n        - API client exceptions -> convert to SandboxApiException\n        - IOError/network errors -> convert to SandboxInternalException with network message\n        - IllegalArgumentError/ValueError -> convert to SandboxInternalException with usage message\n        - Other exceptions -> convert to SandboxInternalException with unexpected error message\n\n        Args:\n            e: The original exception\n\n        Returns:\n            A SandboxException subclass\n        \"\"\"\n        # If already a SandboxException, return as-is\n        if isinstance(e, SandboxException):\n            return e\n\n        # Handle openapi-python-client UnexpectedStatus error\n        if _is_unexpected_status_error(e):\n            return _convert_unexpected_status_to_api_exception(e)\n\n        # Handle httpx HTTPStatusError\n        if _is_httpx_status_error(e):\n            return _convert_httpx_error_to_api_exception(e)\n\n        # Handle network/IO errors\n        if isinstance(e, (IOError, OSError, ConnectionError)):\n            return SandboxInternalException(\n                message=f\"Network connectivity error: {e}\",\n                cause=e,\n            )\n\n        # Handle httpx network errors\n        if _is_httpx_network_error(e):\n            return SandboxInternalException(\n                message=f\"Network connectivity error: {e}\",\n                cause=e,\n            )\n\n        # Handle validation and argument errors (SDK usage errors)\n        # - ValueError/TypeError are typically raised for invalid user inputs or model validation\n        # - Pydantic ValidationError represents invalid input data for SDK models\n        try:\n            from pydantic import ValidationError  # type: ignore\n\n            if isinstance(e, ValidationError):\n                return InvalidArgumentException(message=str(e), cause=e)\n        except Exception:\n            # If pydantic isn't available for some reason, just ignore and continue\n            pass\n\n        if isinstance(e, (ValueError, TypeError)):\n            return InvalidArgumentException(message=str(e), cause=e)\n\n        # Handle unsupported operations\n        if isinstance(e, NotImplementedError):\n            return SandboxInternalException(\n                message=f\"Operation not supported: {e}\",\n                cause=e,\n            )\n\n        # Default to unexpected error\n        return SandboxInternalException(\n            message=f\"Unexpected SDK error occurred: {e}\",\n            cause=e,\n        )\n\n\ndef _is_unexpected_status_error(e: Exception) -> bool:\n    \"\"\"Check if exception is an openapi-python-client UnexpectedStatus error.\"\"\"\n    return isinstance(e, UNEXPECTED_STATUS_TYPES)\n\n\ndef _is_httpx_status_error(e: Exception) -> bool:\n    \"\"\"Check if exception is an httpx HTTPStatusError.\"\"\"\n    return isinstance(e, HTTPStatusError)\n\n\ndef _is_httpx_network_error(e: Exception) -> bool:\n    \"\"\"Check if exception is an httpx network-related error.\"\"\"\n    return isinstance(e, HTTPX_NETWORK_ERROR_TYPES)\n\n\ndef _convert_unexpected_status_to_api_exception(e: Exception) -> SandboxApiException:\n    \"\"\"Convert openapi-python-client UnexpectedStatus to SandboxApiException.\"\"\"\n    status_code = getattr(e, \"status_code\", 0)\n    content = getattr(e, \"content\", b\"\")\n\n    # Try to parse error body\n    sandbox_error = _parse_error_body(content)\n\n    return SandboxApiException(\n        message=f\"API error: HTTP {status_code}\",\n        status_code=status_code,\n        cause=e,\n        error=sandbox_error,\n    )\n\n\ndef _convert_httpx_error_to_api_exception(e: Exception) -> SandboxApiException:\n    \"\"\"Convert httpx HTTPStatusError to SandboxApiException.\"\"\"\n    response = getattr(e, \"response\", None)\n    status_code = response.status_code if response else 0\n    content = response.content if response else b\"\"\n    request_id = None\n    if response is not None:\n        from opensandbox.adapters.converter.response_handler import extract_request_id\n\n        request_id = extract_request_id(response.headers)\n\n    # Try to parse error body\n    sandbox_error = _parse_error_body(content)\n\n    return SandboxApiException(\n        message=f\"API error: HTTP {status_code}\",\n        status_code=status_code,\n        cause=e,\n        error=sandbox_error,\n        request_id=request_id,\n    )\n\n\ndef _parse_error_body(body: Any) -> SandboxError | None:\n    \"\"\"\n    Parse error body to extract SandboxError information.\n\n    Similar to Kotlin SDK's parseSandboxError function.\n\n    Args:\n        body: The error response body (bytes, str, or dict)\n\n    Returns:\n        SandboxError if parsing succeeds, None otherwise\n    \"\"\"\n    if body is None:\n        return None\n\n    try:\n        # Convert bytes to string\n        if isinstance(body, bytes):\n            if not body:\n                return None\n            body = body.decode(\"utf-8\", errors=\"replace\")\n\n        if isinstance(body, str) and not body:\n            return None\n\n        # Parse JSON string\n        if isinstance(body, str):\n            try:\n                body = json.loads(body)\n            except json.JSONDecodeError:\n                # If not JSON, return error with the raw string as message\n                return SandboxError(\n                    code=SandboxError.UNEXPECTED_RESPONSE,\n                    message=body,\n                )\n\n        # Extract code and message from dict\n        if isinstance(body, dict):\n            code: str | None = body.get(\"code\")\n            message: str | None = body.get(\"message\")\n\n            if code:\n                return SandboxError(code=code, message=message or \"\")\n\n        return None\n\n    except Exception as ex:\n        logger.debug(\"Failed to parse error body: %s\", ex)\n        return None\n\n\ndef parse_sandbox_error(body: Any) -> SandboxError | None:\n    \"\"\"\n    Public function to parse error body to SandboxError.\n\n    Exposed for use by other modules that need to parse error bodies.\n    \"\"\"\n    return _parse_error_body(body)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/execution_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nExecution model converter utilities.\n\nProvides conversion functions between API models and domain models for execution-related operations,\nsimilar to the Kotlin SDK ExecutionConverter.\n\nThis converter is designed to work with openapi-python-client generated models.\n\"\"\"\n\nfrom typing import Any\n\nfrom opensandbox.api.execd.models.run_command_request import (\n    RunCommandRequest as ApiRunCommandRequest,\n)\nfrom opensandbox.models.execd import RunCommandOpts\n\n\nclass ExecutionConverter:\n    \"\"\"\n    Execution model converter utilities.\n\n    Provides static methods to convert between API models and domain models\n    for execution-related operations.\n\n    The API models are generated by openapi-python-client and use attrs.\n    \"\"\"\n\n    @staticmethod\n    def to_api_run_command_request(command: str, opts: RunCommandOpts) -> ApiRunCommandRequest:\n        \"\"\"Convert domain command + options to API RunCommandRequest.\"\"\"\n        from opensandbox.api.execd.models.run_command_request_envs import (\n            RunCommandRequestEnvs,\n        )\n        from opensandbox.api.execd.types import UNSET\n\n        # Convert working_directory to cwd, handling None\n        cwd = UNSET\n        if opts.working_directory:\n            cwd = opts.working_directory\n\n        background = UNSET\n        if opts.background:\n            background = opts.background\n\n        timeout_milliseconds = UNSET\n        if opts.timeout is not None:\n            timeout_milliseconds = int(opts.timeout.total_seconds() * 1000)\n\n        uid = UNSET\n        if opts.uid is not None:\n            uid = opts.uid\n\n        gid = UNSET\n        if opts.gid is not None:\n            gid = opts.gid\n\n        envs = UNSET\n        if opts.envs is not None:\n            envs_payload = RunCommandRequestEnvs()\n            for key, value in opts.envs.items():\n                envs_payload[key] = value\n            envs = envs_payload\n\n        return ApiRunCommandRequest(\n            command=command,\n            background=background,\n            cwd=cwd,  # Domain uses 'working_directory', API uses 'cwd'\n            timeout=timeout_milliseconds,\n            uid=uid,\n            gid=gid,\n            envs=envs,\n            # Note: handlers are not included in API request as they are for local processing\n        )\n\n    @staticmethod\n    def to_api_run_command_json(command: str, opts: RunCommandOpts) -> dict[str, Any]:\n        \"\"\"\n        Convert command + options to a plain JSON-serializable dict for httpx requests.\n        Centralizes the attrs/pydantic differences behind one callsite.\n        \"\"\"\n        api_request = ExecutionConverter.to_api_run_command_request(command, opts)\n        if hasattr(api_request, \"to_dict\"):\n            return api_request.to_dict()\n        # Fallback (shouldn't normally happen for openapi-python-client models).\n        return dict(getattr(api_request, \"__dict__\", {}))\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/execution_event_dispatcher.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nDispatcher for processing execution events.\n\"\"\"\n\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.models.execd import (\n    Execution,\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionHandlers,\n    ExecutionInit,\n    ExecutionResult,\n    OutputMessage,\n)\n\n\nclass ExecutionEventDispatcher:\n    \"\"\"\n    Dispatches events from the server stream to the Execution object and handlers.\n    \"\"\"\n\n    def __init__(\n        self,\n        execution: Execution,\n        handlers: ExecutionHandlers | None = None,\n    ) -> None:\n        self.execution = execution\n        self.handlers = handlers\n\n    async def dispatch(self, event_node: EventNode) -> None:\n        \"\"\"Dispatch a single event node asynchronously.\"\"\"\n        event_type = event_node.type\n        timestamp = event_node.timestamp\n\n        if event_type == \"stdout\":\n            await self._handle_stdout(event_node, timestamp)\n        elif event_type == \"stderr\":\n            await self._handle_stderr(event_node, timestamp)\n        elif event_type == \"result\":\n            await self._handle_result(event_node, timestamp)\n        elif event_type == \"error\":\n            await self._handle_error(event_node, timestamp)\n        elif event_type == \"execution_complete\":\n            await self._handle_execution_complete(event_node, timestamp)\n        elif event_type == \"init\":\n            await self._handle_init(event_node, timestamp)\n        elif event_type == \"execution_count\":\n            if event_node.execution_count is not None:\n                self.execution.execution_count = event_node.execution_count\n\n    async def _handle_init(self, event_node: EventNode, timestamp: int) -> None:\n        execution_id = event_node.text or \"\"\n        init_event = ExecutionInit(\n            id=execution_id,\n            timestamp=timestamp,\n        )\n        self.execution.id = init_event.id\n        if self.handlers and self.handlers.on_init:\n            await self.handlers.on_init(init_event)\n\n    async def _handle_stdout(self, event_node: EventNode, timestamp: int) -> None:\n        text = event_node.text or \"\"\n        message = OutputMessage(\n            text=text,\n            timestamp=timestamp,\n            is_error=False,\n        )\n        self.execution.logs.add_stdout(message)\n        if self.handlers and self.handlers.on_stdout:\n            await self.handlers.on_stdout(message)\n\n    async def _handle_stderr(self, event_node: EventNode, timestamp: int) -> None:\n        text = event_node.text or \"\"\n        message = OutputMessage(\n            text=text,\n            timestamp=timestamp,\n            is_error=True,\n        )\n        self.execution.logs.add_stderr(message)\n        if self.handlers and self.handlers.on_stderr:\n            await self.handlers.on_stderr(message)\n\n    async def _handle_result(self, event_node: EventNode, timestamp: int) -> None:\n        result_text = event_node.results.get_text() if event_node.results else \"\"\n        result = ExecutionResult(\n            text=result_text,\n            timestamp=timestamp,\n        )\n        self.execution.add_result(result)\n        if self.handlers and self.handlers.on_result:\n            await self.handlers.on_result(result)\n\n    async def _handle_error(self, event_node: EventNode, timestamp: int) -> None:\n        if not event_node.error:\n            return\n\n        error_data = event_node.error\n        error = ExecutionError(\n            name=error_data.name or \"\",\n            value=error_data.value or \"\",\n            timestamp=timestamp,\n            traceback=error_data.traceback,\n        )\n        self.execution.error = error\n        if self.handlers and self.handlers.on_error:\n            await self.handlers.on_error(error)\n\n    async def _handle_execution_complete(self, event_node: EventNode, timestamp: int) -> None:\n        complete = ExecutionComplete(\n            timestamp=timestamp,\n            execution_time_in_millis=event_node.execution_time_in_millis or 0,\n        )\n        if self.handlers and self.handlers.on_execution_complete:\n            await self.handlers.on_execution_complete(complete)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/filesystem_model_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFilesystem model converter utilities.\n\nProvides conversion functions between API models and domain models for filesystem operations,\nsimilar to SandboxModelConverter.\n\nThis converter is designed to work with openapi-python-client generated models.\n\"\"\"\n\nfrom typing import Any\n\nfrom opensandbox.api.execd.models import FileInfo\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\n\n\nclass FilesystemModelConverter:\n    \"\"\"\n    Filesystem model converter utilities.\n\n    Provides static methods to convert between API models and domain models\n    for filesystem operations, following the pattern from SandboxModelConverter.\n    \"\"\"\n\n    @staticmethod\n    def to_entry_info(api_file_info: FileInfo) -> EntryInfo:\n        \"\"\"Convert API FileInfo to domain EntryInfo.\"\"\"\n        return EntryInfo(\n            path=api_file_info.path,\n            mode=api_file_info.mode,\n            owner=api_file_info.owner,\n            group=api_file_info.group,\n            size=api_file_info.size,\n            modified_at=api_file_info.modified_at,\n            created_at=api_file_info.created_at,\n        )\n\n    @staticmethod\n    def to_entry_info_list(api_file_infos: list[FileInfo]) -> list[EntryInfo]:\n        \"\"\"Convert list of API FileInfo to list of domain EntryInfo.\"\"\"\n        if not api_file_infos:\n            return []\n\n        return [FilesystemModelConverter.to_entry_info(item) for item in api_file_infos]\n\n    @staticmethod\n    def to_entry_info_map(api_response: Any) -> dict[str, EntryInfo]:\n        \"\"\"Convert API response to a map of path to EntryInfo.\"\"\"\n        if not api_response:\n            return {}\n\n        result: dict[str, EntryInfo] = {}\n\n        if hasattr(api_response, \"additional_properties\"):\n            for path, info_data in api_response.additional_properties.items():\n                if isinstance(info_data, FileInfo):\n                    result[path] = FilesystemModelConverter.to_entry_info(info_data)\n        elif isinstance(api_response, dict):\n            for path, info_data in api_response.items():\n                if isinstance(info_data, FileInfo):\n                    result[path] = FilesystemModelConverter.to_entry_info(info_data)\n\n        return result\n\n    @staticmethod\n    def to_api_make_dirs_body(entries: list[WriteEntry]):\n        \"\"\"Convert directory entries to MakeDirsBody.\"\"\"\n        from opensandbox.api.execd.models.make_dirs_body import MakeDirsBody\n\n        dirs_data = {\n            entry.path: {\n                \"mode\": entry.mode,\n                \"owner\": entry.owner,\n                \"group\": entry.group,\n            }\n            for entry in entries\n        }\n        return MakeDirsBody.from_dict(dirs_data)\n\n    @staticmethod\n    def to_api_chmod_files_body(entries: list[SetPermissionEntry]):\n        \"\"\"Convert permission entries to ChmodFilesBody.\"\"\"\n        from opensandbox.api.execd.models.chmod_files_body import ChmodFilesBody\n\n        permission_data = {\n            entry.path: {\n                \"mode\": entry.mode,\n                \"owner\": entry.owner,\n                \"group\": entry.group,\n            }\n            for entry in entries\n        }\n        return ChmodFilesBody.from_dict(permission_data)\n\n    @staticmethod\n    def to_api_replace_content_body(entries: list[ContentReplaceEntry]):\n        \"\"\"Convert content replacement entries to ReplaceContentBody.\"\"\"\n        from opensandbox.api.execd.models.replace_content_body import ReplaceContentBody\n\n        replace_data = {\n            entry.path: {\n                # Execd API expects keys \"old\" and \"new\" (see execd-api.yaml ReplaceFileContentItem).\n                \"old\": entry.old_content,\n                \"new\": entry.new_content,\n            }\n            for entry in entries\n        }\n        return ReplaceContentBody.from_dict(replace_data)\n\n    @staticmethod\n    def to_api_rename_file_items(entries: list[MoveEntry]):\n        \"\"\"Convert move entries to list of RenameFileItem.\"\"\"\n        from opensandbox.api.execd.models.rename_file_item import RenameFileItem\n\n        return [RenameFileItem(src=e.src, dest=e.dest) for e in entries]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/metrics_model_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nMetrics model converter utilities.\n\nProvides conversion functions between API models and domain models for metrics operations.\n\"\"\"\n\nfrom opensandbox.api.execd.models import Metrics\nfrom opensandbox.models.sandboxes import SandboxMetrics\n\n\nclass MetricsModelConverter:\n    \"\"\"\n    Metrics model converter utilities.\n\n    Provides static methods to convert between API models and domain models\n    for metrics operations.\n    \"\"\"\n\n    @staticmethod\n    def to_sandbox_metrics(api_metrics: Metrics) -> SandboxMetrics:\n        \"\"\"Convert API Metrics to domain SandboxMetrics.\"\"\"\n        return SandboxMetrics(\n            cpu_count=api_metrics.cpu_count,\n            cpu_used_percentage=api_metrics.cpu_used_pct,\n            memory_total_in_mib=api_metrics.mem_total_mib,\n            memory_used_in_mib=api_metrics.mem_used_mib,\n            timestamp=api_metrics.timestamp,\n        )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/response_handler.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nUnified response handler for API calls.\n\nProvides a centralized way to handle API responses, including:\n1. Status code validation\n2. Error response handling\n3. Unified exception conversion\n\nThis eliminates the need to repeat response handling logic in each adapter method.\n\"\"\"\n\nimport logging\nfrom http import HTTPStatus\nfrom typing import Any, TypeVar\n\nfrom opensandbox.exceptions import SandboxApiException\n\nlogger = logging.getLogger(__name__)\n\n\nT = TypeVar(\"T\")\n\n\ndef extract_request_id(headers: Any) -> str | None:\n    \"\"\"\n    Extract X-Request-ID from response headers in a case-insensitive way.\n    \"\"\"\n    if not headers:\n        return None\n    try:\n        # httpx.Headers supports case-insensitive lookup.\n        value = headers.get(\"X-Request-ID\") or headers.get(\"x-request-id\")\n        if isinstance(value, str):\n            value = value.strip()\n        return value or None\n    except Exception:\n        return None\n\n\ndef _status_code_to_int(status_code: Any) -> int:\n    \"\"\"\n    Normalize status_code from openapi-python-client responses to a plain int.\n\n    openapi-python-client may use http.HTTPStatus; some callers may already provide an int.\n    \"\"\"\n    if isinstance(status_code, HTTPStatus):\n        return int(status_code)\n    if isinstance(status_code, int):\n        return status_code\n    value = getattr(status_code, \"value\", None)\n    if isinstance(value, int):\n        return value\n    try:\n        return int(status_code)\n    except Exception:\n        return 0\n\n\ndef require_parsed(response_obj: Any, expected_type: type[T], operation_name: str) -> T:\n    \"\"\"\n    Validate and return the parsed payload from an openapi-python-client response.\n\n    Use this after `handle_api_error()` to enforce:\n    - parsed payload must exist\n    - parsed payload must match the expected type\n    \"\"\"\n    status_code = _status_code_to_int(getattr(response_obj, \"status_code\", 0))\n    request_id = extract_request_id(getattr(response_obj, \"headers\", None))\n\n    parsed = getattr(response_obj, \"parsed\", None)\n    if parsed is None:\n        raise SandboxApiException(\n            message=f\"{operation_name} failed: empty response\",\n            status_code=status_code,\n            request_id=request_id,\n        )\n    if not isinstance(parsed, expected_type):\n        raise SandboxApiException(\n            message=f\"{operation_name} failed: unexpected response type\",\n            status_code=status_code,\n            request_id=request_id,\n        )\n    return parsed\n\n\ndef handle_api_error(response_obj: Any, operation_name: str = \"API call\") -> None:\n    \"\"\"\n    Check API response for errors and raise exception if needed.\n\n    Call this before accessing response_obj.parsed to validate the response.\n\n    Args:\n        response_obj: The Response object from asyncio_detailed or sync_detailed\n        operation_name: Name of the operation for error messages\n\n    Raises:\n        SandboxApiException: If the response indicates an error\n    \"\"\"\n    status_code = _status_code_to_int(getattr(response_obj, \"status_code\", 0))\n    request_id = extract_request_id(getattr(response_obj, \"headers\", None))\n\n    logger.debug(f\"{operation_name} response: status={status_code}\")\n\n    if status_code >= 300:\n        error_message = f\"{operation_name} failed: HTTP {status_code}\"\n\n        if hasattr(response_obj, \"parsed\") and response_obj.parsed is not None:\n            if hasattr(response_obj.parsed, \"message\"):\n                error_message = (\n                    f\"{operation_name} failed: {response_obj.parsed.message}\"\n                )\n            elif hasattr(response_obj.parsed, \"code\"):\n                error_message = f\"{operation_name} failed: {response_obj.parsed.code}\"\n\n        raise SandboxApiException(\n            message=error_message,\n            status_code=status_code,\n            request_id=request_id,\n        )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox model converter utilities.\n\nProvides conversion functions between API models and domain models,\nsimilar to the Kotlin SDK SandboxModelConverter.\n\nThis converter is designed to work with openapi-python-client generated models,\nwhich use attrs for model definitions.\n\"\"\"\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Literal, cast\n\nfrom opensandbox.api.lifecycle.models import (\n    CreateSandboxResponse,\n    Endpoint,\n    ListSandboxesResponse,\n    RenewSandboxExpirationRequest,\n    RenewSandboxExpirationResponse,\n    Sandbox,\n)\nfrom opensandbox.api.lifecycle.models import (\n    PaginationInfo as ApiPaginationInfo,\n)\nfrom opensandbox.api.lifecycle.models import (\n    SandboxStatus as ApiSandboxStatus,\n)\nfrom opensandbox.api.lifecycle.models.create_sandbox_request import CreateSandboxRequest\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    NetworkRule,\n    PagedSandboxInfos,\n    PaginationInfo,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxRenewResponse,\n    SandboxStatus,\n    Volume,\n)\n\n\nclass SandboxModelConverter:\n    \"\"\"\n    Sandbox model converter utilities.\n\n    Provides static methods to convert between API models and domain models,\n    following the pattern from the Kotlin SDK.\n\n    The API models are generated by openapi-python-client and use attrs,\n    while domain models are standard Python dataclasses/classes.\n    \"\"\"\n\n    @staticmethod\n    def to_api_image_spec(spec: SandboxImageSpec) -> ImageSpec:\n        \"\"\"Convert domain SandboxImageSpec to API ImageSpec.\"\"\"\n        from opensandbox.api.lifecycle.models.image_spec import ImageSpec\n        from opensandbox.api.lifecycle.models.image_spec_auth import ImageSpecAuth\n        from opensandbox.api.lifecycle.types import UNSET\n\n        auth = UNSET\n        if spec.auth:\n            auth = ImageSpecAuth(\n                username=spec.auth.username,\n                password=spec.auth.password,\n            )\n\n        return ImageSpec(\n            uri=spec.image,  # API uses 'uri', domain uses 'image'\n            auth=auth,\n        )\n\n    @staticmethod\n    def to_api_volume(volume: Volume):\n        \"\"\"Convert domain Volume to API Volume.\"\"\"\n        from opensandbox.api.lifecycle.models.host import (\n            Host as ApiHost,\n        )\n        from opensandbox.api.lifecycle.models.ossfs import (\n            OSSFS as ApiOSSFS,\n        )\n        from opensandbox.api.lifecycle.models.ossfs_version import OSSFSVersion\n        from opensandbox.api.lifecycle.models.pvc import (\n            PVC as ApiPVC,\n        )\n        from opensandbox.api.lifecycle.models.volume import Volume as ApiVolume\n        from opensandbox.api.lifecycle.types import UNSET\n\n        api_host = UNSET\n        if volume.host is not None:\n            api_host = ApiHost(path=volume.host.path)\n\n        api_pvc = UNSET\n        if volume.pvc is not None:\n            api_pvc = ApiPVC(claim_name=volume.pvc.claim_name)\n\n        api_ossfs = UNSET\n        if volume.ossfs is not None and volume.ossfs.access_key_id is not None and volume.ossfs.access_key_secret is not None:\n            api_ossfs = ApiOSSFS(\n                bucket=volume.ossfs.bucket,\n                endpoint=volume.ossfs.endpoint,\n                access_key_id=volume.ossfs.access_key_id,\n                access_key_secret=volume.ossfs.access_key_secret,\n                version=OSSFSVersion(volume.ossfs.version),\n                options=volume.ossfs.options if volume.ossfs.options is not None else UNSET,\n            )\n\n        api_sub_path = UNSET\n        if volume.sub_path is not None:\n            api_sub_path = volume.sub_path\n\n        return ApiVolume(\n            name=volume.name,\n            mount_path=volume.mount_path,\n            read_only=volume.read_only,\n            host=api_host,\n            pvc=api_pvc,\n            ossfs=api_ossfs,\n            sub_path=api_sub_path,\n        )\n\n    @staticmethod\n    def to_api_create_sandbox_request(\n        spec: SandboxImageSpec,\n        entrypoint: list[str],\n        env: dict[str, str],\n        metadata: dict[str, str],\n        timeout: timedelta | None,\n        resource: dict[str, str],\n        network_policy: NetworkPolicy | None,\n        extensions: dict[str, str],\n        volumes: list[Volume] | None,\n    ) -> CreateSandboxRequest:\n        \"\"\"Convert domain parameters to API CreateSandboxRequest.\"\"\"\n        from opensandbox.api.lifecycle.models.create_sandbox_request import (\n            CreateSandboxRequest,\n        )\n        from opensandbox.api.lifecycle.models.create_sandbox_request_env import (\n            CreateSandboxRequestEnv,\n        )\n        from opensandbox.api.lifecycle.models.create_sandbox_request_extensions import (\n            CreateSandboxRequestExtensions,\n        )\n        from opensandbox.api.lifecycle.models.create_sandbox_request_metadata import (\n            CreateSandboxRequestMetadata,\n        )\n        from opensandbox.api.lifecycle.models.network_policy import (\n            NetworkPolicy as ApiNetworkPolicy,\n        )\n        from opensandbox.api.lifecycle.models.network_policy_default_action import (\n            NetworkPolicyDefaultAction,\n        )\n        from opensandbox.api.lifecycle.models.network_rule import (\n            NetworkRule as ApiNetworkRule,\n        )\n        from opensandbox.api.lifecycle.models.network_rule_action import (\n            NetworkRuleAction,\n        )\n        from opensandbox.api.lifecycle.models.resource_limits import ResourceLimits\n        from opensandbox.api.lifecycle.types import UNSET\n\n        # Convert env dict to API model\n        api_env = UNSET\n        if env:\n            api_env = CreateSandboxRequestEnv.from_dict(env)\n\n        # Convert metadata dict to API model\n        api_metadata = UNSET\n        if metadata:\n            api_metadata = CreateSandboxRequestMetadata.from_dict(metadata)\n\n        # Convert resource limits dict to API model\n        api_resource_limits = ResourceLimits.from_dict(resource)\n\n        api_network_policy = UNSET\n        if network_policy is not None:\n            if not isinstance(network_policy, NetworkPolicy):\n                raise TypeError(\n                    \"network_policy must be a NetworkPolicy or None, \"\n                    f\"got {type(network_policy).__name__}\"\n                )\n            api_default_action = UNSET\n            if network_policy.default_action:\n                api_default_action = NetworkPolicyDefaultAction(\n                    network_policy.default_action\n                )\n\n            api_egress = UNSET\n            if network_policy.egress is not None:\n                api_egress = [\n                    ApiNetworkRule(\n                        action=NetworkRuleAction(rule.action),\n                        target=rule.target,\n                    )\n                    for rule in network_policy.egress\n                ]\n\n            api_network_policy = ApiNetworkPolicy(\n                default_action=api_default_action,\n                egress=api_egress,\n            )\n\n        api_extensions = (\n            CreateSandboxRequestExtensions.from_dict(extensions) if extensions else UNSET\n        )\n\n        # Convert volumes to API model\n        api_volumes = UNSET\n        if volumes is not None and len(volumes) > 0:\n            api_volumes = [\n                SandboxModelConverter.to_api_volume(v) for v in volumes\n            ]\n\n        request = CreateSandboxRequest(\n            image=SandboxModelConverter.to_api_image_spec(spec),\n            entrypoint=entrypoint,\n            env=api_env,\n            metadata=api_metadata,\n            resource_limits=api_resource_limits,\n            network_policy=api_network_policy,\n            extensions=api_extensions,\n            volumes=api_volumes,\n        )\n        if timeout is not None:\n            request.timeout = int(timeout.total_seconds())\n        return request\n\n    @staticmethod\n    def to_api_renew_request(\n        new_expiration_time: datetime,\n    ) -> RenewSandboxExpirationRequest:\n        \"\"\"Convert datetime to API renew request.\"\"\"\n        from opensandbox.api.lifecycle.models.renew_sandbox_expiration_request import (\n            RenewSandboxExpirationRequest,\n        )\n\n        # Ensure timezone-aware datetime for unambiguous serialization.\n        # If a naive datetime is provided, treat it as UTC.\n        if new_expiration_time.tzinfo is None:\n            new_expiration_time = new_expiration_time.replace(tzinfo=timezone.utc)\n\n        return RenewSandboxExpirationRequest(\n            expires_at=new_expiration_time,\n        )\n\n    @staticmethod\n    def to_api_network_rules(rules: list[NetworkRule]):\n        \"\"\"Convert domain NetworkRule list to API NetworkRule list.\"\"\"\n        from opensandbox.api.lifecycle.models.network_rule import (\n            NetworkRule as ApiNetworkRule,\n        )\n        from opensandbox.api.lifecycle.models.network_rule_action import (\n            NetworkRuleAction,\n        )\n\n        return [\n            ApiNetworkRule(\n                action=NetworkRuleAction(rule.action),\n                target=rule.target,\n            )\n            for rule in rules\n        ]\n\n    @staticmethod\n    def to_sandbox_network_policy(api_policy):\n        \"\"\"Convert API NetworkPolicy to domain NetworkPolicy.\"\"\"\n        from opensandbox.api.lifecycle.models.network_policy import (\n            NetworkPolicy as ApiNetworkPolicy,\n        )\n        from opensandbox.api.lifecycle.types import Unset\n\n        if not isinstance(api_policy, ApiNetworkPolicy):\n            raise TypeError(f\"Expected NetworkPolicy, got {type(api_policy).__name__}\")\n\n        default_action: str | None = \"deny\"\n        if not isinstance(api_policy.default_action, Unset):\n            default_action = str(api_policy.default_action.value)\n\n        egress: list[NetworkRule] | None = None\n        if not isinstance(api_policy.egress, Unset):\n            egress = [\n                NetworkRule(\n                    action=cast(Literal[\"allow\", \"deny\"], rule.action.value),\n                    target=rule.target,\n                )\n                for rule in api_policy.egress\n            ]\n\n        return NetworkPolicy.model_validate(\n            {\n                \"defaultAction\": default_action,\n                \"egress\": egress,\n            }\n        )\n\n    @staticmethod\n    def to_sandbox_renew_response(\n        api_response: RenewSandboxExpirationResponse,\n    ) -> SandboxRenewResponse:\n        \"\"\"\n        Convert API RenewSandboxExpirationResponse to domain SandboxRenewResponse.\n\n        Note: We intentionally keep the public SDK surface using domain models instead of the\n        generated OpenAPI client models.\n        \"\"\"\n\n        if not isinstance(api_response, RenewSandboxExpirationResponse):\n            raise TypeError(\n                f\"Expected RenewSandboxExpirationResponse, got {type(api_response).__name__}\"\n            )\n\n        return SandboxRenewResponse(expires_at=api_response.expires_at)\n\n    @staticmethod\n    def to_sandbox_create_response(\n        api_response: CreateSandboxResponse,\n    ) -> SandboxCreateResponse:\n        \"\"\"Convert API CreateSandboxResponse to domain SandboxCreateResponse.\"\"\"\n        from opensandbox.models.sandboxes import SandboxCreateResponse\n\n        return SandboxCreateResponse(\n            id=str(api_response.id)\n        )\n\n    @staticmethod\n    def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo:\n        \"\"\"Convert API Sandbox to domain SandboxInfo.\"\"\"\n        from opensandbox.api.lifecycle.types import Unset\n        from opensandbox.models.sandboxes import (\n            SandboxImageAuth,\n            SandboxImageSpec,\n            SandboxInfo,\n        )\n\n        domain_image_spec = None\n        if hasattr(api_sandbox, \"image\") and not isinstance(api_sandbox.image, Unset):\n            auth = None\n            if hasattr(api_sandbox.image, \"auth\") and not isinstance(\n                api_sandbox.image.auth, Unset\n            ):\n                auth_obj = api_sandbox.image.auth\n                username_val = getattr(auth_obj, \"username\", None)\n                password_val = getattr(auth_obj, \"password\", None)\n                if isinstance(username_val, str) and isinstance(password_val, str):\n                    auth = SandboxImageAuth(username=username_val, password=password_val)\n            domain_image_spec = SandboxImageSpec(\n                image=api_sandbox.image.uri,\n                auth=auth,\n            )\n\n        metadata: dict[str, str] = {}\n        if hasattr(api_sandbox, \"metadata\") and not isinstance(api_sandbox.metadata, Unset):\n            metadata_obj = api_sandbox.metadata\n            if hasattr(metadata_obj, \"additional_properties\") and not isinstance(\n                getattr(metadata_obj, \"additional_properties\", None), Unset\n            ):\n                props = metadata_obj.additional_properties\n                if isinstance(props, dict):\n                    metadata = dict(props)\n            elif isinstance(metadata_obj, dict):\n                metadata = metadata_obj\n\n        expires_at = api_sandbox.expires_at\n        if isinstance(expires_at, Unset):\n            expires_at = None\n\n        return SandboxInfo(\n            id=api_sandbox.id,\n            status=SandboxModelConverter._convert_sandbox_status(api_sandbox.status),\n            image=domain_image_spec,\n            created_at=api_sandbox.created_at,\n            expires_at=expires_at,\n            entrypoint=api_sandbox.entrypoint,\n            metadata=metadata,\n        )\n\n    @staticmethod\n    def to_paged_sandbox_infos(\n        api_response: ListSandboxesResponse,\n    ) -> PagedSandboxInfos:\n        \"\"\"Convert API ListSandboxesResponse to domain PagedSandboxInfos.\"\"\"\n        from opensandbox.models.sandboxes import PagedSandboxInfos\n\n        items = api_response.items if hasattr(api_response, \"items\") else []\n\n        return PagedSandboxInfos(\n            sandbox_infos=[SandboxModelConverter.to_sandbox_info(s) for s in items],\n            pagination=SandboxModelConverter._convert_pagination_info(\n                api_response.pagination\n            ),\n        )\n\n    @staticmethod\n    def to_sandbox_endpoint(api_endpoint: Endpoint) -> SandboxEndpoint:\n        \"\"\"Convert API Endpoint to domain SandboxEndpoint.\"\"\"\n        from opensandbox.api.lifecycle.types import Unset\n        from opensandbox.models.sandboxes import SandboxEndpoint\n\n        headers: dict[str, str] = {}\n        if not isinstance(api_endpoint.headers, Unset):\n            headers = dict(api_endpoint.headers.additional_properties)\n        return SandboxEndpoint(\n            endpoint=api_endpoint.endpoint,\n            headers=headers,\n        )\n\n    @staticmethod\n    def _convert_sandbox_status(\n        api_status: ApiSandboxStatus | None,\n    ) -> SandboxStatus:\n        \"\"\"Convert API SandboxStatus to domain SandboxStatus.\"\"\"\n        from datetime import datetime\n\n        from opensandbox.api.lifecycle.types import Unset\n        from opensandbox.models.sandboxes import SandboxStatus\n\n        if api_status is None:\n            return SandboxStatus(\n                state=\"Unknown\",\n                reason=None,\n                message=None,\n                last_transition_at=None,\n            )\n\n        reason: str | None = None\n        if hasattr(api_status, \"reason\"):\n            reason_val = api_status.reason\n            if isinstance(reason_val, str):\n                reason = reason_val\n\n        message: str | None = None\n        if hasattr(api_status, \"message\"):\n            message_val = api_status.message\n            if isinstance(message_val, str):\n                message = message_val\n\n        last_transition_at: datetime | None = None\n        if hasattr(api_status, \"last_transition_at\"):\n            lta_val = api_status.last_transition_at\n            if isinstance(lta_val, datetime):\n                last_transition_at = lta_val\n            elif isinstance(lta_val, Unset) or lta_val is None:\n                last_transition_at = None\n\n        return SandboxStatus(\n            state=api_status.state,\n            reason=reason,\n            message=message,\n            last_transition_at=last_transition_at,\n        )\n\n    @staticmethod\n    def _convert_pagination_info(\n        api_pagination: ApiPaginationInfo | None,\n    ) -> PaginationInfo:\n        \"\"\"Convert API PaginationInfo to domain PaginationInfo.\"\"\"\n        from opensandbox.models.sandboxes import PaginationInfo\n\n        if api_pagination is None:\n            return PaginationInfo(\n                page=1,\n                page_size=10,\n                total_pages=0,\n                total_items=0,\n                has_next_page=False,\n            )\n\n        return PaginationInfo(\n            page=api_pagination.page or 1,\n            page_size=api_pagination.page_size or 10,\n            total_pages=api_pagination.total_pages or 0,\n            total_items=api_pagination.total_items or 0,\n            has_next_page=api_pagination.has_next_page or False,\n        )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nDirect egress sidecar adapter implementation.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import ExceptionConverter\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule, SandboxEndpoint\nfrom opensandbox.services.egress import Egress\n\nlogger = logging.getLogger(__name__)\n\n\nclass EgressAdapter(Egress):\n    \"\"\"Direct egress sidecar adapter using the generated egress client.\"\"\"\n\n    def __init__(self, connection_config: ConnectionConfig, endpoint: SandboxEndpoint) -> None:\n        self.connection_config = connection_config\n        self.endpoint = endpoint\n        from opensandbox.api.egress import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.endpoint.headers,\n        }\n\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n    async def get_policy(self) -> NetworkPolicy:\n        try:\n            from opensandbox.api.egress.api.policy import get_policy\n            from opensandbox.api.egress.models.network_policy import (\n                NetworkPolicy as ApiNetworkPolicy,\n            )\n            from opensandbox.api.egress.models.policy_status_response import (\n                PolicyStatusResponse,\n            )\n            from opensandbox.api.egress.types import Unset\n\n            response_obj = await get_policy.asyncio_detailed(client=self._client)\n            handle_api_error(response_obj, \"Get egress policy\")\n            parsed = require_parsed(response_obj, PolicyStatusResponse, \"Get egress policy\")\n            policy = parsed.policy\n            if isinstance(policy, Unset):\n                raise ValueError(\"Egress policy response missing policy payload\")\n            if not isinstance(policy, ApiNetworkPolicy):\n                raise TypeError(f\"Expected NetworkPolicy, got {type(policy).__name__}\")\n            return NetworkPolicy.model_validate(policy.to_dict())\n        except Exception as e:\n            logger.error(\"Failed to get egress policy from endpoint %s\", self.endpoint.endpoint, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def patch_rules(self, rules: list[NetworkRule]) -> None:\n        try:\n            from opensandbox.api.egress.api.policy import patch_policy\n            from opensandbox.api.egress.models.network_rule import (\n                NetworkRule as ApiNetworkRule,\n            )\n            from opensandbox.api.egress.models.network_rule_action import (\n                NetworkRuleAction,\n            )\n\n            response_obj = await patch_policy.asyncio_detailed(\n                client=self._client,\n                body=[\n                    ApiNetworkRule(\n                        action=NetworkRuleAction(rule.action),\n                        target=rule.target,\n                    )\n                    for rule in rules\n                ],\n            )\n            handle_api_error(response_obj, \"Patch egress rules\")\n        except Exception as e:\n            logger.error(\"Failed to patch egress policy via endpoint %s\", self.endpoint.endpoint, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/factory.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nService factory for creating adapter instances.\n\nFactory for creating service adapter instances that provide access to\nsandbox operations including command execution, file system management,\nhealth monitoring, and metrics collection.\n\nAll HTTP clients created by adapters share the same `ConnectionConfig.transport`\nto ensure consistent pooling/proxy/retry behavior across services.\n\"\"\"\n\nfrom opensandbox.adapters.command_adapter import CommandsAdapter\nfrom opensandbox.adapters.egress_adapter import EgressAdapter\nfrom opensandbox.adapters.filesystem_adapter import FilesystemAdapter\nfrom opensandbox.adapters.health_adapter import HealthAdapter\nfrom opensandbox.adapters.metrics_adapter import MetricsAdapter\nfrom opensandbox.adapters.sandboxes_adapter import SandboxesAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.services.command import Commands\nfrom opensandbox.services.egress import Egress\nfrom opensandbox.services.filesystem import Filesystem\nfrom opensandbox.services.health import Health\nfrom opensandbox.services.metrics import Metrics\nfrom opensandbox.services.sandbox import Sandboxes\n\n\nclass AdapterFactory:\n    \"\"\"\n    Factory responsible for creating service instances.\n\n    This factory encapsulates the instantiation logic of specific service adapters.\n    Each adapter creates its own httpx clients, but they all share the same transport\n    instance coming from the provided ConnectionConfig.\n\n    Usage:\n        config = ConnectionConfig(...)\n        factory = AdapterFactory(config)\n    \"\"\"\n\n    def __init__(self, connection_config: ConnectionConfig) -> None:\n        \"\"\"\n        Initialize the service factory.\n\n        Args:\n            connection_config: Shared connection configuration, including transport.\n        \"\"\"\n        self.connection_config = connection_config\n\n    def create_sandbox_service(self) -> Sandboxes:\n        \"\"\"Create a sandbox management service for lifecycle operations.\n\n        Returns:\n            Service for creating, managing, and monitoring sandbox instances\n        \"\"\"\n        return SandboxesAdapter(self.connection_config)\n\n    def create_filesystem_service(self, endpoint: SandboxEndpoint) -> Filesystem:\n        \"\"\"Create a filesystem service for file and directory operations.\n\n        Args:\n            endpoint: Sandbox endpoint information for file operations\n\n        Returns:\n            Service for file system management within the sandbox\n        \"\"\"\n        return FilesystemAdapter(self.connection_config, endpoint)\n\n    def create_command_service(self, endpoint: SandboxEndpoint) -> Commands:\n        \"\"\"Create a command execution service for running shell commands.\n\n        Args:\n            endpoint: Sandbox endpoint information for command execution\n\n        Returns:\n            Service for executing commands within the sandbox\n        \"\"\"\n        return CommandsAdapter(self.connection_config, endpoint)\n\n    def create_egress_service(self, endpoint: SandboxEndpoint) -> Egress:\n        \"\"\"Create a direct egress service for runtime egress policy operations.\"\"\"\n        return EgressAdapter(self.connection_config, endpoint)\n\n    def create_health_service(self, endpoint: SandboxEndpoint) -> Health:\n        \"\"\"Create a health monitoring service for sandbox status checks.\n\n        Args:\n            endpoint: Sandbox endpoint information for health checks\n\n        Returns:\n            Service for monitoring sandbox health and availability\n        \"\"\"\n        return HealthAdapter(self.connection_config, endpoint)\n\n    def create_metrics_service(self, endpoint: SandboxEndpoint) -> Metrics:\n        \"\"\"Create a metrics collection service for resource monitoring.\n\n        Args:\n            endpoint: Sandbox endpoint information for metrics collection\n\n        Returns:\n            Service for collecting sandbox resource usage metrics\n        \"\"\"\n        return MetricsAdapter(self.connection_config, endpoint)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFilesystem service adapter implementation.\n\nImplementation of FilesystemService that adapts openapi-python-client generated FilesystemApi.\nThis adapter handles file operations within sandboxes using the auto-generated API client.\n\"\"\"\n\nimport json\nimport logging\nfrom collections.abc import AsyncIterator\nfrom io import IOBase, TextIOBase\nfrom typing import TypedDict\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.filesystem_model_converter import (\n    FilesystemModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n)\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.services.filesystem import Filesystem\n\nlogger = logging.getLogger(__name__)\n\nclass _DownloadRequest(TypedDict):\n    url: str\n    params: dict[str, str] | None\n    headers: dict[str, str]\n\n\nclass FilesystemAdapter(Filesystem):\n    \"\"\"\n    Implementation of FilesystemService that provides comprehensive file system operations.\n\n    This adapter handles file operations within sandboxes using optimized approaches\n    for different operation types - API calls for standard operations and direct HTTP\n    for file upload/download operations requiring special handling.\n\n    All HTTP clients created by this adapter share `ConnectionConfig.transport`.\n    \"\"\"\n\n    FILESYSTEM_UPLOAD_PATH = \"/files/upload\"\n    FILESYSTEM_DOWNLOAD_PATH = \"/files/download\"\n\n    def __init__(\n        self, connection_config: ConnectionConfig, execd_endpoint: SandboxEndpoint\n    ) -> None:\n        \"\"\"\n        Initialize the filesystem service adapter.\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n            execd_endpoint: Execd endpoint information for direct HTTP calls\n        \"\"\"\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        base_url = self._get_execd_base_url()\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n\n        # Execd API does not require authentication\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n    def _get_execd_base_url(self) -> str:\n        protocol = self.connection_config.protocol\n        return f\"{protocol}://{self.execd_endpoint.endpoint}\"\n\n    async def _get_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Return adapter-owned httpx client for execd (no auth required).\"\"\"\n        return self._httpx_client\n\n    async def _get_client(self):\n        \"\"\"Return the client for execd API (no auth required).\"\"\"\n        return self._client\n\n    def _get_execd_url(self, path: str) -> str:\n        \"\"\"Build URL for execd endpoint.\"\"\"\n        protocol = self.connection_config.protocol\n        return f\"{protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    async def read_file(\n        self,\n        path: str,\n        *,\n        encoding: str = \"utf-8\",\n        range_header: str | None = None,\n    ) -> str:\n        \"\"\"Read file content as string via HTTP API.\"\"\"\n        content = await self.read_bytes(path, range_header=range_header)\n        return content.decode(encoding)\n\n    async def read_bytes(\n        self,\n        path: str,\n        *,\n        range_header: str | None = None,\n    ) -> bytes:\n        \"\"\"Read file content as bytes with support for range requests.\n\n        Args:\n            path: Path to the file to read\n            range_header: Optional range header for partial content requests\n\n        Returns:\n            File content as bytes\n\n        Raises:\n            SandboxApiException: If the read operation fails\n        \"\"\"\n        logger.debug(f\"Reading file as bytes: {path}\")\n        try:\n            request_data = self._build_download_request(path, range_header)\n            client = await self._get_httpx_client()\n\n            if request_data[\"params\"] is None:\n                response = await client.get(\n                    request_data[\"url\"],\n                    headers=request_data[\"headers\"],\n                )\n            else:\n                response = await client.get(\n                    request_data[\"url\"],\n                    headers=request_data[\"headers\"],\n                    params=request_data[\"params\"],\n                )\n            response.raise_for_status()\n            return response.content\n        except Exception as e:\n            logger.error(f\"Failed to read file {path}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def read_bytes_stream(\n            self,\n            path: str,\n            *,\n            chunk_size: int = 64 * 1024,\n            range_header: str | None = None,\n    ) -> AsyncIterator[bytes]:\n        \"\"\"Stream file content as bytes chunks via HTTP (true streaming).\"\"\"\n        logger.debug(f\"Streaming file as bytes: {path} (chunk_size={chunk_size})\")\n        try:\n            request_data = self._build_download_request(path, range_header)\n            client = await self._get_httpx_client()\n\n            url = request_data[\"url\"]\n            params = request_data[\"params\"]\n            headers = request_data[\"headers\"]\n\n            if params is None:\n                request = client.build_request(\"GET\", url, headers=headers)\n            else:\n                request = client.build_request(\n                    \"GET\",\n                    url,\n                    headers=headers,\n                    params=params,\n                )\n\n            response = await client.send(request, stream=True)\n\n            if response.status_code >= 300:\n                try:\n                    await response.aread()\n                finally:\n                    await response.aclose()\n\n                raise SandboxApiException(\n                    f\"Failed to stream file {path}: {response.status_code}\",\n                    status_code=response.status_code,\n                    request_id=extract_request_id(response.headers),\n                )\n            return response.aiter_bytes(chunk_size=chunk_size)\n        except Exception as e:\n            logger.error(f\"Failed to stream file {path}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def write_files(self, entries: list[WriteEntry]) -> None:\n        \"\"\"Write multiple files in a single operation using multipart upload.\n\n        Aligned with Kotlin SDK implementation.\n        \"\"\"\n        if not entries:\n            return\n\n        logger.debug(f\"Writing {len(entries)} files\")\n\n        try:\n            client = await self._get_httpx_client()\n            multipart_parts = []\n\n            for entry in entries:\n                if not entry.path:\n                    raise InvalidArgumentException(\"File path cannot be null\")\n                if entry.data is None:\n                    raise InvalidArgumentException(\"File data cannot be null\")\n\n                metadata = {\n                    \"path\": entry.path,\n                    \"owner\": entry.owner,\n                    \"group\": entry.group,\n                    \"mode\": entry.mode,\n                }\n                metadata_json = json.dumps(metadata)\n\n                multipart_parts.append(\n                    (\"metadata\", (\"metadata\", metadata_json, \"application/json\"))\n                )\n\n                content: bytes | str | IOBase\n                content_type: str\n\n                if isinstance(entry.data, bytes):\n                    content = entry.data\n                    content_type = \"application/octet-stream\"\n\n                elif isinstance(entry.data, str):\n                    encoding = entry.encoding or \"utf-8\"\n                    content = entry.data\n                    content_type = f\"text/plain; charset={encoding}\"\n\n                elif isinstance(entry.data, IOBase):\n                    if isinstance(entry.data, TextIOBase):\n                        raise InvalidArgumentException(\n                            \"File stream must be binary (opened with 'rb'). Text streams are not supported.\"\n                        )\n                    else:\n                        content = entry.data\n                        content_type = \"application/octet-stream\"\n                else:\n                    raise InvalidArgumentException(\n                        f\"Unsupported file data type: {type(entry.data)}\"\n                    )\n                multipart_parts.append((\"file\", (entry.path, content, content_type)))\n\n            url = self._get_execd_url(self.FILESYSTEM_UPLOAD_PATH)\n            response = await client.post(url, files=multipart_parts)\n            response.raise_for_status()\n        except Exception as e:\n            logger.error(f\"Failed to write {len(entries)} files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def write_file(\n        self,\n        path: str,\n        data: str | bytes | IOBase,\n        *,\n        encoding: str = \"utf-8\",\n        mode: int = 755,\n        owner: str | None = None,\n        group: str | None = None,\n    ) -> None:\n        \"\"\"Write single file (convenience method).\"\"\"\n        entry = WriteEntry(\n            path=path,\n            data=data,\n            mode=mode,\n            owner=owner,\n            group=group,\n            encoding=encoding,\n        )\n        await self.write_files([entry])\n\n    async def create_directories(self, entries: list[WriteEntry]) -> None:\n        \"\"\"Create multiple directories with specified permissions.\n\n        Args:\n            entries: List of directory entries with paths and permissions\n\n        Raises:\n            SandboxException: If directory creation fails\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import make_dirs\n\n            client = await self._get_client()\n            response_obj = await make_dirs.asyncio_detailed(\n                client=client,\n                body=FilesystemModelConverter.to_api_make_dirs_body(entries),\n            )\n\n            handle_api_error(response_obj, \"Create directories\")\n\n        except Exception as e:\n            logger.error(\"Failed to create directories\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def delete_files(self, paths: list[str]) -> None:\n        \"\"\"Delete files using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import remove_files\n\n            client = await self._get_client()\n            response_obj = await remove_files.asyncio_detailed(\n                client=client,\n                path=paths,\n            )\n\n            handle_api_error(response_obj, \"Delete files\")\n\n        except Exception as e:\n            logger.error(f\"Failed to delete {len(paths)} files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def delete_directories(self, paths: list[str]) -> None:\n        \"\"\"Delete directories using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import remove_dirs\n\n            client = await self._get_client()\n            response_obj = await remove_dirs.asyncio_detailed(\n                client=client,\n                path=paths,\n            )\n\n            handle_api_error(response_obj, \"Delete directories\")\n\n        except Exception as e:\n            logger.error(f\"Failed to delete {len(paths)} directories\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def move_files(self, entries: list[MoveEntry]) -> None:\n        \"\"\"Move or rename multiple files and directories.\n\n        Args:\n            entries: List of move operations with source and destination paths\n\n        Raises:\n            SandboxException: If move operations fail\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import rename_files\n            rename_items = FilesystemModelConverter.to_api_rename_file_items(entries)\n\n            client = await self._get_client()\n            response_obj = await rename_files.asyncio_detailed(\n                client=client,\n                body=rename_items,\n            )\n\n            handle_api_error(response_obj, \"Move files\")\n\n        except Exception as e:\n            logger.error(\"Failed to move files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def set_permissions(self, entries: list[SetPermissionEntry]) -> None:\n        \"\"\"Set file permissions using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import chmod_files\n\n            client = await self._get_client()\n            response_obj = await chmod_files.asyncio_detailed(\n                client=client,\n                body=FilesystemModelConverter.to_api_chmod_files_body(entries),\n            )\n\n            handle_api_error(response_obj, \"Set permissions\")\n\n        except Exception as e:\n            logger.error(\"Failed to set permissions\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def replace_contents(self, entries: list[ContentReplaceEntry]) -> None:\n        \"\"\"Replace file contents using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import replace_content\n\n            client = await self._get_client()\n            response_obj = await replace_content.asyncio_detailed(\n                client=client,\n                body=FilesystemModelConverter.to_api_replace_content_body(entries),\n            )\n\n            handle_api_error(response_obj, \"Replace contents\")\n\n        except Exception as e:\n            logger.error(\"Failed to replace contents\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def search(self, entry: SearchEntry) -> list[EntryInfo]:\n        \"\"\"Search files using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import search_files\n            from opensandbox.api.execd.models import FileInfo\n\n            client = await self._get_client()\n            response_obj = await search_files.asyncio_detailed(\n                client=client,\n                path=entry.path,\n                pattern=entry.pattern,\n            )\n\n            handle_api_error(response_obj, \"Search files\")\n\n            parsed = response_obj.parsed\n            if not parsed:\n                return []\n\n            if isinstance(parsed, list) and all(isinstance(x, FileInfo) for x in parsed):\n                return FilesystemModelConverter.to_entry_info_list(parsed)\n            raise SandboxApiException(\n                message=\"Search files failed: unexpected response type\",\n                request_id=extract_request_id(getattr(response_obj, \"headers\", None)),\n            )\n\n        except Exception as e:\n            logger.error(\"Failed to search files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_file_info(self, paths: list[str]) -> dict[str, EntryInfo]:\n        \"\"\"Get file information using auto-generated API.\"\"\"\n        try:\n            from opensandbox.api.execd.api.filesystem import get_files_info\n\n            client = await self._get_client()\n            response_obj = await get_files_info.asyncio_detailed(\n                client=client,\n                path=paths,\n            )\n\n            handle_api_error(response_obj, \"Get file info\")\n\n            if not response_obj.parsed:\n                return {}\n\n            return FilesystemModelConverter.to_entry_info_map(response_obj.parsed)\n\n        except Exception as e:\n            logger.error(f\"Failed to get file info for {len(paths)} paths\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def _build_download_request(\n            self, path: str, range_header: str | None = None\n    ) -> _DownloadRequest:\n        \"\"\"Build HTTP request for file download operations.\n\n        Args:\n            path: File path to download\n            range_header: Optional range header for partial downloads\n\n        Returns:\n            Dictionary containing URL, parameters, and headers for the request\n        \"\"\"\n        encoded_path = quote(path, safe=\"/\")\n        url = f\"{self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)}?path={encoded_path}\"\n        headers: dict[str, str] = {}\n\n        if range_header:\n            headers[\"Range\"] = range_header\n\n        return {\n            \"url\": url,\n            \"params\": None,\n            \"headers\": headers,\n        }\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/health_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nHealth service adapter implementation.\n\nImplementation of HealthService that adapts openapi-python-client generated HealthApi.\nThis adapter provides health check functionality for sandboxes.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.services.health import Health\n\nlogger = logging.getLogger(__name__)\n\n\nclass HealthAdapter(Health):\n    \"\"\"\n    Implementation of HealthService for sandbox health monitoring.\n\n    This adapter provides health check functionality to verify sandbox\n    availability and responsiveness using the openapi-python-client\n    generated API client.\n    \"\"\"\n\n    def __init__(\n        self,\n        connection_config: ConnectionConfig,\n        execd_endpoint: SandboxEndpoint,\n    ) -> None:\n        \"\"\"\n        Initialize the health service adapter.\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n            execd_endpoint: Endpoint for execd service\n        \"\"\"\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        protocol = self.connection_config.protocol\n        base_url = f\"{protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        # Execd API does not require authentication\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n    async def _get_client(self):\n        \"\"\"Return the client for execd API (no auth required).\"\"\"\n        return self._client\n\n    async def ping(self, sandbox_id: str) -> bool:\n        \"\"\"Check if a sandbox is alive and responsive.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox to check\n\n        Returns:\n            True if the sandbox is healthy and responsive, False otherwise\n        \"\"\"\n        try:\n            from opensandbox.adapters.converter.response_handler import (\n                handle_api_error,\n            )\n            from opensandbox.api.execd.api.health import ping\n\n            client = await self._get_client()\n            response_obj = await ping.asyncio_detailed(client=client)\n\n            handle_api_error(response_obj, \"Ping\")\n            return True\n\n        except Exception as e:\n            logger.debug(f\"Health check failed for sandbox {sandbox_id}: {e}\")\n            return False\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/metrics_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nMetrics service adapter implementation.\n\nImplementation of MetricsService that adapts openapi-python-client generated MetricApi.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.metrics_model_converter import (\n    MetricsModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint, SandboxMetrics\nfrom opensandbox.services.metrics import Metrics\n\nlogger = logging.getLogger(__name__)\n\n\nclass MetricsAdapter(Metrics):\n    \"\"\"\n    Implementation of MetricsService for sandbox resource monitoring.\n\n    This adapter provides comprehensive metrics collection and monitoring capabilities\n    for sandbox environments, including CPU usage, memory consumption, and other\n    performance metrics using the openapi-python-client generated API client.\n    \"\"\"\n\n    def __init__(\n        self,\n        connection_config: ConnectionConfig,\n        execd_endpoint: SandboxEndpoint,\n    ) -> None:\n        \"\"\"\n        Initialize the metrics service adapter.\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n            execd_endpoint: Endpoint for execd service\n        \"\"\"\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        protocol = self.connection_config.protocol\n        base_url = f\"{protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        # Execd API does not require authentication\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n\n        self._httpx_client = httpx.AsyncClient(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n    async def _get_client(self):\n        \"\"\"Return the client for execd API (no auth required).\"\"\"\n        return self._client\n\n    async def get_metrics(self, sandbox_id: str) -> SandboxMetrics:\n        \"\"\"Retrieve current resource usage metrics for a sandbox.\n\n        Args:\n            sandbox_id: The unique identifier of the sandbox\n\n        Returns:\n            Current metrics including CPU usage, memory consumption, and timestamp\n\n        Raises:\n            SandboxException: If metrics retrieval fails\n        \"\"\"\n        logger.debug(f\"Retrieving sandbox metrics for {sandbox_id}\")\n\n        try:\n            from opensandbox.api.execd.api.metric import get_metrics\n\n            client = await self._get_client()\n            response_obj = await get_metrics.asyncio_detailed(client=client)\n\n            handle_api_error(response_obj, \"Get metrics\")\n            from opensandbox.api.execd.models import Metrics\n            parsed = require_parsed(response_obj, Metrics, \"Get metrics\")\n            return MetricsModelConverter.to_sandbox_metrics(parsed)\n\n        except Exception as e:\n            logger.error(f\"Failed to get metrics for sandbox {sandbox_id}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/adapters/sandboxes_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox service adapter implementation.\n\nImplementation of SandboxService that adapts openapi-python-client generated API.\nThis adapter provides a clean abstraction layer between business logic and\nthe auto-generated API client, handling all model conversions and error mapping.\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta\n\nimport httpx  # type: ignore[reportMissingImports]\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.adapters.converter.sandbox_model_converter import (\n    SandboxModelConverter,\n)\nfrom opensandbox.api.lifecycle.types import UNSET\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    PagedSandboxInfos,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxRenewResponse,\n    Volume,\n)\nfrom opensandbox.services.sandbox import Sandboxes\n\nlogger = logging.getLogger(__name__)\n\n\nclass SandboxesAdapter(Sandboxes):\n    \"\"\"\n    Implementation of SandboxService that adapts openapi-python-client generated API.\n\n    This adapter provides a clean abstraction layer between business logic and\n    the sandbox management API, handling all model conversions and error mapping.\n\n    The openapi-python-client generates functional APIs that support custom\n    httpx.AsyncClient injection, allowing for fine-grained control over HTTP behavior.\n    \"\"\"\n\n    def __init__(self, connection_config: ConnectionConfig) -> None:\n        \"\"\"\n        Initialize the sandbox service adapter.\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n        \"\"\"\n        self.connection_config = connection_config\n        from opensandbox.api.lifecycle import AuthenticatedClient\n\n        api_key = self.connection_config.get_api_key()\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n        }\n        if api_key:\n            headers[\"OPEN-SANDBOX-API-KEY\"] = api_key\n\n        # Create client with custom auth header for OpenSandbox API\n        self._client = AuthenticatedClient(\n            base_url=self.connection_config.get_base_url(),\n            token=api_key or \"\",\n            prefix=\"\",  # No prefix, just the token\n            auth_header_name=\"OPEN-SANDBOX-API-KEY\",  # Custom header name\n            timeout=timeout,\n        )\n\n        # Inject httpx client (adapter-owned)\n        self._httpx_client = httpx.AsyncClient(\n            base_url=self.connection_config.get_base_url(),\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_async_httpx_client(self._httpx_client)\n\n    async def _get_client(self):\n        \"\"\"Return the authenticated client for lifecycle API.\"\"\"\n        return self._client\n\n    async def create_sandbox(\n        self,\n        spec: SandboxImageSpec,\n        entrypoint: list[str],\n        env: dict[str, str],\n        metadata: dict[str, str],\n        timeout: timedelta | None,\n        resource: dict[str, str],\n        network_policy: NetworkPolicy | None,\n        extensions: dict[str, str],\n        volumes: list[Volume] | None,\n    ) -> SandboxCreateResponse:\n        \"\"\"Create a new sandbox instance with the specified configuration.\"\"\"\n        logger.info(f\"Creating sandbox with image: {spec.image}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\n\n            create_request = SandboxModelConverter.to_api_create_sandbox_request(\n                spec=spec,\n                entrypoint=entrypoint,\n                env=env,\n                metadata=metadata,\n                timeout=timeout,\n                resource=resource,\n                network_policy=network_policy,\n                extensions=extensions,\n                volumes=volumes,\n            )\n\n            client = await self._get_client()\n            response_obj = await post_sandboxes.asyncio_detailed(\n                client=client,\n                body=create_request,\n            )\n\n            handle_api_error(response_obj, \"Create sandbox\")\n\n            from opensandbox.api.lifecycle.models import CreateSandboxResponse\n            parsed = require_parsed(response_obj, CreateSandboxResponse, \"Create sandbox\")\n            response = SandboxModelConverter.to_sandbox_create_response(parsed)\n            logger.info(f\"Successfully created sandbox: {response.id}\")\n            return response\n\n        except Exception as e:\n            logger.error(\n                f\"Failed to create sandbox with image: {spec.image}\", exc_info=e\n            )\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        \"\"\"Retrieve detailed information about a sandbox.\"\"\"\n        logger.debug(f\"Retrieving sandbox information: {sandbox_id}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import get_sandboxes_sandbox_id\n\n            client = await self._get_client()\n            response_obj = await get_sandboxes_sandbox_id.asyncio_detailed(\n                client=client,\n                sandbox_id=sandbox_id,\n            )\n\n            handle_api_error(response_obj, f\"Get sandbox {sandbox_id}\")\n\n            from opensandbox.api.lifecycle.models import Sandbox\n            parsed = require_parsed(response_obj, Sandbox, f\"Get sandbox {sandbox_id}\")\n            return SandboxModelConverter.to_sandbox_info(parsed)\n\n        except Exception as e:\n            logger.error(f\"Failed to get sandbox info: {sandbox_id}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def list_sandboxes(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        \"\"\"List sandboxes with optional filtering criteria.\"\"\"\n        logger.debug(f\"Listing sandboxes with filter: {filter}\")\n\n        # Prepare metadata parameter similar to Kotlin SDK\n        metadata = UNSET\n        if filter.metadata:\n\n            metadata_parts: list[str] = []\n            for key, value in filter.metadata.items():\n                metadata_parts.append(f\"{key}={value}\")\n            metadata = \"&\".join(metadata_parts)\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import get_sandboxes\n            from opensandbox.api.lifecycle.types import UNSET as API_UNSET\n\n            client = await self._get_client()\n            response_obj = await get_sandboxes.asyncio_detailed(\n                client=client,\n                state=filter.states if filter.states else API_UNSET,\n                metadata=metadata,\n                page=filter.page if filter.page is not None else API_UNSET,\n                page_size=filter.page_size if filter.page_size is not None else API_UNSET,\n            )\n\n            handle_api_error(response_obj, \"List sandboxes\")\n\n            from opensandbox.api.lifecycle.models import ListSandboxesResponse\n            parsed = require_parsed(response_obj, ListSandboxesResponse, \"List sandboxes\")\n            return SandboxModelConverter.to_paged_sandbox_infos(parsed)\n\n        except Exception as e:\n            logger.error(\"Failed to list sandboxes\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def get_sandbox_endpoint(\n        self, sandbox_id: str, port: int, use_server_proxy: bool = False\n    ) -> SandboxEndpoint:\n        \"\"\"Get network endpoint information for a sandbox service.\"\"\"\n        logger.debug(f\"Retrieving sandbox endpoint: {sandbox_id}, port {port}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                get_sandboxes_sandbox_id_endpoints_port,\n            )\n\n            client = await self._get_client()\n            response_obj = (\n                await get_sandboxes_sandbox_id_endpoints_port.asyncio_detailed(\n                    client=client,\n                    sandbox_id=sandbox_id,\n                    port=port,\n                    use_server_proxy=use_server_proxy,\n                )\n            )\n\n            handle_api_error(\n                response_obj, f\"Get endpoint for sandbox {sandbox_id} port {port}\"\n            )\n\n            from opensandbox.api.lifecycle.models import Endpoint\n            parsed = require_parsed(response_obj, Endpoint, \"Get endpoint\")\n            return SandboxModelConverter.to_sandbox_endpoint(parsed)\n\n        except Exception as e:\n            logger.error(\n                f\"Failed to retrieve sandbox endpoint for sandbox {sandbox_id}\",\n                exc_info=e,\n            )\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"Pause a running sandbox while preserving its state.\"\"\"\n        logger.info(f\"Pausing sandbox: {sandbox_id}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_pause,\n            )\n\n            client = await self._get_client()\n            response_obj = await post_sandboxes_sandbox_id_pause.asyncio_detailed(\n                client=client,\n                sandbox_id=sandbox_id,\n            )\n\n            handle_api_error(response_obj, f\"Pause sandbox {sandbox_id}\")\n\n            logger.info(f\"Initiated pause for sandbox: {sandbox_id}\")\n\n        except Exception as e:\n            logger.error(f\"Failed to initiate pause sandbox: {sandbox_id}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"Resume a previously paused sandbox.\"\"\"\n        logger.info(f\"Resuming sandbox: {sandbox_id}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_resume,\n            )\n\n            client = await self._get_client()\n            response_obj = await post_sandboxes_sandbox_id_resume.asyncio_detailed(\n                client=client,\n                sandbox_id=sandbox_id,\n            )\n\n            handle_api_error(response_obj, f\"Resume sandbox {sandbox_id}\")\n\n            logger.info(f\"Initiated resume for sandbox: {sandbox_id}\")\n\n        except Exception as e:\n            logger.error(f\"Failed initiate resume sandbox: {sandbox_id}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def renew_sandbox_expiration(\n        self, sandbox_id: str, new_expiration_time: datetime\n    ) -> SandboxRenewResponse:\n        \"\"\"Extend the expiration time of a sandbox.\"\"\"\n        logger.info(f\"Renew sandbox {sandbox_id} expiration to {new_expiration_time}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_renew_expiration,\n            )\n            from opensandbox.api.lifecycle.models.renew_sandbox_expiration_response import (\n                RenewSandboxExpirationResponse,\n            )\n\n            renew_request = SandboxModelConverter.to_api_renew_request(\n                new_expiration_time\n            )\n\n            client = await self._get_client()\n            response_obj = (\n                await post_sandboxes_sandbox_id_renew_expiration.asyncio_detailed(\n                    client=client,\n                    sandbox_id=sandbox_id,\n                    body=renew_request,\n                )\n            )\n\n            handle_api_error(response_obj, f\"Renew sandbox {sandbox_id} expiration\")\n\n            parsed = require_parsed(\n                response_obj,\n                RenewSandboxExpirationResponse,\n                f\"Renew sandbox {sandbox_id} expiration\",\n            )\n            renew_response = SandboxModelConverter.to_sandbox_renew_response(parsed)\n            logger.info(\n                \"Successfully renewed sandbox %s expiration to %s\",\n                sandbox_id,\n                renew_response.expires_at,\n            )\n            return renew_response\n\n        except Exception as e:\n            logger.error(f\"Failed to renew sandbox {sandbox_id} expiration\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    async def kill_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"Permanently terminate a sandbox and clean up its resources.\"\"\"\n        logger.info(f\"Terminating sandbox: {sandbox_id}\")\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                delete_sandboxes_sandbox_id,\n            )\n\n            client = await self._get_client()\n            response_obj = await delete_sandboxes_sandbox_id.asyncio_detailed(\n                client=client,\n                sandbox_id=sandbox_id,\n            )\n\n            handle_api_error(response_obj, f\"Kill sandbox {sandbox_id}\")\n\n            logger.info(f\"Successfully terminated sandbox: {sandbox_id}\")\n\n        except Exception as e:\n            logger.error(f\"Failed to terminate sandbox: {sandbox_id}\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"OpenSandbox API clients generated from OpenAPI specs.\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"A client library for accessing OpenSandbox Egress API\"\"\"\n\nfrom .client import AuthenticatedClient, Client\n\n__all__ = (\n    \"AuthenticatedClient\",\n    \"Client\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/api/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains methods for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/api/policy/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/api/policy/get_policy.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.policy_status_response import PolicyStatusResponse\nfrom ...types import Response\n\n\ndef _get_kwargs() -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/policy\",\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> PolicyStatusResponse | str | None:\n    if response.status_code == 200:\n        response_200 = PolicyStatusResponse.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 401:\n        response_401 = response.text\n        return response_401\n\n    if response.status_code == 500:\n        response_500 = response.text\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[PolicyStatusResponse | str]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[PolicyStatusResponse | str]:\n    \"\"\"Get current egress policy\n\n     Returns the currently enforced egress policy and the sidecar's derived\n    runtime mode metadata.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[PolicyStatusResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n) -> PolicyStatusResponse | str | None:\n    \"\"\"Get current egress policy\n\n     Returns the currently enforced egress policy and the sidecar's derived\n    runtime mode metadata.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        PolicyStatusResponse | str\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[PolicyStatusResponse | str]:\n    \"\"\"Get current egress policy\n\n     Returns the currently enforced egress policy and the sidecar's derived\n    runtime mode metadata.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[PolicyStatusResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n) -> PolicyStatusResponse | str | None:\n    \"\"\"Get current egress policy\n\n     Returns the currently enforced egress policy and the sidecar's derived\n    runtime mode metadata.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        PolicyStatusResponse | str\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/api/policy/patch_policy.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.network_rule import NetworkRule\nfrom ...models.policy_status_response import PolicyStatusResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: list[NetworkRule],\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"patch\",\n        \"url\": \"/policy\",\n    }\n\n    _kwargs[\"json\"] = []\n    for body_item_data in body:\n        body_item = body_item_data.to_dict()\n        _kwargs[\"json\"].append(body_item)\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> PolicyStatusResponse | str | None:\n    if response.status_code == 200:\n        response_200 = PolicyStatusResponse.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = response.text\n        return response_400\n\n    if response.status_code == 401:\n        response_401 = response.text\n        return response_401\n\n    if response.status_code == 500:\n        response_500 = response.text\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[PolicyStatusResponse | str]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[NetworkRule],\n) -> Response[PolicyStatusResponse | str]:\n    \"\"\"Patch egress rules\n\n     Merge incoming egress rules with the currently enforced policy.\n\n    This endpoint uses merge semantics:\n    - Existing rules remain unless overridden by incoming rules.\n    - Incoming rules are applied with higher priority than existing rules.\n    - If multiple incoming rules refer to the same `target`, the first one wins.\n\n    Args:\n        body (list[NetworkRule]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[PolicyStatusResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[NetworkRule],\n) -> PolicyStatusResponse | str | None:\n    \"\"\"Patch egress rules\n\n     Merge incoming egress rules with the currently enforced policy.\n\n    This endpoint uses merge semantics:\n    - Existing rules remain unless overridden by incoming rules.\n    - Incoming rules are applied with higher priority than existing rules.\n    - If multiple incoming rules refer to the same `target`, the first one wins.\n\n    Args:\n        body (list[NetworkRule]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        PolicyStatusResponse | str\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[NetworkRule],\n) -> Response[PolicyStatusResponse | str]:\n    \"\"\"Patch egress rules\n\n     Merge incoming egress rules with the currently enforced policy.\n\n    This endpoint uses merge semantics:\n    - Existing rules remain unless overridden by incoming rules.\n    - Incoming rules are applied with higher priority than existing rules.\n    - If multiple incoming rules refer to the same `target`, the first one wins.\n\n    Args:\n        body (list[NetworkRule]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[PolicyStatusResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[NetworkRule],\n) -> PolicyStatusResponse | str | None:\n    \"\"\"Patch egress rules\n\n     Merge incoming egress rules with the currently enforced policy.\n\n    This endpoint uses merge semantics:\n    - Existing rules remain unless overridden by incoming rules.\n    - Incoming rules are applied with higher priority than existing rules.\n    - If multiple incoming rules refer to the same `target`, the first one wins.\n\n    Args:\n        body (list[NetworkRule]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        PolicyStatusResponse | str\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/client.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nimport ssl\nfrom typing import Any\n\nimport httpx\nfrom attrs import define, evolve, field\n\n\n@define\nclass Client:\n    \"\"\"A class for keeping track of data related to the API\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    def with_headers(self, headers: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"Client\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n\n\n@define\nclass AuthenticatedClient:\n    \"\"\"A Client which has been authenticated for use on secured endpoints\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n        token: The token to use for authentication\n        prefix: The prefix to use for the Authorization header\n        auth_header_name: The name of the Authorization header\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    token: str\n    prefix: str = \"Bearer\"\n    auth_header_name: str = \"Authorization\"\n\n    def with_headers(self, headers: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/errors.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains shared errors types that can be raised from API functions\"\"\"\n\n\nclass UnexpectedStatus(Exception):\n    \"\"\"Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True\"\"\"\n\n    def __init__(self, status_code: int, content: bytes):\n        self.status_code = status_code\n        self.content = content\n\n        super().__init__(\n            f\"Unexpected status code: {status_code}\\n\\nResponse content:\\n{content.decode(errors='ignore')}\"\n        )\n\n\n__all__ = [\"UnexpectedStatus\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains all the data models used in inputs/outputs\"\"\"\n\nfrom .network_policy import NetworkPolicy\nfrom .network_policy_default_action import NetworkPolicyDefaultAction\nfrom .network_rule import NetworkRule\nfrom .network_rule_action import NetworkRuleAction\nfrom .policy_status_response import PolicyStatusResponse\n\n__all__ = (\n    \"NetworkPolicy\",\n    \"NetworkPolicyDefaultAction\",\n    \"NetworkRule\",\n    \"NetworkRuleAction\",\n    \"PolicyStatusResponse\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/network_policy.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..models.network_policy_default_action import NetworkPolicyDefaultAction\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.network_rule import NetworkRule\n\n\nT = TypeVar(\"T\", bound=\"NetworkPolicy\")\n\n\n@_attrs_define\nclass NetworkPolicy:\n    \"\"\"Egress network policy matching the sidecar `/policy` request body.\n    If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n    object or null results in allow-all behavior at startup.\n\n        Attributes:\n            default_action (NetworkPolicyDefaultAction | Unset): Default action when no egress rule matches. Defaults to\n                \"deny\".\n            egress (list[NetworkRule] | Unset): List of egress rules evaluated in order.\n    \"\"\"\n\n    default_action: NetworkPolicyDefaultAction | Unset = UNSET\n    egress: list[NetworkRule] | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        default_action: str | Unset = UNSET\n        if not isinstance(self.default_action, Unset):\n            default_action = self.default_action.value\n\n        egress: list[dict[str, Any]] | Unset = UNSET\n        if not isinstance(self.egress, Unset):\n            egress = []\n            for egress_item_data in self.egress:\n                egress_item = egress_item_data.to_dict()\n                egress.append(egress_item)\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update({})\n        if default_action is not UNSET:\n            field_dict[\"defaultAction\"] = default_action\n        if egress is not UNSET:\n            field_dict[\"egress\"] = egress\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.network_rule import NetworkRule\n\n        d = dict(src_dict)\n        _default_action = d.pop(\"defaultAction\", UNSET)\n        default_action: NetworkPolicyDefaultAction | Unset\n        if isinstance(_default_action, Unset):\n            default_action = UNSET\n        else:\n            default_action = NetworkPolicyDefaultAction(_default_action)\n\n        _egress = d.pop(\"egress\", UNSET)\n        egress: list[NetworkRule] | Unset = UNSET\n        if _egress is not UNSET:\n            egress = []\n            for egress_item_data in _egress:\n                egress_item = NetworkRule.from_dict(egress_item_data)\n\n                egress.append(egress_item)\n\n        network_policy = cls(\n            default_action=default_action,\n            egress=egress,\n        )\n\n        return network_policy\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/network_policy_default_action.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass NetworkPolicyDefaultAction(str, Enum):\n    ALLOW = \"allow\"\n    DENY = \"deny\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/network_rule.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..models.network_rule_action import NetworkRuleAction\n\nT = TypeVar(\"T\", bound=\"NetworkRule\")\n\n\n@_attrs_define\nclass NetworkRule:\n    \"\"\"\n    Attributes:\n        action (NetworkRuleAction): Whether to allow or deny matching targets.\n        target (str): FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n            IP/CIDR not yet supported in the egress MVP.\n    \"\"\"\n\n    action: NetworkRuleAction\n    target: str\n\n    def to_dict(self) -> dict[str, Any]:\n        action = self.action.value\n\n        target = self.target\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"action\": action,\n                \"target\": target,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        action = NetworkRuleAction(d.pop(\"action\"))\n\n        target = d.pop(\"target\")\n\n        network_rule = cls(\n            action=action,\n            target=target,\n        )\n\n        return network_rule\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/network_rule_action.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass NetworkRuleAction(str, Enum):\n    ALLOW = \"allow\"\n    DENY = \"deny\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/models/policy_status_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.network_policy import NetworkPolicy\n\n\nT = TypeVar(\"T\", bound=\"PolicyStatusResponse\")\n\n\n@_attrs_define\nclass PolicyStatusResponse:\n    \"\"\"\n    Attributes:\n        status (str | Unset): Operation status reported by the sidecar. Example: ok.\n        mode (str | Unset): Derived runtime mode for the current policy. Example: deny_all.\n        enforcement_mode (str | Unset): Egress sidecar enforcement backend mode. Example: dns.\n        reason (str | Unset): Optional human-readable reason when the sidecar returns extra context.\n        policy (NetworkPolicy | Unset): Egress network policy matching the sidecar `/policy` request body.\n            If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n            object or null results in allow-all behavior at startup.\n    \"\"\"\n\n    status: str | Unset = UNSET\n    mode: str | Unset = UNSET\n    enforcement_mode: str | Unset = UNSET\n    reason: str | Unset = UNSET\n    policy: NetworkPolicy | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        status = self.status\n\n        mode = self.mode\n\n        enforcement_mode = self.enforcement_mode\n\n        reason = self.reason\n\n        policy: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.policy, Unset):\n            policy = self.policy.to_dict()\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update({})\n        if status is not UNSET:\n            field_dict[\"status\"] = status\n        if mode is not UNSET:\n            field_dict[\"mode\"] = mode\n        if enforcement_mode is not UNSET:\n            field_dict[\"enforcementMode\"] = enforcement_mode\n        if reason is not UNSET:\n            field_dict[\"reason\"] = reason\n        if policy is not UNSET:\n            field_dict[\"policy\"] = policy\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.network_policy import NetworkPolicy\n\n        d = dict(src_dict)\n        status = d.pop(\"status\", UNSET)\n\n        mode = d.pop(\"mode\", UNSET)\n\n        enforcement_mode = d.pop(\"enforcementMode\", UNSET)\n\n        reason = d.pop(\"reason\", UNSET)\n\n        _policy = d.pop(\"policy\", UNSET)\n        policy: NetworkPolicy | Unset\n        if isinstance(_policy, Unset):\n            policy = UNSET\n        else:\n            policy = NetworkPolicy.from_dict(_policy)\n\n        policy_status_response = cls(\n            status=status,\n            mode=mode,\n            enforcement_mode=enforcement_mode,\n            reason=reason,\n            policy=policy,\n        )\n\n        return policy_status_response\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/py.typed",
    "content": "# Marker file for PEP 561"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/egress/types.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains some shared types for properties\"\"\"\n\nfrom collections.abc import Mapping, MutableMapping\nfrom http import HTTPStatus\nfrom typing import IO, BinaryIO, Generic, Literal, TypeVar\n\nfrom attrs import define\n\n\nclass Unset:\n    def __bool__(self) -> Literal[False]:\n        return False\n\n\nUNSET: Unset = Unset()\n\n# The types that `httpx.Client(files=)` can accept, copied from that library.\nFileContent = IO[bytes] | bytes | str\nFileTypes = (\n    # (filename, file (or bytes), content_type)\n    tuple[str | None, FileContent, str | None]\n    # (filename, file (or bytes), content_type, headers)\n    | tuple[str | None, FileContent, str | None, Mapping[str, str]]\n)\nRequestFiles = list[tuple[str, FileTypes]]\n\n\n@define\nclass File:\n    \"\"\"Contains information for file uploads\"\"\"\n\n    payload: BinaryIO\n    file_name: str | None = None\n    mime_type: str | None = None\n\n    def to_tuple(self) -> FileTypes:\n        \"\"\"Return a tuple representation that httpx will accept for multipart/form-data\"\"\"\n        return self.file_name, self.payload, self.mime_type\n\n\nT = TypeVar(\"T\")\n\n\n@define\nclass Response(Generic[T]):\n    \"\"\"A response from an endpoint\"\"\"\n\n    status_code: HTTPStatus\n    content: bytes\n    headers: MutableMapping[str, str]\n    parsed: T | None\n\n\n__all__ = [\"UNSET\", \"File\", \"FileTypes\", \"RequestFiles\", \"Response\", \"Unset\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"A client library for accessing OpenSandbox Execd API\"\"\"\n\nfrom .client import AuthenticatedClient, Client\n\n__all__ = (\n    \"AuthenticatedClient\",\n    \"Client\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains methods for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/create_code_context.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.code_context import CodeContext\nfrom ...models.code_context_request import CodeContextRequest\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: CodeContextRequest,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/code/context\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> CodeContext | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = CodeContext.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[CodeContext | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CodeContextRequest,\n) -> Response[CodeContext | ErrorResponse]:\n    \"\"\"Create code execution context\n\n     Creates a new code execution environment and returns a session ID that can be used\n    for subsequent code execution requests. The context maintains state across multiple\n    code executions within the same session.\n\n    Args:\n        body (CodeContextRequest): Request to create a code execution context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CodeContext | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CodeContextRequest,\n) -> CodeContext | ErrorResponse | None:\n    \"\"\"Create code execution context\n\n     Creates a new code execution environment and returns a session ID that can be used\n    for subsequent code execution requests. The context maintains state across multiple\n    code executions within the same session.\n\n    Args:\n        body (CodeContextRequest): Request to create a code execution context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CodeContext | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CodeContextRequest,\n) -> Response[CodeContext | ErrorResponse]:\n    \"\"\"Create code execution context\n\n     Creates a new code execution environment and returns a session ID that can be used\n    for subsequent code execution requests. The context maintains state across multiple\n    code executions within the same session.\n\n    Args:\n        body (CodeContextRequest): Request to create a code execution context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CodeContext | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CodeContextRequest,\n) -> CodeContext | ErrorResponse | None:\n    \"\"\"Create code execution context\n\n     Creates a new code execution environment and returns a session ID that can be used\n    for subsequent code execution requests. The context maintains state across multiple\n    code executions within the same session.\n\n    Args:\n        body (CodeContextRequest): Request to create a code execution context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CodeContext | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/delete_context.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    context_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/code/contexts/{context_id}\".format(\n            context_id=quote(str(context_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete a code execution context by id\n\n     Deletes an existing code execution context (session) by id.\n    This should terminate the underlying context thread/process and release resources.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        context_id=context_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete a code execution context by id\n\n     Deletes an existing code execution context (session) by id.\n    This should terminate the underlying context thread/process and release resources.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        context_id=context_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete a code execution context by id\n\n     Deletes an existing code execution context (session) by id.\n    This should terminate the underlying context thread/process and release resources.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        context_id=context_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete a code execution context by id\n\n     Deletes an existing code execution context (session) by id.\n    This should terminate the underlying context thread/process and release resources.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            context_id=context_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/delete_contexts_by_language.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    language: str,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"language\"] = language\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/code/contexts\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete all contexts under a language\n\n     Deletes all existing code execution contexts under the specified `language`/runtime.\n    This is a bulk operation intended for code-interpreter context cleanup.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        language=language,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete all contexts under a language\n\n     Deletes all existing code execution contexts under the specified `language`/runtime.\n    This is a bulk operation intended for code-interpreter context cleanup.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        language=language,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete all contexts under a language\n\n     Deletes all existing code execution contexts under the specified `language`/runtime.\n    This is a bulk operation intended for code-interpreter context cleanup.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        language=language,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete all contexts under a language\n\n     Deletes all existing code execution contexts under the specified `language`/runtime.\n    This is a bulk operation intended for code-interpreter context cleanup.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            language=language,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/get_context.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.code_context import CodeContext\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    context_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/code/contexts/{context_id}\".format(\n            context_id=quote(str(context_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> CodeContext | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = CodeContext.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[CodeContext | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[CodeContext | ErrorResponse]:\n    \"\"\"Get a code execution context by id\n\n     Retrieves the details of an existing code execution context (session) by id.\n    Returns the context ID, language, and any associated metadata.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CodeContext | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        context_id=context_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> CodeContext | ErrorResponse | None:\n    \"\"\"Get a code execution context by id\n\n     Retrieves the details of an existing code execution context (session) by id.\n    Returns the context ID, language, and any associated metadata.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CodeContext | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        context_id=context_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[CodeContext | ErrorResponse]:\n    \"\"\"Get a code execution context by id\n\n     Retrieves the details of an existing code execution context (session) by id.\n    Returns the context ID, language, and any associated metadata.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CodeContext | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        context_id=context_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    context_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> CodeContext | ErrorResponse | None:\n    \"\"\"Get a code execution context by id\n\n     Retrieves the details of an existing code execution context (session) by id.\n    Returns the context ID, language, and any associated metadata.\n\n    Args:\n        context_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CodeContext | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            context_id=context_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/interrupt_code.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    id: str,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"id\"] = id\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/code\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Interrupt code execution\n\n     Interrupts the currently running code execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Interrupt code execution\n\n     Interrupts the currently running code execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        id=id,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Interrupt code execution\n\n     Interrupts the currently running code execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Interrupt code execution\n\n     Interrupts the currently running code execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            id=id,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/list_contexts.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.code_context import CodeContext\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    language: str,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"language\"] = language\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/code/contexts\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | list[CodeContext] | None:\n    if response.status_code == 200:\n        response_200 = []\n        _response_200 = response.json()\n        for response_200_item_data in _response_200:\n            response_200_item = CodeContext.from_dict(response_200_item_data)\n\n            response_200.append(response_200_item)\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | list[CodeContext]]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Response[ErrorResponse | list[CodeContext]]:\n    \"\"\"List active code execution contexts\n\n     Lists all active/available code execution contexts.\n    If `language` is provided, only contexts under that language/runtime are returned.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | list[CodeContext]]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        language=language,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> ErrorResponse | list[CodeContext] | None:\n    \"\"\"List active code execution contexts\n\n     Lists all active/available code execution contexts.\n    If `language` is provided, only contexts under that language/runtime are returned.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | list[CodeContext]\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        language=language,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> Response[ErrorResponse | list[CodeContext]]:\n    \"\"\"List active code execution contexts\n\n     Lists all active/available code execution contexts.\n    If `language` is provided, only contexts under that language/runtime are returned.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | list[CodeContext]]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        language=language,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    language: str,\n) -> ErrorResponse | list[CodeContext] | None:\n    \"\"\"List active code execution contexts\n\n     Lists all active/available code execution contexts.\n    If `language` is provided, only contexts under that language/runtime are returned.\n\n    Args:\n        language (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | list[CodeContext]\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            language=language,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/code_interpreting/run_code.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.run_code_request import RunCodeRequest\nfrom ...models.server_stream_event import ServerStreamEvent\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: RunCodeRequest,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/code\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | ServerStreamEvent | None:\n    if response.status_code == 200:\n        response_200 = ServerStreamEvent.from_dict(response.text)\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCodeRequest,\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    \"\"\"Execute code in context\n\n     Executes code using Jupyter kernel in a specified execution context and streams\n    the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n    languages (Python, JavaScript, etc.) and maintains execution state within the session.\n    Returns execution results, output streams, execution count, and any errors.\n\n    Args:\n        body (RunCodeRequest): Request to execute code in a context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ServerStreamEvent]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCodeRequest,\n) -> ErrorResponse | ServerStreamEvent | None:\n    \"\"\"Execute code in context\n\n     Executes code using Jupyter kernel in a specified execution context and streams\n    the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n    languages (Python, JavaScript, etc.) and maintains execution state within the session.\n    Returns execution results, output streams, execution count, and any errors.\n\n    Args:\n        body (RunCodeRequest): Request to execute code in a context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ServerStreamEvent\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCodeRequest,\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    \"\"\"Execute code in context\n\n     Executes code using Jupyter kernel in a specified execution context and streams\n    the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n    languages (Python, JavaScript, etc.) and maintains execution state within the session.\n    Returns execution results, output streams, execution count, and any errors.\n\n    Args:\n        body (RunCodeRequest): Request to execute code in a context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ServerStreamEvent]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCodeRequest,\n) -> ErrorResponse | ServerStreamEvent | None:\n    \"\"\"Execute code in context\n\n     Executes code using Jupyter kernel in a specified execution context and streams\n    the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n    languages (Python, JavaScript, etc.) and maintains execution state within the session.\n    Returns execution results, output streams, execution count, and any errors.\n\n    Args:\n        body (RunCodeRequest): Request to execute code in a context\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ServerStreamEvent\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/command/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/command/get_background_command_logs.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response, Unset\n\n\ndef _get_kwargs(\n    id: str,\n    *,\n    cursor: int | Unset = UNSET,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"cursor\"] = cursor\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/command/{id}/logs\".format(\n            id=quote(str(id), safe=\"\"),\n        ),\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> ErrorResponse | str | None:\n    if response.status_code == 200:\n        response_200 = response.text\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[ErrorResponse | str]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    cursor: int | Unset = UNSET,\n) -> Response[ErrorResponse | str]:\n    \"\"\"Get background command stdout/stderr (non-streamed)\n\n     Returns stdout and stderr for a background (detached) command by command ID.\n    Foreground commands should be consumed via SSE; this endpoint is intended for\n    polling logs of background commands. Supports incremental reads similar to a file seek:\n    pass a starting line via query to fetch output after that line and receive the latest\n    tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n    Response body is plain text so it can be rendered directly in browsers; the latest line index\n    is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n\n    Args:\n        id (str):\n        cursor (int | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n        cursor=cursor,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    cursor: int | Unset = UNSET,\n) -> ErrorResponse | str | None:\n    \"\"\"Get background command stdout/stderr (non-streamed)\n\n     Returns stdout and stderr for a background (detached) command by command ID.\n    Foreground commands should be consumed via SSE; this endpoint is intended for\n    polling logs of background commands. Supports incremental reads similar to a file seek:\n    pass a starting line via query to fetch output after that line and receive the latest\n    tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n    Response body is plain text so it can be rendered directly in browsers; the latest line index\n    is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n\n    Args:\n        id (str):\n        cursor (int | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | str\n    \"\"\"\n\n    return sync_detailed(\n        id=id,\n        client=client,\n        cursor=cursor,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    cursor: int | Unset = UNSET,\n) -> Response[ErrorResponse | str]:\n    \"\"\"Get background command stdout/stderr (non-streamed)\n\n     Returns stdout and stderr for a background (detached) command by command ID.\n    Foreground commands should be consumed via SSE; this endpoint is intended for\n    polling logs of background commands. Supports incremental reads similar to a file seek:\n    pass a starting line via query to fetch output after that line and receive the latest\n    tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n    Response body is plain text so it can be rendered directly in browsers; the latest line index\n    is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n\n    Args:\n        id (str):\n        cursor (int | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | str]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n        cursor=cursor,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    cursor: int | Unset = UNSET,\n) -> ErrorResponse | str | None:\n    \"\"\"Get background command stdout/stderr (non-streamed)\n\n     Returns stdout and stderr for a background (detached) command by command ID.\n    Foreground commands should be consumed via SSE; this endpoint is intended for\n    polling logs of background commands. Supports incremental reads similar to a file seek:\n    pass a starting line via query to fetch output after that line and receive the latest\n    tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n    Response body is plain text so it can be rendered directly in browsers; the latest line index\n    is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n\n    Args:\n        id (str):\n        cursor (int | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | str\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            id=id,\n            client=client,\n            cursor=cursor,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/command/get_command_status.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.command_status_response import CommandStatusResponse\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/command/status/{id}\".format(\n            id=quote(str(id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> CommandStatusResponse | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = CommandStatusResponse.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[CommandStatusResponse | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[CommandStatusResponse | ErrorResponse]:\n    \"\"\"Get command running status\n\n     Returns the current status of a command (foreground or background) by command ID.\n    Includes running flag, exit code, error (if any), and start/finish timestamps.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CommandStatusResponse | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> CommandStatusResponse | ErrorResponse | None:\n    \"\"\"Get command running status\n\n     Returns the current status of a command (foreground or background) by command ID.\n    Includes running flag, exit code, error (if any), and start/finish timestamps.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CommandStatusResponse | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        id=id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[CommandStatusResponse | ErrorResponse]:\n    \"\"\"Get command running status\n\n     Returns the current status of a command (foreground or background) by command ID.\n    Includes running flag, exit code, error (if any), and start/finish timestamps.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CommandStatusResponse | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> CommandStatusResponse | ErrorResponse | None:\n    \"\"\"Get command running status\n\n     Returns the current status of a command (foreground or background) by command ID.\n    Includes running flag, exit code, error (if any), and start/finish timestamps.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CommandStatusResponse | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            id=id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/command/interrupt_command.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    id: str,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"id\"] = id\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/command\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Interrupt command execution\n\n     Interrupts the currently running command execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Interrupt command execution\n\n     Interrupts the currently running command execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        id=id,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Interrupt command execution\n\n     Interrupts the currently running command execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        id=id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    id: str,\n) -> Any | ErrorResponse | None:\n    \"\"\"Interrupt command execution\n\n     Interrupts the currently running command execution in the specified context.\n    This sends a signal to terminate the execution process and releases associated resources.\n\n    Args:\n        id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            id=id,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/command/run_command.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.run_command_request import RunCommandRequest\nfrom ...models.server_stream_event import ServerStreamEvent\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: RunCommandRequest,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/command\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | ServerStreamEvent | None:\n    if response.status_code == 200:\n        response_200 = ServerStreamEvent.from_dict(response.text)\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCommandRequest,\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    \"\"\"Execute shell command\n\n     Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n    The command can run in foreground or background mode. The response includes stdout, stderr,\n    execution status, and completion events.\n    Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n    terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n    with specific user/group IDs, and `envs` to inject environment variables.\n\n    Args:\n        body (RunCommandRequest): Request to execute a shell command\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ServerStreamEvent]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCommandRequest,\n) -> ErrorResponse | ServerStreamEvent | None:\n    \"\"\"Execute shell command\n\n     Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n    The command can run in foreground or background mode. The response includes stdout, stderr,\n    execution status, and completion events.\n    Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n    terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n    with specific user/group IDs, and `envs` to inject environment variables.\n\n    Args:\n        body (RunCommandRequest): Request to execute a shell command\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ServerStreamEvent\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCommandRequest,\n) -> Response[ErrorResponse | ServerStreamEvent]:\n    \"\"\"Execute shell command\n\n     Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n    The command can run in foreground or background mode. The response includes stdout, stderr,\n    execution status, and completion events.\n    Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n    terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n    with specific user/group IDs, and `envs` to inject environment variables.\n\n    Args:\n        body (RunCommandRequest): Request to execute a shell command\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ServerStreamEvent]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: RunCommandRequest,\n) -> ErrorResponse | ServerStreamEvent | None:\n    \"\"\"Execute shell command\n\n     Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n    The command can run in foreground or background mode. The response includes stdout, stderr,\n    execution status, and completion events.\n    Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n    terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n    with specific user/group IDs, and `envs` to inject environment variables.\n\n    Args:\n        body (RunCommandRequest): Request to execute a shell command\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ServerStreamEvent\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/chmod_files.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.chmod_files_body import ChmodFilesBody\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: ChmodFilesBody,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/files/permissions\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ChmodFilesBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Change file permissions\n\n     Changes permissions (mode), owner, and group for one or multiple files.\n    Accepts a map of file paths to permission settings including octal mode,\n    owner username, and group name.\n\n    Args:\n        body (ChmodFilesBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ChmodFilesBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Change file permissions\n\n     Changes permissions (mode), owner, and group for one or multiple files.\n    Accepts a map of file paths to permission settings including octal mode,\n    owner username, and group name.\n\n    Args:\n        body (ChmodFilesBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ChmodFilesBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Change file permissions\n\n     Changes permissions (mode), owner, and group for one or multiple files.\n    Accepts a map of file paths to permission settings including octal mode,\n    owner username, and group name.\n\n    Args:\n        body (ChmodFilesBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ChmodFilesBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Change file permissions\n\n     Changes permissions (mode), owner, and group for one or multiple files.\n    Accepts a map of file paths to permission settings including octal mode,\n    owner username, and group name.\n\n    Args:\n        body (ChmodFilesBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/download_file.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom io import BytesIO\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, File, Response, Unset\n\n\ndef _get_kwargs(\n    *,\n    path: str,\n    range_: str | Unset = UNSET,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n    if not isinstance(range_, Unset):\n        headers[\"Range\"] = range_\n\n    params: dict[str, Any] = {}\n\n    params[\"path\"] = path\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/files/download\",\n        \"params\": params,\n    }\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> ErrorResponse | File | None:\n    if response.status_code == 200:\n        response_200 = File(payload=BytesIO(response.content))\n\n        return response_200\n\n    if response.status_code == 206:\n        response_206 = File(payload=BytesIO(response.content))\n\n        return response_206\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 416:\n        response_416 = ErrorResponse.from_dict(response.json())\n\n        return response_416\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | File]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    range_: str | Unset = UNSET,\n) -> Response[ErrorResponse | File]:\n    \"\"\"Download file from sandbox\n\n     Downloads a file from the specified path within the sandbox. Supports HTTP\n    range requests for resumable downloads and partial content retrieval.\n    Returns file as octet-stream with appropriate headers.\n\n    Args:\n        path (str):\n        range_ (str | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | File]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n        range_=range_,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    range_: str | Unset = UNSET,\n) -> ErrorResponse | File | None:\n    \"\"\"Download file from sandbox\n\n     Downloads a file from the specified path within the sandbox. Supports HTTP\n    range requests for resumable downloads and partial content retrieval.\n    Returns file as octet-stream with appropriate headers.\n\n    Args:\n        path (str):\n        range_ (str | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | File\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        path=path,\n        range_=range_,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    range_: str | Unset = UNSET,\n) -> Response[ErrorResponse | File]:\n    \"\"\"Download file from sandbox\n\n     Downloads a file from the specified path within the sandbox. Supports HTTP\n    range requests for resumable downloads and partial content retrieval.\n    Returns file as octet-stream with appropriate headers.\n\n    Args:\n        path (str):\n        range_ (str | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | File]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n        range_=range_,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    range_: str | Unset = UNSET,\n) -> ErrorResponse | File | None:\n    \"\"\"Download file from sandbox\n\n     Downloads a file from the specified path within the sandbox. Supports HTTP\n    range requests for resumable downloads and partial content retrieval.\n    Returns file as octet-stream with appropriate headers.\n\n    Args:\n        path (str):\n        range_ (str | Unset):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | File\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            path=path,\n            range_=range_,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/get_files_info.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.get_files_info_response_200 import GetFilesInfoResponse200\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    path: list[str],\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    json_path = path\n\n    params[\"path\"] = json_path\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/files/info\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | GetFilesInfoResponse200 | None:\n    if response.status_code == 200:\n        response_200 = GetFilesInfoResponse200.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | GetFilesInfoResponse200]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[ErrorResponse | GetFilesInfoResponse200]:\n    \"\"\"Get file metadata\n\n     Retrieves detailed metadata for one or multiple files including permissions, owner,\n    group, size, and modification time. Returns a map of file paths to their corresponding\n    FileInfo objects.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | GetFilesInfoResponse200]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> ErrorResponse | GetFilesInfoResponse200 | None:\n    \"\"\"Get file metadata\n\n     Retrieves detailed metadata for one or multiple files including permissions, owner,\n    group, size, and modification time. Returns a map of file paths to their corresponding\n    FileInfo objects.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | GetFilesInfoResponse200\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        path=path,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[ErrorResponse | GetFilesInfoResponse200]:\n    \"\"\"Get file metadata\n\n     Retrieves detailed metadata for one or multiple files including permissions, owner,\n    group, size, and modification time. Returns a map of file paths to their corresponding\n    FileInfo objects.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | GetFilesInfoResponse200]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> ErrorResponse | GetFilesInfoResponse200 | None:\n    \"\"\"Get file metadata\n\n     Retrieves detailed metadata for one or multiple files including permissions, owner,\n    group, size, and modification time. Returns a map of file paths to their corresponding\n    FileInfo objects.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | GetFilesInfoResponse200\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            path=path,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/make_dirs.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.make_dirs_body import MakeDirsBody\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: MakeDirsBody,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/directories\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: MakeDirsBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Create directories\n\n     Creates one or multiple directories with specified permissions. Creates parent\n    directories as needed (similar to mkdir -p). Accepts a map of directory paths\n    to permission objects.\n\n    Args:\n        body (MakeDirsBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: MakeDirsBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Create directories\n\n     Creates one or multiple directories with specified permissions. Creates parent\n    directories as needed (similar to mkdir -p). Accepts a map of directory paths\n    to permission objects.\n\n    Args:\n        body (MakeDirsBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: MakeDirsBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Create directories\n\n     Creates one or multiple directories with specified permissions. Creates parent\n    directories as needed (similar to mkdir -p). Accepts a map of directory paths\n    to permission objects.\n\n    Args:\n        body (MakeDirsBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: MakeDirsBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Create directories\n\n     Creates one or multiple directories with specified permissions. Creates parent\n    directories as needed (similar to mkdir -p). Accepts a map of directory paths\n    to permission objects.\n\n    Args:\n        body (MakeDirsBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/remove_dirs.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    path: list[str],\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    json_path = path\n\n    params[\"path\"] = json_path\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/directories\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete directories\n\n     Recursively deletes one or multiple directories and all their contents.\n    Similar to rm -rf. Use with caution as this operation cannot be undone.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete directories\n\n     Recursively deletes one or multiple directories and all their contents.\n    Similar to rm -rf. Use with caution as this operation cannot be undone.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        path=path,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete directories\n\n     Recursively deletes one or multiple directories and all their contents.\n    Similar to rm -rf. Use with caution as this operation cannot be undone.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete directories\n\n     Recursively deletes one or multiple directories and all their contents.\n    Similar to rm -rf. Use with caution as this operation cannot be undone.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            path=path,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/remove_files.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response\n\n\ndef _get_kwargs(\n    *,\n    path: list[str],\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    json_path = path\n\n    params[\"path\"] = json_path\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/files\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete files\n\n     Deletes one or multiple files from the sandbox. Only removes files, not directories.\n    Use RemoveDirs for directory removal.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete files\n\n     Deletes one or multiple files from the sandbox. Only removes files, not directories.\n    Use RemoveDirs for directory removal.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        path=path,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete files\n\n     Deletes one or multiple files from the sandbox. Only removes files, not directories.\n    Use RemoveDirs for directory removal.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    path: list[str],\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete files\n\n     Deletes one or multiple files from the sandbox. Only removes files, not directories.\n    Use RemoveDirs for directory removal.\n\n    Args:\n        path (list[str]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            path=path,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/rename_files.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.rename_file_item import RenameFileItem\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: list[RenameFileItem],\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/files/mv\",\n    }\n\n    _kwargs[\"json\"] = []\n    for body_item_data in body:\n        body_item = body_item_data.to_dict()\n        _kwargs[\"json\"].append(body_item)\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[RenameFileItem],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Rename or move files\n\n     Renames or moves one or multiple files to new paths. Can be used for both\n    renaming within the same directory and moving to different directories.\n    Target directory must exist.\n\n    Args:\n        body (list[RenameFileItem]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[RenameFileItem],\n) -> Any | ErrorResponse | None:\n    \"\"\"Rename or move files\n\n     Renames or moves one or multiple files to new paths. Can be used for both\n    renaming within the same directory and moving to different directories.\n    Target directory must exist.\n\n    Args:\n        body (list[RenameFileItem]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[RenameFileItem],\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Rename or move files\n\n     Renames or moves one or multiple files to new paths. Can be used for both\n    renaming within the same directory and moving to different directories.\n    Target directory must exist.\n\n    Args:\n        body (list[RenameFileItem]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: list[RenameFileItem],\n) -> Any | ErrorResponse | None:\n    \"\"\"Rename or move files\n\n     Renames or moves one or multiple files to new paths. Can be used for both\n    renaming within the same directory and moving to different directories.\n    Target directory must exist.\n\n    Args:\n        body (list[RenameFileItem]):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/replace_content.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.replace_content_body import ReplaceContentBody\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: ReplaceContentBody,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/files/replace\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ReplaceContentBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Replace file content\n\n     Performs text replacement in one or multiple files. Replaces all occurrences\n    of the old string with the new string (similar to strings.ReplaceAll).\n    Preserves file permissions. Useful for batch text substitution across files.\n\n    Args:\n        body (ReplaceContentBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ReplaceContentBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Replace file content\n\n     Performs text replacement in one or multiple files. Replaces all occurrences\n    of the old string with the new string (similar to strings.ReplaceAll).\n    Preserves file permissions. Useful for batch text substitution across files.\n\n    Args:\n        body (ReplaceContentBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ReplaceContentBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Replace file content\n\n     Performs text replacement in one or multiple files. Replaces all occurrences\n    of the old string with the new string (similar to strings.ReplaceAll).\n    Preserves file permissions. Useful for batch text substitution across files.\n\n    Args:\n        body (ReplaceContentBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: ReplaceContentBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Replace file content\n\n     Performs text replacement in one or multiple files. Replaces all occurrences\n    of the old string with the new string (similar to strings.ReplaceAll).\n    Preserves file permissions. Useful for batch text substitution across files.\n\n    Args:\n        body (ReplaceContentBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/search_files.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.file_info import FileInfo\nfrom ...types import UNSET, Response, Unset\n\n\ndef _get_kwargs(\n    *,\n    path: str,\n    pattern: str | Unset = \"**\",\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"path\"] = path\n\n    params[\"pattern\"] = pattern\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/files/search\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | list[FileInfo] | None:\n    if response.status_code == 200:\n        response_200 = []\n        _response_200 = response.json()\n        for response_200_item_data in _response_200:\n            response_200_item = FileInfo.from_dict(response_200_item_data)\n\n            response_200.append(response_200_item)\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | list[FileInfo]]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    pattern: str | Unset = \"**\",\n) -> Response[ErrorResponse | list[FileInfo]]:\n    \"\"\"Search for files\n\n     Searches for files matching a glob pattern within a specified directory and\n    its subdirectories. Returns file metadata including path, permissions, owner,\n    and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n\n    Args:\n        path (str):\n        pattern (str | Unset):  Default: '**'.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | list[FileInfo]]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n        pattern=pattern,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    pattern: str | Unset = \"**\",\n) -> ErrorResponse | list[FileInfo] | None:\n    \"\"\"Search for files\n\n     Searches for files matching a glob pattern within a specified directory and\n    its subdirectories. Returns file metadata including path, permissions, owner,\n    and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n\n    Args:\n        path (str):\n        pattern (str | Unset):  Default: '**'.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | list[FileInfo]\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        path=path,\n        pattern=pattern,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    pattern: str | Unset = \"**\",\n) -> Response[ErrorResponse | list[FileInfo]]:\n    \"\"\"Search for files\n\n     Searches for files matching a glob pattern within a specified directory and\n    its subdirectories. Returns file metadata including path, permissions, owner,\n    and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n\n    Args:\n        path (str):\n        pattern (str | Unset):  Default: '**'.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | list[FileInfo]]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        path=path,\n        pattern=pattern,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    path: str,\n    pattern: str | Unset = \"**\",\n) -> ErrorResponse | list[FileInfo] | None:\n    \"\"\"Search for files\n\n     Searches for files matching a glob pattern within a specified directory and\n    its subdirectories. Returns file metadata including path, permissions, owner,\n    and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n\n    Args:\n        path (str):\n        pattern (str | Unset):  Default: '**'.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | list[FileInfo]\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            path=path,\n            pattern=pattern,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/filesystem/upload_file.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.upload_file_body import UploadFileBody\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: UploadFileBody,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/files/upload\",\n    }\n\n    _kwargs[\"files\"] = body.to_multipart()\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = cast(Any, None)\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: UploadFileBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Upload files to sandbox\n\n     Uploads one or multiple files to specified paths within the sandbox.\n    Reads metadata and file content from multipart form parts in sequence.\n    Each file upload consists of two parts: a metadata part (JSON) followed\n    by the actual file part.\n\n    Args:\n        body (UploadFileBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: UploadFileBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Upload files to sandbox\n\n     Uploads one or multiple files to specified paths within the sandbox.\n    Reads metadata and file content from multipart form parts in sequence.\n    Each file upload consists of two parts: a metadata part (JSON) followed\n    by the actual file part.\n\n    Args:\n        body (UploadFileBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: UploadFileBody,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Upload files to sandbox\n\n     Uploads one or multiple files to specified paths within the sandbox.\n    Reads metadata and file content from multipart form parts in sequence.\n    Each file upload consists of two parts: a metadata part (JSON) followed\n    by the actual file part.\n\n    Args:\n        body (UploadFileBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: UploadFileBody,\n) -> Any | ErrorResponse | None:\n    \"\"\"Upload files to sandbox\n\n     Uploads one or multiple files to specified paths within the sandbox.\n    Reads metadata and file content from multipart form parts in sequence.\n    Each file upload consists of two parts: a metadata part (JSON) followed\n    by the actual file part.\n\n    Args:\n        body (UploadFileBody):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/health/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/health/ping.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...types import Response\n\n\ndef _get_kwargs() -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/ping\",\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None:\n    if response.status_code == 200:\n        return None\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any]:\n    \"\"\"Health check endpoint\n\n     Performs a simple health check to verify that the server is running and responsive.\n    Returns HTTP 200 OK status if the server is healthy. This endpoint is typically used\n    by load balancers, monitoring systems, and orchestration platforms (like Kubernetes)\n    to check service availability.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any]:\n    \"\"\"Health check endpoint\n\n     Performs a simple health check to verify that the server is running and responsive.\n    Returns HTTP 200 OK status if the server is healthy. This endpoint is typically used\n    by load balancers, monitoring systems, and orchestration platforms (like Kubernetes)\n    to check service availability.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/metric/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/metric/get_metrics.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.metrics import Metrics\nfrom ...types import Response\n\n\ndef _get_kwargs() -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/metrics\",\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | Metrics | None:\n    if response.status_code == 200:\n        response_200 = Metrics.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | Metrics]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Metrics]:\n    \"\"\"Get system metrics\n\n     Retrieves current system resource metrics including CPU usage percentage,\n    CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n    of system resource utilization at the time of request.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Metrics]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Metrics | None:\n    \"\"\"Get system metrics\n\n     Retrieves current system resource metrics including CPU usage percentage,\n    CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n    of system resource utilization at the time of request.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Metrics\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Metrics]:\n    \"\"\"Get system metrics\n\n     Retrieves current system resource metrics including CPU usage percentage,\n    CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n    of system resource utilization at the time of request.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Metrics]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Metrics | None:\n    \"\"\"Get system metrics\n\n     Retrieves current system resource metrics including CPU usage percentage,\n    CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n    of system resource utilization at the time of request.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Metrics\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/api/metric/watch_metrics.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.metrics import Metrics\nfrom ...types import Response\n\n\ndef _get_kwargs() -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/metrics/watch\",\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | Metrics | None:\n    if response.status_code == 200:\n        response_200 = Metrics.from_dict(response.text)\n\n        return response_200\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | Metrics]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Metrics]:\n    \"\"\"Watch system metrics in real-time\n\n     Streams system resource metrics in real-time using Server-Sent Events (SSE).\n    Updates are sent every second, providing continuous monitoring of CPU usage,\n    memory usage, and other system metrics. The connection remains open until\n    the client disconnects.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Metrics]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Metrics | None:\n    \"\"\"Watch system metrics in real-time\n\n     Streams system resource metrics in real-time using Server-Sent Events (SSE).\n    Updates are sent every second, providing continuous monitoring of CPU usage,\n    memory usage, and other system metrics. The connection remains open until\n    the client disconnects.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Metrics\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Metrics]:\n    \"\"\"Watch system metrics in real-time\n\n     Streams system resource metrics in real-time using Server-Sent Events (SSE).\n    Updates are sent every second, providing continuous monitoring of CPU usage,\n    memory usage, and other system metrics. The connection remains open until\n    the client disconnects.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Metrics]\n    \"\"\"\n\n    kwargs = _get_kwargs()\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Metrics | None:\n    \"\"\"Watch system metrics in real-time\n\n     Streams system resource metrics in real-time using Server-Sent Events (SSE).\n    Updates are sent every second, providing continuous monitoring of CPU usage,\n    memory usage, and other system metrics. The connection remains open until\n    the client disconnects.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Metrics\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/client.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nimport ssl\nfrom typing import Any\n\nimport httpx\nfrom attrs import define, evolve, field\n\n\n@define\nclass Client:\n    \"\"\"A class for keeping track of data related to the API\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    def with_headers(self, headers: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"Client\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n\n\n@define\nclass AuthenticatedClient:\n    \"\"\"A Client which has been authenticated for use on secured endpoints\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n        token: The token to use for authentication\n        prefix: The prefix to use for the Authorization header\n        auth_header_name: The name of the Authorization header\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    token: str\n    prefix: str = \"Bearer\"\n    auth_header_name: str = \"Authorization\"\n\n    def with_headers(self, headers: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/errors.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains shared errors types that can be raised from API functions\"\"\"\n\n\nclass UnexpectedStatus(Exception):\n    \"\"\"Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True\"\"\"\n\n    def __init__(self, status_code: int, content: bytes):\n        self.status_code = status_code\n        self.content = content\n\n        super().__init__(\n            f\"Unexpected status code: {status_code}\\n\\nResponse content:\\n{content.decode(errors='ignore')}\"\n        )\n\n\n__all__ = [\"UnexpectedStatus\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains all the data models used in inputs/outputs\"\"\"\n\nfrom .chmod_files_body import ChmodFilesBody\nfrom .code_context import CodeContext\nfrom .code_context_request import CodeContextRequest\nfrom .command_status_response import CommandStatusResponse\nfrom .error_response import ErrorResponse\nfrom .file_info import FileInfo\nfrom .file_metadata import FileMetadata\nfrom .get_files_info_response_200 import GetFilesInfoResponse200\nfrom .make_dirs_body import MakeDirsBody\nfrom .metrics import Metrics\nfrom .permission import Permission\nfrom .rename_file_item import RenameFileItem\nfrom .replace_content_body import ReplaceContentBody\nfrom .replace_file_content_item import ReplaceFileContentItem\nfrom .run_code_request import RunCodeRequest\nfrom .run_command_request import RunCommandRequest\nfrom .run_command_request_envs import RunCommandRequestEnvs\nfrom .server_stream_event import ServerStreamEvent\nfrom .server_stream_event_error import ServerStreamEventError\nfrom .server_stream_event_results import ServerStreamEventResults\nfrom .server_stream_event_type import ServerStreamEventType\nfrom .upload_file_body import UploadFileBody\n\n__all__ = (\n    \"ChmodFilesBody\",\n    \"CodeContext\",\n    \"CodeContextRequest\",\n    \"CommandStatusResponse\",\n    \"ErrorResponse\",\n    \"FileInfo\",\n    \"FileMetadata\",\n    \"GetFilesInfoResponse200\",\n    \"MakeDirsBody\",\n    \"Metrics\",\n    \"Permission\",\n    \"RenameFileItem\",\n    \"ReplaceContentBody\",\n    \"ReplaceFileContentItem\",\n    \"RunCodeRequest\",\n    \"RunCommandRequest\",\n    \"RunCommandRequestEnvs\",\n    \"ServerStreamEvent\",\n    \"ServerStreamEventError\",\n    \"ServerStreamEventResults\",\n    \"ServerStreamEventType\",\n    \"UploadFileBody\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/chmod_files_body.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nif TYPE_CHECKING:\n    from ..models.permission import Permission\n\n\nT = TypeVar(\"T\", bound=\"ChmodFilesBody\")\n\n\n@_attrs_define\nclass ChmodFilesBody:\n    \"\"\" \"\"\"\n\n    additional_properties: dict[str, Permission] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        for prop_name, prop in self.additional_properties.items():\n            field_dict[prop_name] = prop.to_dict()\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.permission import Permission\n\n        d = dict(src_dict)\n        chmod_files_body = cls()\n\n        additional_properties = {}\n        for prop_name, prop_dict in d.items():\n            additional_property = Permission.from_dict(prop_dict)\n\n            additional_properties[prop_name] = additional_property\n\n        chmod_files_body.additional_properties = additional_properties\n        return chmod_files_body\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Permission:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Permission) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/code_context.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"CodeContext\")\n\n\n@_attrs_define\nclass CodeContext:\n    \"\"\"Code execution context with session identifier\n\n    Attributes:\n        language (str): Execution runtime Example: python.\n        id (str | Unset): Unique session identifier returned by CreateContext Example: session-abc123.\n    \"\"\"\n\n    language: str\n    id: str | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        language = self.language\n\n        id = self.id\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"language\": language,\n            }\n        )\n        if id is not UNSET:\n            field_dict[\"id\"] = id\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        language = d.pop(\"language\")\n\n        id = d.pop(\"id\", UNSET)\n\n        code_context = cls(\n            language=language,\n            id=id,\n        )\n\n        code_context.additional_properties = d\n        return code_context\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/code_context_request.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"CodeContextRequest\")\n\n\n@_attrs_define\nclass CodeContextRequest:\n    \"\"\"Request to create a code execution context\n\n    Attributes:\n        language (str | Unset): Execution runtime (python, bash, java, etc.) Example: python.\n    \"\"\"\n\n    language: str | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        language = self.language\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if language is not UNSET:\n            field_dict[\"language\"] = language\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        language = d.pop(\"language\", UNSET)\n\n        code_context_request = cls(\n            language=language,\n        )\n\n        code_context_request.additional_properties = d\n        return code_context_request\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/command_status_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\nfrom dateutil.parser import isoparse\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"CommandStatusResponse\")\n\n\n@_attrs_define\nclass CommandStatusResponse:\n    \"\"\"Command execution status (foreground or background)\n\n    Attributes:\n        id (str | Unset): Command ID returned by RunCommand Example: cmd-abc123.\n        content (str | Unset): Original command content Example: ls -la.\n        running (bool | Unset): Whether the command is still running\n        exit_code (int | None | Unset): Exit code if the command has finished\n        error (str | Unset): Error message if the command failed Example: permission denied.\n        started_at (datetime.datetime | Unset): Start time in RFC3339 format Example: 2025-12-22T09:08:05Z.\n        finished_at (datetime.datetime | None | Unset): Finish time in RFC3339 format (null if still running) Example:\n            2025-12-22T09:08:09Z.\n    \"\"\"\n\n    id: str | Unset = UNSET\n    content: str | Unset = UNSET\n    running: bool | Unset = UNSET\n    exit_code: int | None | Unset = UNSET\n    error: str | Unset = UNSET\n    started_at: datetime.datetime | Unset = UNSET\n    finished_at: datetime.datetime | None | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        id = self.id\n\n        content = self.content\n\n        running = self.running\n\n        exit_code: int | None | Unset\n        if isinstance(self.exit_code, Unset):\n            exit_code = UNSET\n        else:\n            exit_code = self.exit_code\n\n        error = self.error\n\n        started_at: str | Unset = UNSET\n        if not isinstance(self.started_at, Unset):\n            started_at = self.started_at.isoformat()\n\n        finished_at: None | str | Unset\n        if isinstance(self.finished_at, Unset):\n            finished_at = UNSET\n        elif isinstance(self.finished_at, datetime.datetime):\n            finished_at = self.finished_at.isoformat()\n        else:\n            finished_at = self.finished_at\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if id is not UNSET:\n            field_dict[\"id\"] = id\n        if content is not UNSET:\n            field_dict[\"content\"] = content\n        if running is not UNSET:\n            field_dict[\"running\"] = running\n        if exit_code is not UNSET:\n            field_dict[\"exit_code\"] = exit_code\n        if error is not UNSET:\n            field_dict[\"error\"] = error\n        if started_at is not UNSET:\n            field_dict[\"started_at\"] = started_at\n        if finished_at is not UNSET:\n            field_dict[\"finished_at\"] = finished_at\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        id = d.pop(\"id\", UNSET)\n\n        content = d.pop(\"content\", UNSET)\n\n        running = d.pop(\"running\", UNSET)\n\n        def _parse_exit_code(data: object) -> int | None | Unset:\n            if data is None:\n                return data\n            if isinstance(data, Unset):\n                return data\n            return cast(int | None | Unset, data)\n\n        exit_code = _parse_exit_code(d.pop(\"exit_code\", UNSET))\n\n        error = d.pop(\"error\", UNSET)\n\n        _started_at = d.pop(\"started_at\", UNSET)\n        started_at: datetime.datetime | Unset\n        if isinstance(_started_at, Unset):\n            started_at = UNSET\n        else:\n            started_at = isoparse(_started_at)\n\n        def _parse_finished_at(data: object) -> datetime.datetime | None | Unset:\n            if data is None:\n                return data\n            if isinstance(data, Unset):\n                return data\n            try:\n                if not isinstance(data, str):\n                    raise TypeError()\n                finished_at_type_0 = isoparse(data)\n\n                return finished_at_type_0\n            except (TypeError, ValueError, AttributeError, KeyError):\n                pass\n            return cast(datetime.datetime | None | Unset, data)\n\n        finished_at = _parse_finished_at(d.pop(\"finished_at\", UNSET))\n\n        command_status_response = cls(\n            id=id,\n            content=content,\n            running=running,\n            exit_code=exit_code,\n            error=error,\n            started_at=started_at,\n            finished_at=finished_at,\n        )\n\n        command_status_response.additional_properties = d\n        return command_status_response\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/error_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"ErrorResponse\")\n\n\n@_attrs_define\nclass ErrorResponse:\n    \"\"\"Standard error response format\n\n    Attributes:\n        code (str): Error code for programmatic handling Example: INVALID_REQUEST_BODY.\n        message (str): Human-readable error message Example: error parsing request, MAYBE invalid body format.\n    \"\"\"\n\n    code: str\n    message: str\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        code = self.code\n\n        message = self.message\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"code\": code,\n                \"message\": message,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        code = d.pop(\"code\")\n\n        message = d.pop(\"message\")\n\n        error_response = cls(\n            code=code,\n            message=message,\n        )\n\n        error_response.additional_properties = d\n        return error_response\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/file_info.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\nfrom dateutil.parser import isoparse\n\nT = TypeVar(\"T\", bound=\"FileInfo\")\n\n\n@_attrs_define\nclass FileInfo:\n    \"\"\"File metadata including path and permissions\n\n    Attributes:\n        path (str): Absolute file path Example: /workspace/file.txt.\n        size (int): File size in bytes Example: 2048.\n        modified_at (datetime.datetime): Last modification time Example: 2025-11-16 14:30:45+00:00.\n        created_at (datetime.datetime): File creation time Example: 2025-11-16 14:30:45+00:00.\n        owner (str): File owner username Example: admin.\n        group (str): File group name Example: admin.\n        mode (int): File permissions in octal format Example: 755.\n    \"\"\"\n\n    path: str\n    size: int\n    modified_at: datetime.datetime\n    created_at: datetime.datetime\n    owner: str\n    group: str\n    mode: int\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        path = self.path\n\n        size = self.size\n\n        modified_at = self.modified_at.isoformat()\n\n        created_at = self.created_at.isoformat()\n\n        owner = self.owner\n\n        group = self.group\n\n        mode = self.mode\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"path\": path,\n                \"size\": size,\n                \"modified_at\": modified_at,\n                \"created_at\": created_at,\n                \"owner\": owner,\n                \"group\": group,\n                \"mode\": mode,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        path = d.pop(\"path\")\n\n        size = d.pop(\"size\")\n\n        modified_at = isoparse(d.pop(\"modified_at\"))\n\n        created_at = isoparse(d.pop(\"created_at\"))\n\n        owner = d.pop(\"owner\")\n\n        group = d.pop(\"group\")\n\n        mode = d.pop(\"mode\")\n\n        file_info = cls(\n            path=path,\n            size=size,\n            modified_at=modified_at,\n            created_at=created_at,\n            owner=owner,\n            group=group,\n            mode=mode,\n        )\n\n        file_info.additional_properties = d\n        return file_info\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/file_metadata.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"FileMetadata\")\n\n\n@_attrs_define\nclass FileMetadata:\n    \"\"\"File metadata for upload operations\n\n    Attributes:\n        path (str | Unset): Target file path Example: /workspace/upload.txt.\n        owner (str | Unset): File owner Example: admin.\n        group (str | Unset): File group Example: admin.\n        mode (int | Unset): File permissions in octal Example: 755.\n    \"\"\"\n\n    path: str | Unset = UNSET\n    owner: str | Unset = UNSET\n    group: str | Unset = UNSET\n    mode: int | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        path = self.path\n\n        owner = self.owner\n\n        group = self.group\n\n        mode = self.mode\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if path is not UNSET:\n            field_dict[\"path\"] = path\n        if owner is not UNSET:\n            field_dict[\"owner\"] = owner\n        if group is not UNSET:\n            field_dict[\"group\"] = group\n        if mode is not UNSET:\n            field_dict[\"mode\"] = mode\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        path = d.pop(\"path\", UNSET)\n\n        owner = d.pop(\"owner\", UNSET)\n\n        group = d.pop(\"group\", UNSET)\n\n        mode = d.pop(\"mode\", UNSET)\n\n        file_metadata = cls(\n            path=path,\n            owner=owner,\n            group=group,\n            mode=mode,\n        )\n\n        file_metadata.additional_properties = d\n        return file_metadata\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/get_files_info_response_200.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nif TYPE_CHECKING:\n    from ..models.file_info import FileInfo\n\n\nT = TypeVar(\"T\", bound=\"GetFilesInfoResponse200\")\n\n\n@_attrs_define\nclass GetFilesInfoResponse200:\n    \"\"\" \"\"\"\n\n    additional_properties: dict[str, FileInfo] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        for prop_name, prop in self.additional_properties.items():\n            field_dict[prop_name] = prop.to_dict()\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.file_info import FileInfo\n\n        d = dict(src_dict)\n        get_files_info_response_200 = cls()\n\n        additional_properties = {}\n        for prop_name, prop_dict in d.items():\n            additional_property = FileInfo.from_dict(prop_dict)\n\n            additional_properties[prop_name] = additional_property\n\n        get_files_info_response_200.additional_properties = additional_properties\n        return get_files_info_response_200\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> FileInfo:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: FileInfo) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/make_dirs_body.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nif TYPE_CHECKING:\n    from ..models.permission import Permission\n\n\nT = TypeVar(\"T\", bound=\"MakeDirsBody\")\n\n\n@_attrs_define\nclass MakeDirsBody:\n    \"\"\" \"\"\"\n\n    additional_properties: dict[str, Permission] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        for prop_name, prop in self.additional_properties.items():\n            field_dict[prop_name] = prop.to_dict()\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.permission import Permission\n\n        d = dict(src_dict)\n        make_dirs_body = cls()\n\n        additional_properties = {}\n        for prop_name, prop_dict in d.items():\n            additional_property = Permission.from_dict(prop_dict)\n\n            additional_properties[prop_name] = additional_property\n\n        make_dirs_body.additional_properties = additional_properties\n        return make_dirs_body\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Permission:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Permission) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/metrics.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"Metrics\")\n\n\n@_attrs_define\nclass Metrics:\n    \"\"\"System resource usage metrics\n\n    Attributes:\n        cpu_count (float): Number of CPU cores Example: 4.0.\n        cpu_used_pct (float): CPU usage percentage Example: 45.5.\n        mem_total_mib (float): Total memory in MiB Example: 8192.0.\n        mem_used_mib (float): Used memory in MiB Example: 4096.0.\n        timestamp (int): Timestamp when metrics were collected (Unix milliseconds) Example: 1700000000000.\n    \"\"\"\n\n    cpu_count: float\n    cpu_used_pct: float\n    mem_total_mib: float\n    mem_used_mib: float\n    timestamp: int\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        cpu_count = self.cpu_count\n\n        cpu_used_pct = self.cpu_used_pct\n\n        mem_total_mib = self.mem_total_mib\n\n        mem_used_mib = self.mem_used_mib\n\n        timestamp = self.timestamp\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"cpu_count\": cpu_count,\n                \"cpu_used_pct\": cpu_used_pct,\n                \"mem_total_mib\": mem_total_mib,\n                \"mem_used_mib\": mem_used_mib,\n                \"timestamp\": timestamp,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        cpu_count = d.pop(\"cpu_count\")\n\n        cpu_used_pct = d.pop(\"cpu_used_pct\")\n\n        mem_total_mib = d.pop(\"mem_total_mib\")\n\n        mem_used_mib = d.pop(\"mem_used_mib\")\n\n        timestamp = d.pop(\"timestamp\")\n\n        metrics = cls(\n            cpu_count=cpu_count,\n            cpu_used_pct=cpu_used_pct,\n            mem_total_mib=mem_total_mib,\n            mem_used_mib=mem_used_mib,\n            timestamp=timestamp,\n        )\n\n        metrics.additional_properties = d\n        return metrics\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/permission.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"Permission\")\n\n\n@_attrs_define\nclass Permission:\n    \"\"\"File ownership and mode settings\n\n    Attributes:\n        mode (int): Permission mode in octal format (e.g., 644, 755) Default: 755. Example: 755.\n        owner (str | Unset): Owner username Example: root.\n        group (str | Unset): Group name Example: root.\n    \"\"\"\n\n    mode: int = 755\n    owner: str | Unset = UNSET\n    group: str | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        mode = self.mode\n\n        owner = self.owner\n\n        group = self.group\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"mode\": mode,\n            }\n        )\n        if owner is not UNSET:\n            field_dict[\"owner\"] = owner\n        if group is not UNSET:\n            field_dict[\"group\"] = group\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        mode = d.pop(\"mode\")\n\n        owner = d.pop(\"owner\", UNSET)\n\n        group = d.pop(\"group\", UNSET)\n\n        permission = cls(\n            mode=mode,\n            owner=owner,\n            group=group,\n        )\n\n        permission.additional_properties = d\n        return permission\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/rename_file_item.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"RenameFileItem\")\n\n\n@_attrs_define\nclass RenameFileItem:\n    \"\"\"File rename/move operation\n\n    Attributes:\n        src (str): Source file path Example: /workspace/old.txt.\n        dest (str): Destination file path Example: /workspace/new.txt.\n    \"\"\"\n\n    src: str\n    dest: str\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        src = self.src\n\n        dest = self.dest\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"src\": src,\n                \"dest\": dest,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        src = d.pop(\"src\")\n\n        dest = d.pop(\"dest\")\n\n        rename_file_item = cls(\n            src=src,\n            dest=dest,\n        )\n\n        rename_file_item.additional_properties = d\n        return rename_file_item\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/replace_content_body.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nif TYPE_CHECKING:\n    from ..models.replace_file_content_item import ReplaceFileContentItem\n\n\nT = TypeVar(\"T\", bound=\"ReplaceContentBody\")\n\n\n@_attrs_define\nclass ReplaceContentBody:\n    \"\"\" \"\"\"\n\n    additional_properties: dict[str, ReplaceFileContentItem] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        for prop_name, prop in self.additional_properties.items():\n            field_dict[prop_name] = prop.to_dict()\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.replace_file_content_item import ReplaceFileContentItem\n\n        d = dict(src_dict)\n        replace_content_body = cls()\n\n        additional_properties = {}\n        for prop_name, prop_dict in d.items():\n            additional_property = ReplaceFileContentItem.from_dict(prop_dict)\n\n            additional_properties[prop_name] = additional_property\n\n        replace_content_body.additional_properties = additional_properties\n        return replace_content_body\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> ReplaceFileContentItem:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: ReplaceFileContentItem) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/replace_file_content_item.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"ReplaceFileContentItem\")\n\n\n@_attrs_define\nclass ReplaceFileContentItem:\n    \"\"\"Content replacement operation\n\n    Attributes:\n        old (str): String to be replaced Example: localhost.\n        new (str): Replacement string Example: 0.0.0.0.\n    \"\"\"\n\n    old: str\n    new: str\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        old = self.old\n\n        new = self.new\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"old\": old,\n                \"new\": new,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        old = d.pop(\"old\")\n\n        new = d.pop(\"new\")\n\n        replace_file_content_item = cls(\n            old=old,\n            new=new,\n        )\n\n        replace_file_content_item.additional_properties = d\n        return replace_file_content_item\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/run_code_request.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.code_context import CodeContext\n\n\nT = TypeVar(\"T\", bound=\"RunCodeRequest\")\n\n\n@_attrs_define\nclass RunCodeRequest:\n    \"\"\"Request to execute code in a context\n\n    Attributes:\n        code (str): Source code to execute Example: import numpy as np\n            result = np.array([1, 2, 3])\n            print(result)\n            .\n        context (CodeContext | Unset): Code execution context with session identifier\n    \"\"\"\n\n    code: str\n    context: CodeContext | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        code = self.code\n\n        context: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.context, Unset):\n            context = self.context.to_dict()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"code\": code,\n            }\n        )\n        if context is not UNSET:\n            field_dict[\"context\"] = context\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.code_context import CodeContext\n\n        d = dict(src_dict)\n        code = d.pop(\"code\")\n\n        _context = d.pop(\"context\", UNSET)\n        context: CodeContext | Unset\n        if isinstance(_context, Unset):\n            context = UNSET\n        else:\n            context = CodeContext.from_dict(_context)\n\n        run_code_request = cls(\n            code=code,\n            context=context,\n        )\n\n        run_code_request.additional_properties = d\n        return run_code_request\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.run_command_request_envs import RunCommandRequestEnvs\n\n\nT = TypeVar(\"T\", bound=\"RunCommandRequest\")\n\n\n@_attrs_define\nclass RunCommandRequest:\n    \"\"\"Request to execute a shell command\n\n    Attributes:\n        command (str): Shell command to execute Example: ls -la /workspace.\n        cwd (str | Unset): Working directory for command execution Example: /workspace.\n        background (bool | Unset): Whether to run command in detached mode Default: False.\n        timeout (int | Unset): Maximum allowed execution time in milliseconds before the command is forcefully\n            terminated by the server. If omitted, the server will not enforce any timeout. Example: 60000.\n        uid (int | Unset): Unix user ID used to run the command. If `gid` is provided, `uid` is required.\n             Example: 1000.\n        gid (int | Unset): Unix group ID used to run the command. Requires `uid` to be provided.\n             Example: 1000.\n        envs (RunCommandRequestEnvs | Unset): Environment variables injected into the command process. Example: {'PATH':\n            '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PYTHONUNBUFFERED': '1'}.\n    \"\"\"\n\n    command: str\n    cwd: str | Unset = UNSET\n    background: bool | Unset = False\n    timeout: int | Unset = UNSET\n    uid: int | Unset = UNSET\n    gid: int | Unset = UNSET\n    envs: RunCommandRequestEnvs | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        command = self.command\n\n        cwd = self.cwd\n\n        background = self.background\n\n        timeout = self.timeout\n\n        uid = self.uid\n\n        gid = self.gid\n\n        envs: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.envs, Unset):\n            envs = self.envs.to_dict()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"command\": command,\n            }\n        )\n        if cwd is not UNSET:\n            field_dict[\"cwd\"] = cwd\n        if background is not UNSET:\n            field_dict[\"background\"] = background\n        if timeout is not UNSET:\n            field_dict[\"timeout\"] = timeout\n        if uid is not UNSET:\n            field_dict[\"uid\"] = uid\n        if gid is not UNSET:\n            field_dict[\"gid\"] = gid\n        if envs is not UNSET:\n            field_dict[\"envs\"] = envs\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.run_command_request_envs import RunCommandRequestEnvs\n\n        d = dict(src_dict)\n        command = d.pop(\"command\")\n\n        cwd = d.pop(\"cwd\", UNSET)\n\n        background = d.pop(\"background\", UNSET)\n\n        timeout = d.pop(\"timeout\", UNSET)\n\n        uid = d.pop(\"uid\", UNSET)\n\n        gid = d.pop(\"gid\", UNSET)\n\n        _envs = d.pop(\"envs\", UNSET)\n        envs: RunCommandRequestEnvs | Unset\n        if isinstance(_envs, Unset):\n            envs = UNSET\n        else:\n            envs = RunCommandRequestEnvs.from_dict(_envs)\n\n        run_command_request = cls(\n            command=command,\n            cwd=cwd,\n            background=background,\n            timeout=timeout,\n            uid=uid,\n            gid=gid,\n            envs=envs,\n        )\n\n        run_command_request.additional_properties = d\n        return run_command_request\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/run_command_request_envs.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"RunCommandRequestEnvs\")\n\n\n@_attrs_define\nclass RunCommandRequestEnvs:\n    \"\"\"Environment variables injected into the command process.\n\n    Example:\n        {'PATH': '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PYTHONUNBUFFERED': '1'}\n\n    \"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        run_command_request_envs = cls()\n\n        run_command_request_envs.additional_properties = d\n        return run_command_request_envs\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/server_stream_event.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..models.server_stream_event_type import ServerStreamEventType\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.server_stream_event_error import ServerStreamEventError\n    from ..models.server_stream_event_results import ServerStreamEventResults\n\n\nT = TypeVar(\"T\", bound=\"ServerStreamEvent\")\n\n\n@_attrs_define\nclass ServerStreamEvent:\n    \"\"\"Server-sent event for streaming execution output\n\n    Attributes:\n        type_ (ServerStreamEventType | Unset): Event type for client-side handling Example: stdout.\n        text (str | Unset): Textual data for status, init, and stream events Example: Hello, World!\n            .\n        execution_count (int | Unset): Cell execution number in the session Example: 1.\n        execution_time (int | Unset): Execution duration in milliseconds Example: 150.\n        timestamp (int | Unset): When the event was generated (Unix milliseconds) Example: 1700000000000.\n        results (ServerStreamEventResults | Unset): Execution output in various MIME types (e.g., \"text/plain\",\n            \"text/html\") Example: {'text/plain': '4'}.\n        error (ServerStreamEventError | Unset): Execution error details if an error occurred\n    \"\"\"\n\n    type_: ServerStreamEventType | Unset = UNSET\n    text: str | Unset = UNSET\n    execution_count: int | Unset = UNSET\n    execution_time: int | Unset = UNSET\n    timestamp: int | Unset = UNSET\n    results: ServerStreamEventResults | Unset = UNSET\n    error: ServerStreamEventError | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        type_: str | Unset = UNSET\n        if not isinstance(self.type_, Unset):\n            type_ = self.type_.value\n\n        text = self.text\n\n        execution_count = self.execution_count\n\n        execution_time = self.execution_time\n\n        timestamp = self.timestamp\n\n        results: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.results, Unset):\n            results = self.results.to_dict()\n\n        error: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.error, Unset):\n            error = self.error.to_dict()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if type_ is not UNSET:\n            field_dict[\"type\"] = type_\n        if text is not UNSET:\n            field_dict[\"text\"] = text\n        if execution_count is not UNSET:\n            field_dict[\"execution_count\"] = execution_count\n        if execution_time is not UNSET:\n            field_dict[\"execution_time\"] = execution_time\n        if timestamp is not UNSET:\n            field_dict[\"timestamp\"] = timestamp\n        if results is not UNSET:\n            field_dict[\"results\"] = results\n        if error is not UNSET:\n            field_dict[\"error\"] = error\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.server_stream_event_error import ServerStreamEventError\n        from ..models.server_stream_event_results import ServerStreamEventResults\n\n        d = dict(src_dict)\n        _type_ = d.pop(\"type\", UNSET)\n        type_: ServerStreamEventType | Unset\n        if isinstance(_type_, Unset):\n            type_ = UNSET\n        else:\n            type_ = ServerStreamEventType(_type_)\n\n        text = d.pop(\"text\", UNSET)\n\n        execution_count = d.pop(\"execution_count\", UNSET)\n\n        execution_time = d.pop(\"execution_time\", UNSET)\n\n        timestamp = d.pop(\"timestamp\", UNSET)\n\n        _results = d.pop(\"results\", UNSET)\n        results: ServerStreamEventResults | Unset\n        if isinstance(_results, Unset):\n            results = UNSET\n        else:\n            results = ServerStreamEventResults.from_dict(_results)\n\n        _error = d.pop(\"error\", UNSET)\n        error: ServerStreamEventError | Unset\n        if isinstance(_error, Unset):\n            error = UNSET\n        else:\n            error = ServerStreamEventError.from_dict(_error)\n\n        server_stream_event = cls(\n            type_=type_,\n            text=text,\n            execution_count=execution_count,\n            execution_time=execution_time,\n            timestamp=timestamp,\n            results=results,\n            error=error,\n        )\n\n        server_stream_event.additional_properties = d\n        return server_stream_event\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/server_stream_event_error.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"ServerStreamEventError\")\n\n\n@_attrs_define\nclass ServerStreamEventError:\n    \"\"\"Execution error details if an error occurred\n\n    Attributes:\n        ename (str | Unset): Error name/type Example: NameError.\n        evalue (str | Unset): Error value/message Example: name 'undefined_var' is not defined.\n        traceback (list[str] | Unset): Stack trace lines Example: ['Traceback (most recent call last):', '  File\n            \"<stdin>\", line 1, in <module>', \"NameError: name 'undefined_var' is not defined\"].\n    \"\"\"\n\n    ename: str | Unset = UNSET\n    evalue: str | Unset = UNSET\n    traceback: list[str] | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        ename = self.ename\n\n        evalue = self.evalue\n\n        traceback: list[str] | Unset = UNSET\n        if not isinstance(self.traceback, Unset):\n            traceback = self.traceback\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if ename is not UNSET:\n            field_dict[\"ename\"] = ename\n        if evalue is not UNSET:\n            field_dict[\"evalue\"] = evalue\n        if traceback is not UNSET:\n            field_dict[\"traceback\"] = traceback\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        ename = d.pop(\"ename\", UNSET)\n\n        evalue = d.pop(\"evalue\", UNSET)\n\n        traceback = cast(list[str], d.pop(\"traceback\", UNSET))\n\n        server_stream_event_error = cls(\n            ename=ename,\n            evalue=evalue,\n            traceback=traceback,\n        )\n\n        server_stream_event_error.additional_properties = d\n        return server_stream_event_error\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/server_stream_event_results.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"ServerStreamEventResults\")\n\n\n@_attrs_define\nclass ServerStreamEventResults:\n    \"\"\"Execution output in various MIME types (e.g., \"text/plain\", \"text/html\")\n\n    Example:\n        {'text/plain': '4'}\n\n    \"\"\"\n\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        server_stream_event_results = cls()\n\n        server_stream_event_results.additional_properties = d\n        return server_stream_event_results\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/server_stream_event_type.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass ServerStreamEventType(str, Enum):\n    ERROR = \"error\"\n    EXECUTION_COMPLETE = \"execution_complete\"\n    EXECUTION_COUNT = \"execution_count\"\n    INIT = \"init\"\n    PING = \"ping\"\n    RESULT = \"result\"\n    STATUS = \"status\"\n    STDERR = \"stderr\"\n    STDOUT = \"stdout\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/models/upload_file_body.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom io import BytesIO\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom .. import types\nfrom ..types import UNSET, File, FileTypes, Unset\n\nT = TypeVar(\"T\", bound=\"UploadFileBody\")\n\n\n@_attrs_define\nclass UploadFileBody:\n    \"\"\"\n    Attributes:\n        metadata (str | Unset): JSON-encoded file metadata (FileMetadata object) Example:\n            {\"path\":\"/workspace/file.txt\",\"owner\":\"admin\",\"group\":\"admin\",\"mode\":755}.\n        file (File | Unset): File to upload\n    \"\"\"\n\n    metadata: str | Unset = UNSET\n    file: File | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        metadata = self.metadata\n\n        file: FileTypes | Unset = UNSET\n        if not isinstance(self.file, Unset):\n            file = self.file.to_tuple()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update({})\n        if metadata is not UNSET:\n            field_dict[\"metadata\"] = metadata\n        if file is not UNSET:\n            field_dict[\"file\"] = file\n\n        return field_dict\n\n    def to_multipart(self) -> types.RequestFiles:\n        files: types.RequestFiles = []\n\n        if not isinstance(self.metadata, Unset):\n            files.append((\"metadata\", (None, str(self.metadata).encode(), \"text/plain\")))\n\n        if not isinstance(self.file, Unset):\n            files.append((\"file\", self.file.to_tuple()))\n\n        for prop_name, prop in self.additional_properties.items():\n            files.append((prop_name, (None, str(prop).encode(), \"text/plain\")))\n\n        return files\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        metadata = d.pop(\"metadata\", UNSET)\n\n        _file = d.pop(\"file\", UNSET)\n        file: File | Unset\n        if isinstance(_file, Unset):\n            file = UNSET\n        else:\n            file = File(payload=BytesIO(_file))\n\n        upload_file_body = cls(\n            metadata=metadata,\n            file=file,\n        )\n\n        upload_file_body.additional_properties = d\n        return upload_file_body\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/py.typed",
    "content": "# Marker file for PEP 561"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/execd/types.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains some shared types for properties\"\"\"\n\nfrom collections.abc import Mapping, MutableMapping\nfrom http import HTTPStatus\nfrom typing import IO, BinaryIO, Generic, Literal, TypeVar\n\nfrom attrs import define\n\n\nclass Unset:\n    def __bool__(self) -> Literal[False]:\n        return False\n\n\nUNSET: Unset = Unset()\n\n# The types that `httpx.Client(files=)` can accept, copied from that library.\nFileContent = IO[bytes] | bytes | str\nFileTypes = (\n    # (filename, file (or bytes), content_type)\n    tuple[str | None, FileContent, str | None]\n    # (filename, file (or bytes), content_type, headers)\n    | tuple[str | None, FileContent, str | None, Mapping[str, str]]\n)\nRequestFiles = list[tuple[str, FileTypes]]\n\n\n@define\nclass File:\n    \"\"\"Contains information for file uploads\"\"\"\n\n    payload: BinaryIO\n    file_name: str | None = None\n    mime_type: str | None = None\n\n    def to_tuple(self) -> FileTypes:\n        \"\"\"Return a tuple representation that httpx will accept for multipart/form-data\"\"\"\n        return self.file_name, self.payload, self.mime_type\n\n\nT = TypeVar(\"T\")\n\n\n@define\nclass Response(Generic[T]):\n    \"\"\"A response from an endpoint\"\"\"\n\n    status_code: HTTPStatus\n    content: bytes\n    headers: MutableMapping[str, str]\n    parsed: T | None\n\n\n__all__ = [\"UNSET\", \"File\", \"FileTypes\", \"RequestFiles\", \"Response\", \"Unset\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"A client library for accessing OpenSandbox Lifecycle API\"\"\"\n\nfrom .client import AuthenticatedClient, Client\n\n__all__ = (\n    \"AuthenticatedClient\",\n    \"Client\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains methods for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains endpoint functions for accessing the API\"\"\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/delete_sandboxes_sandbox_id.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"delete\",\n        \"url\": \"/sandboxes/{sandbox_id}\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 204:\n        response_204 = cast(Any, None)\n        return response_204\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 409:\n        response_409 = ErrorResponse.from_dict(response.json())\n\n        return response_409\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete a sandbox\n\n     Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to\n    Terminated.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete a sandbox\n\n     Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to\n    Terminated.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Delete a sandbox\n\n     Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to\n    Terminated.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Delete a sandbox\n\n     Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to\n    Terminated.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/get_sandboxes.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.list_sandboxes_response import ListSandboxesResponse\nfrom ...types import UNSET, Response, Unset\n\n\ndef _get_kwargs(\n    *,\n    state: list[str] | Unset = UNSET,\n    metadata: str | Unset = UNSET,\n    page: int | Unset = 1,\n    page_size: int | Unset = 20,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    json_state: list[str] | Unset = UNSET\n    if not isinstance(state, Unset):\n        json_state = state\n\n    params[\"state\"] = json_state\n\n    params[\"metadata\"] = metadata\n\n    params[\"page\"] = page\n\n    params[\"pageSize\"] = page_size\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/sandboxes\",\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | ListSandboxesResponse | None:\n    if response.status_code == 200:\n        response_200 = ListSandboxesResponse.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | ListSandboxesResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    state: list[str] | Unset = UNSET,\n    metadata: str | Unset = UNSET,\n    page: int | Unset = 1,\n    page_size: int | Unset = 20,\n) -> Response[ErrorResponse | ListSandboxesResponse]:\n    \"\"\"List sandboxes\n\n     List all sandboxes with optional filtering and pagination using query parameters.\n    All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n\n    Args:\n        state (list[str] | Unset):\n        metadata (str | Unset):\n        page (int | Unset):  Default: 1.\n        page_size (int | Unset):  Default: 20.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ListSandboxesResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        state=state,\n        metadata=metadata,\n        page=page,\n        page_size=page_size,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    state: list[str] | Unset = UNSET,\n    metadata: str | Unset = UNSET,\n    page: int | Unset = 1,\n    page_size: int | Unset = 20,\n) -> ErrorResponse | ListSandboxesResponse | None:\n    \"\"\"List sandboxes\n\n     List all sandboxes with optional filtering and pagination using query parameters.\n    All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n\n    Args:\n        state (list[str] | Unset):\n        metadata (str | Unset):\n        page (int | Unset):  Default: 1.\n        page_size (int | Unset):  Default: 20.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ListSandboxesResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        state=state,\n        metadata=metadata,\n        page=page,\n        page_size=page_size,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    state: list[str] | Unset = UNSET,\n    metadata: str | Unset = UNSET,\n    page: int | Unset = 1,\n    page_size: int | Unset = 20,\n) -> Response[ErrorResponse | ListSandboxesResponse]:\n    \"\"\"List sandboxes\n\n     List all sandboxes with optional filtering and pagination using query parameters.\n    All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n\n    Args:\n        state (list[str] | Unset):\n        metadata (str | Unset):\n        page (int | Unset):  Default: 1.\n        page_size (int | Unset):  Default: 20.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | ListSandboxesResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        state=state,\n        metadata=metadata,\n        page=page,\n        page_size=page_size,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    state: list[str] | Unset = UNSET,\n    metadata: str | Unset = UNSET,\n    page: int | Unset = 1,\n    page_size: int | Unset = 20,\n) -> ErrorResponse | ListSandboxesResponse | None:\n    \"\"\"List sandboxes\n\n     List all sandboxes with optional filtering and pagination using query parameters.\n    All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n\n    Args:\n        state (list[str] | Unset):\n        metadata (str | Unset):\n        page (int | Unset):  Default: 1.\n        page_size (int | Unset):  Default: 20.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | ListSandboxesResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            state=state,\n            metadata=metadata,\n            page=page,\n            page_size=page_size,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/get_sandboxes_sandbox_id.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.sandbox import Sandbox\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/sandboxes/{sandbox_id}\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | Sandbox | None:\n    if response.status_code == 200:\n        response_200 = Sandbox.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | Sandbox]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Sandbox]:\n    \"\"\"Fetch a sandbox by id\n\n     Returns the complete sandbox information including:\n    - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n    - `image`: Container image specification (not included in create response)\n    - `entrypoint`: Entry process specification\n\n    This is the complete representation of the sandbox resource.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Sandbox]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Sandbox | None:\n    \"\"\"Fetch a sandbox by id\n\n     Returns the complete sandbox information including:\n    - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n    - `image`: Container image specification (not included in create response)\n    - `entrypoint`: Entry process specification\n\n    This is the complete representation of the sandbox resource.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Sandbox\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[ErrorResponse | Sandbox]:\n    \"\"\"Fetch a sandbox by id\n\n     Returns the complete sandbox information including:\n    - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n    - `image`: Container image specification (not included in create response)\n    - `entrypoint`: Entry process specification\n\n    This is the complete representation of the sandbox resource.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | Sandbox]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> ErrorResponse | Sandbox | None:\n    \"\"\"Fetch a sandbox by id\n\n     Returns the complete sandbox information including:\n    - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n    - `image`: Container image specification (not included in create response)\n    - `entrypoint`: Entry process specification\n\n    This is the complete representation of the sandbox resource.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | Sandbox\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/get_sandboxes_sandbox_id_endpoints_port.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.endpoint import Endpoint\nfrom ...models.error_response import ErrorResponse\nfrom ...types import UNSET, Response, Unset\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n    port: int,\n    *,\n    use_server_proxy: bool | Unset = False,\n) -> dict[str, Any]:\n    params: dict[str, Any] = {}\n\n    params[\"use_server_proxy\"] = use_server_proxy\n\n    params = {k: v for k, v in params.items() if v is not UNSET and v is not None}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"get\",\n        \"url\": \"/sandboxes/{sandbox_id}/endpoints/{port}\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n            port=quote(str(port), safe=\"\"),\n        ),\n        \"params\": params,\n    }\n\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Endpoint | ErrorResponse | None:\n    if response.status_code == 200:\n        response_200 = Endpoint.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[Endpoint | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    port: int,\n    *,\n    client: AuthenticatedClient | Client,\n    use_server_proxy: bool | Unset = False,\n) -> Response[Endpoint | ErrorResponse]:\n    \"\"\"Get sandbox access endpoint\n\n     Get the public access endpoint URL for accessing a service running on a specific port\n    within the sandbox. The service must be listening on the specified port inside\n    the sandbox for the endpoint to be available.\n\n    Args:\n        sandbox_id (str):\n        port (int):\n        use_server_proxy (bool | Unset):  Default: False.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Endpoint | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n        port=port,\n        use_server_proxy=use_server_proxy,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    port: int,\n    *,\n    client: AuthenticatedClient | Client,\n    use_server_proxy: bool | Unset = False,\n) -> Endpoint | ErrorResponse | None:\n    \"\"\"Get sandbox access endpoint\n\n     Get the public access endpoint URL for accessing a service running on a specific port\n    within the sandbox. The service must be listening on the specified port inside\n    the sandbox for the endpoint to be available.\n\n    Args:\n        sandbox_id (str):\n        port (int):\n        use_server_proxy (bool | Unset):  Default: False.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Endpoint | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        port=port,\n        client=client,\n        use_server_proxy=use_server_proxy,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    port: int,\n    *,\n    client: AuthenticatedClient | Client,\n    use_server_proxy: bool | Unset = False,\n) -> Response[Endpoint | ErrorResponse]:\n    \"\"\"Get sandbox access endpoint\n\n     Get the public access endpoint URL for accessing a service running on a specific port\n    within the sandbox. The service must be listening on the specified port inside\n    the sandbox for the endpoint to be available.\n\n    Args:\n        sandbox_id (str):\n        port (int):\n        use_server_proxy (bool | Unset):  Default: False.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Endpoint | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n        port=port,\n        use_server_proxy=use_server_proxy,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    port: int,\n    *,\n    client: AuthenticatedClient | Client,\n    use_server_proxy: bool | Unset = False,\n) -> Endpoint | ErrorResponse | None:\n    \"\"\"Get sandbox access endpoint\n\n     Get the public access endpoint URL for accessing a service running on a specific port\n    within the sandbox. The service must be listening on the specified port inside\n    the sandbox for the endpoint to be available.\n\n    Args:\n        sandbox_id (str):\n        port (int):\n        use_server_proxy (bool | Unset):  Default: False.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Endpoint | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            port=port,\n            client=client,\n            use_server_proxy=use_server_proxy,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/post_sandboxes.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.create_sandbox_request import CreateSandboxRequest\nfrom ...models.create_sandbox_response import CreateSandboxResponse\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    *,\n    body: CreateSandboxRequest,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/sandboxes\",\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> CreateSandboxResponse | ErrorResponse | None:\n    if response.status_code == 202:\n        response_202 = CreateSandboxResponse.from_dict(response.json())\n\n        return response_202\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 409:\n        response_409 = ErrorResponse.from_dict(response.json())\n\n        return response_409\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[CreateSandboxResponse | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CreateSandboxRequest,\n) -> Response[CreateSandboxResponse | ErrorResponse]:\n    \"\"\"Create a sandbox from a container image\n\n     Creates a new sandbox from a container image with optional resource limits,\n    environment variables, and metadata. Sandboxes are provisioned directly from\n    the specified image without requiring a pre-created template.\n\n    ## Authentication\n\n    API Key authentication is required via:\n    - `OPEN-SANDBOX-API-KEY: <api-key>` header\n\n    Args:\n        body (CreateSandboxRequest): Request to create a new sandbox from a container image.\n\n            **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CreateSandboxResponse | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CreateSandboxRequest,\n) -> CreateSandboxResponse | ErrorResponse | None:\n    \"\"\"Create a sandbox from a container image\n\n     Creates a new sandbox from a container image with optional resource limits,\n    environment variables, and metadata. Sandboxes are provisioned directly from\n    the specified image without requiring a pre-created template.\n\n    ## Authentication\n\n    API Key authentication is required via:\n    - `OPEN-SANDBOX-API-KEY: <api-key>` header\n\n    Args:\n        body (CreateSandboxRequest): Request to create a new sandbox from a container image.\n\n            **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CreateSandboxResponse | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CreateSandboxRequest,\n) -> Response[CreateSandboxResponse | ErrorResponse]:\n    \"\"\"Create a sandbox from a container image\n\n     Creates a new sandbox from a container image with optional resource limits,\n    environment variables, and metadata. Sandboxes are provisioned directly from\n    the specified image without requiring a pre-created template.\n\n    ## Authentication\n\n    API Key authentication is required via:\n    - `OPEN-SANDBOX-API-KEY: <api-key>` header\n\n    Args:\n        body (CreateSandboxRequest): Request to create a new sandbox from a container image.\n\n            **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[CreateSandboxResponse | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    *,\n    client: AuthenticatedClient | Client,\n    body: CreateSandboxRequest,\n) -> CreateSandboxResponse | ErrorResponse | None:\n    \"\"\"Create a sandbox from a container image\n\n     Creates a new sandbox from a container image with optional resource limits,\n    environment variables, and metadata. Sandboxes are provisioned directly from\n    the specified image without requiring a pre-created template.\n\n    ## Authentication\n\n    API Key authentication is required via:\n    - `OPEN-SANDBOX-API-KEY: <api-key>` header\n\n    Args:\n        body (CreateSandboxRequest): Request to create a new sandbox from a container image.\n\n            **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        CreateSandboxResponse | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/post_sandboxes_sandbox_id_pause.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/sandboxes/{sandbox_id}/pause\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 202:\n        response_202 = cast(Any, None)\n        return response_202\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 409:\n        response_409 = ErrorResponse.from_dict(response.json())\n\n        return response_409\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Pause execution while retaining state\n\n     Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state\n    transition to Paused.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Pause execution while retaining state\n\n     Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state\n    transition to Paused.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Pause execution while retaining state\n\n     Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state\n    transition to Paused.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Pause execution while retaining state\n\n     Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state\n    transition to Paused.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/post_sandboxes_sandbox_id_renew_expiration.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...models.renew_sandbox_expiration_request import RenewSandboxExpirationRequest\nfrom ...models.renew_sandbox_expiration_response import RenewSandboxExpirationResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n    *,\n    body: RenewSandboxExpirationRequest,\n) -> dict[str, Any]:\n    headers: dict[str, Any] = {}\n\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/sandboxes/{sandbox_id}/renew-expiration\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n        ),\n    }\n\n    _kwargs[\"json\"] = body.to_dict()\n\n    headers[\"Content-Type\"] = \"application/json\"\n\n    _kwargs[\"headers\"] = headers\n    return _kwargs\n\n\ndef _parse_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> ErrorResponse | RenewSandboxExpirationResponse | None:\n    if response.status_code == 200:\n        response_200 = RenewSandboxExpirationResponse.from_dict(response.json())\n\n        return response_200\n\n    if response.status_code == 400:\n        response_400 = ErrorResponse.from_dict(response.json())\n\n        return response_400\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 409:\n        response_409 = ErrorResponse.from_dict(response.json())\n\n        return response_409\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(\n    *, client: AuthenticatedClient | Client, response: httpx.Response\n) -> Response[ErrorResponse | RenewSandboxExpirationResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    body: RenewSandboxExpirationRequest,\n) -> Response[ErrorResponse | RenewSandboxExpirationResponse]:\n    \"\"\"Renew sandbox expiration\n\n     Renew the absolute expiration time of a sandbox.\n\n    Args:\n        sandbox_id (str):\n        body (RenewSandboxExpirationRequest):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | RenewSandboxExpirationResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n        body=body,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    body: RenewSandboxExpirationRequest,\n) -> ErrorResponse | RenewSandboxExpirationResponse | None:\n    \"\"\"Renew sandbox expiration\n\n     Renew the absolute expiration time of a sandbox.\n\n    Args:\n        sandbox_id (str):\n        body (RenewSandboxExpirationRequest):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | RenewSandboxExpirationResponse\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        client=client,\n        body=body,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    body: RenewSandboxExpirationRequest,\n) -> Response[ErrorResponse | RenewSandboxExpirationResponse]:\n    \"\"\"Renew sandbox expiration\n\n     Renew the absolute expiration time of a sandbox.\n\n    Args:\n        sandbox_id (str):\n        body (RenewSandboxExpirationRequest):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[ErrorResponse | RenewSandboxExpirationResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n        body=body,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n    body: RenewSandboxExpirationRequest,\n) -> ErrorResponse | RenewSandboxExpirationResponse | None:\n    \"\"\"Renew sandbox expiration\n\n     Renew the absolute expiration time of a sandbox.\n\n    Args:\n        sandbox_id (str):\n        body (RenewSandboxExpirationRequest):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        ErrorResponse | RenewSandboxExpirationResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            client=client,\n            body=body,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/api/sandboxes/post_sandboxes_sandbox_id_resume.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom http import HTTPStatus\nfrom typing import Any, cast\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom ... import errors\nfrom ...client import AuthenticatedClient, Client\nfrom ...models.error_response import ErrorResponse\nfrom ...types import Response\n\n\ndef _get_kwargs(\n    sandbox_id: str,\n) -> dict[str, Any]:\n    _kwargs: dict[str, Any] = {\n        \"method\": \"post\",\n        \"url\": \"/sandboxes/{sandbox_id}/resume\".format(\n            sandbox_id=quote(str(sandbox_id), safe=\"\"),\n        ),\n    }\n\n    return _kwargs\n\n\ndef _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:\n    if response.status_code == 202:\n        response_202 = cast(Any, None)\n        return response_202\n\n    if response.status_code == 401:\n        response_401 = ErrorResponse.from_dict(response.json())\n\n        return response_401\n\n    if response.status_code == 403:\n        response_403 = ErrorResponse.from_dict(response.json())\n\n        return response_403\n\n    if response.status_code == 404:\n        response_404 = ErrorResponse.from_dict(response.json())\n\n        return response_404\n\n    if response.status_code == 409:\n        response_409 = ErrorResponse.from_dict(response.json())\n\n        return response_409\n\n    if response.status_code == 500:\n        response_500 = ErrorResponse.from_dict(response.json())\n\n        return response_500\n\n    if client.raise_on_unexpected_status:\n        raise errors.UnexpectedStatus(response.status_code, response.content)\n    else:\n        return None\n\n\ndef _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:\n    return Response(\n        status_code=HTTPStatus(response.status_code),\n        content=response.content,\n        headers=response.headers,\n        parsed=_parse_response(client=client, response=response),\n    )\n\n\ndef sync_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Resume a paused sandbox\n\n     Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to\n    Running.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = client.get_httpx_client().request(\n        **kwargs,\n    )\n\n    return _build_response(client=client, response=response)\n\n\ndef sync(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Resume a paused sandbox\n\n     Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to\n    Running.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return sync_detailed(\n        sandbox_id=sandbox_id,\n        client=client,\n    ).parsed\n\n\nasync def asyncio_detailed(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Response[Any | ErrorResponse]:\n    \"\"\"Resume a paused sandbox\n\n     Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to\n    Running.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Response[Any | ErrorResponse]\n    \"\"\"\n\n    kwargs = _get_kwargs(\n        sandbox_id=sandbox_id,\n    )\n\n    response = await client.get_async_httpx_client().request(**kwargs)\n\n    return _build_response(client=client, response=response)\n\n\nasync def asyncio(\n    sandbox_id: str,\n    *,\n    client: AuthenticatedClient | Client,\n) -> Any | ErrorResponse | None:\n    \"\"\"Resume a paused sandbox\n\n     Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to\n    Running.\n\n    Args:\n        sandbox_id (str):\n\n    Raises:\n        errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.\n        httpx.TimeoutException: If the request takes longer than Client.timeout.\n\n    Returns:\n        Any | ErrorResponse\n    \"\"\"\n\n    return (\n        await asyncio_detailed(\n            sandbox_id=sandbox_id,\n            client=client,\n        )\n    ).parsed\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/client.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nimport ssl\nfrom typing import Any\n\nimport httpx\nfrom attrs import define, evolve, field\n\n\n@define\nclass Client:\n    \"\"\"A class for keeping track of data related to the API\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    def with_headers(self, headers: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"Client\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"Client\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"Client\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"Client\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n\n\n@define\nclass AuthenticatedClient:\n    \"\"\"A Client which has been authenticated for use on secured endpoints\n\n    The following are accepted as keyword arguments and will be used to construct httpx Clients internally:\n\n        ``base_url``: The base URL for the API, all requests are made to a relative path to this URL\n\n        ``cookies``: A dictionary of cookies to be sent with every request\n\n        ``headers``: A dictionary of headers to be sent with every request\n\n        ``timeout``: The maximum amount of a time a request can take. API functions will raise\n        httpx.TimeoutException if this is exceeded.\n\n        ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,\n        but can be set to False for testing purposes.\n\n        ``follow_redirects``: Whether or not to follow redirects. Default value is False.\n\n        ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.\n\n\n    Attributes:\n        raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a\n            status code that was not documented in the source OpenAPI document. Can also be provided as a keyword\n            argument to the constructor.\n        token: The token to use for authentication\n        prefix: The prefix to use for the Authorization header\n        auth_header_name: The name of the Authorization header\n    \"\"\"\n\n    raise_on_unexpected_status: bool = field(default=False, kw_only=True)\n    _base_url: str = field(alias=\"base_url\")\n    _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias=\"cookies\")\n    _headers: dict[str, str] = field(factory=dict, kw_only=True, alias=\"headers\")\n    _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias=\"timeout\")\n    _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias=\"verify_ssl\")\n    _follow_redirects: bool = field(default=False, kw_only=True, alias=\"follow_redirects\")\n    _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias=\"httpx_args\")\n    _client: httpx.Client | None = field(default=None, init=False)\n    _async_client: httpx.AsyncClient | None = field(default=None, init=False)\n\n    token: str\n    prefix: str = \"Bearer\"\n    auth_header_name: str = \"Authorization\"\n\n    def with_headers(self, headers: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional headers\"\"\"\n        if self._client is not None:\n            self._client.headers.update(headers)\n        if self._async_client is not None:\n            self._async_client.headers.update(headers)\n        return evolve(self, headers={**self._headers, **headers})\n\n    def with_cookies(self, cookies: dict[str, str]) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with additional cookies\"\"\"\n        if self._client is not None:\n            self._client.cookies.update(cookies)\n        if self._async_client is not None:\n            self._async_client.cookies.update(cookies)\n        return evolve(self, cookies={**self._cookies, **cookies})\n\n    def with_timeout(self, timeout: httpx.Timeout) -> \"AuthenticatedClient\":\n        \"\"\"Get a new client matching this one with a new timeout configuration\"\"\"\n        if self._client is not None:\n            self._client.timeout = timeout\n        if self._async_client is not None:\n            self._async_client.timeout = timeout\n        return evolve(self, timeout=timeout)\n\n    def set_httpx_client(self, client: httpx.Client) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.Client\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._client = client\n        return self\n\n    def get_httpx_client(self) -> httpx.Client:\n        \"\"\"Get the underlying httpx.Client, constructing a new one if not previously set\"\"\"\n        if self._client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._client = httpx.Client(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._client\n\n    def __enter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for self.client—you cannot enter twice (see httpx docs)\"\"\"\n        self.get_httpx_client().__enter__()\n        return self\n\n    def __exit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for internal httpx.Client (see httpx docs)\"\"\"\n        self.get_httpx_client().__exit__(*args, **kwargs)\n\n    def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> \"AuthenticatedClient\":\n        \"\"\"Manually set the underlying httpx.AsyncClient\n\n        **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.\n        \"\"\"\n        self._async_client = async_client\n        return self\n\n    def get_async_httpx_client(self) -> httpx.AsyncClient:\n        \"\"\"Get the underlying httpx.AsyncClient, constructing a new one if not previously set\"\"\"\n        if self._async_client is None:\n            self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token\n            self._async_client = httpx.AsyncClient(\n                base_url=self._base_url,\n                cookies=self._cookies,\n                headers=self._headers,\n                timeout=self._timeout,\n                verify=self._verify_ssl,\n                follow_redirects=self._follow_redirects,\n                **self._httpx_args,\n            )\n        return self._async_client\n\n    async def __aenter__(self) -> \"AuthenticatedClient\":\n        \"\"\"Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aenter__()\n        return self\n\n    async def __aexit__(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Exit a context manager for underlying httpx.AsyncClient (see httpx docs)\"\"\"\n        await self.get_async_httpx_client().__aexit__(*args, **kwargs)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/errors.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains shared errors types that can be raised from API functions\"\"\"\n\n\nclass UnexpectedStatus(Exception):\n    \"\"\"Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True\"\"\"\n\n    def __init__(self, status_code: int, content: bytes):\n        self.status_code = status_code\n        self.content = content\n\n        super().__init__(\n            f\"Unexpected status code: {status_code}\\n\\nResponse content:\\n{content.decode(errors='ignore')}\"\n        )\n\n\n__all__ = [\"UnexpectedStatus\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains all the data models used in inputs/outputs\"\"\"\n\nfrom .create_sandbox_request import CreateSandboxRequest\nfrom .create_sandbox_request_env import CreateSandboxRequestEnv\nfrom .create_sandbox_request_extensions import CreateSandboxRequestExtensions\nfrom .create_sandbox_request_metadata import CreateSandboxRequestMetadata\nfrom .create_sandbox_response import CreateSandboxResponse\nfrom .create_sandbox_response_metadata import CreateSandboxResponseMetadata\nfrom .endpoint import Endpoint\nfrom .endpoint_headers import EndpointHeaders\nfrom .error_response import ErrorResponse\nfrom .host import Host\nfrom .image_spec import ImageSpec\nfrom .image_spec_auth import ImageSpecAuth\nfrom .list_sandboxes_response import ListSandboxesResponse\nfrom .network_policy import NetworkPolicy\nfrom .network_policy_default_action import NetworkPolicyDefaultAction\nfrom .network_rule import NetworkRule\nfrom .network_rule_action import NetworkRuleAction\nfrom .ossfs import OSSFS\nfrom .ossfs_version import OSSFSVersion\nfrom .pagination_info import PaginationInfo\nfrom .pvc import PVC\nfrom .renew_sandbox_expiration_request import RenewSandboxExpirationRequest\nfrom .renew_sandbox_expiration_response import RenewSandboxExpirationResponse\nfrom .resource_limits import ResourceLimits\nfrom .sandbox import Sandbox\nfrom .sandbox_metadata import SandboxMetadata\nfrom .sandbox_status import SandboxStatus\nfrom .volume import Volume\n\n__all__ = (\n    \"CreateSandboxRequest\",\n    \"CreateSandboxRequestEnv\",\n    \"CreateSandboxRequestExtensions\",\n    \"CreateSandboxRequestMetadata\",\n    \"CreateSandboxResponse\",\n    \"CreateSandboxResponseMetadata\",\n    \"Endpoint\",\n    \"EndpointHeaders\",\n    \"ErrorResponse\",\n    \"Host\",\n    \"ImageSpec\",\n    \"ImageSpecAuth\",\n    \"ListSandboxesResponse\",\n    \"NetworkPolicy\",\n    \"NetworkPolicyDefaultAction\",\n    \"NetworkRule\",\n    \"NetworkRuleAction\",\n    \"OSSFS\",\n    \"OSSFSVersion\",\n    \"PaginationInfo\",\n    \"PVC\",\n    \"RenewSandboxExpirationRequest\",\n    \"RenewSandboxExpirationResponse\",\n    \"ResourceLimits\",\n    \"Sandbox\",\n    \"SandboxMetadata\",\n    \"SandboxStatus\",\n    \"Volume\",\n)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.create_sandbox_request_env import CreateSandboxRequestEnv\n    from ..models.create_sandbox_request_extensions import CreateSandboxRequestExtensions\n    from ..models.create_sandbox_request_metadata import CreateSandboxRequestMetadata\n    from ..models.image_spec import ImageSpec\n    from ..models.network_policy import NetworkPolicy\n    from ..models.resource_limits import ResourceLimits\n    from ..models.volume import Volume\n\n\nT = TypeVar(\"T\", bound=\"CreateSandboxRequest\")\n\n\n@_attrs_define\nclass CreateSandboxRequest:\n    \"\"\"Request to create a new sandbox from a container image.\n\n    **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n\n        Attributes:\n            image (ImageSpec): Container image specification for sandbox provisioning.\n\n                Supports public registry images and private registry images with authentication.\n            resource_limits (ResourceLimits): Runtime resource constraints as key-value pairs. Similar to Kubernetes\n                resource specifications,\n                allows flexible definition of resource limits. Common resource types include:\n                - `cpu`: CPU allocation in millicores (e.g., \"250m\" for 0.25 CPU cores)\n                - `memory`: Memory allocation in bytes or human-readable format (e.g., \"512Mi\", \"1Gi\")\n                - `gpu`: Number of GPU devices (e.g., \"1\")\n\n                New resource types can be added without API changes.\n                 Example: {'cpu': '500m', 'memory': '512Mi', 'gpu': '1'}.\n            entrypoint (list[str]): The command to execute as the sandbox's entry process (required).\n\n                Explicitly specifies the user's expected main process, allowing the sandbox management\n                service to reliably inject control processes before executing this command.\n\n                Format: [executable, arg1, arg2, ...]\n\n                Examples:\n                - [\"python\", \"/app/main.py\"]\n                - [\"/bin/bash\"]\n                - [\"java\", \"-jar\", \"/app/app.jar\"]\n                - [\"node\", \"server.js\"]\n                 Example: ['python', '/app/main.py'].\n            timeout (int | None | Unset): Sandbox timeout in seconds. The sandbox will automatically terminate after this\n                duration.\n                The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`).\n                Omit or set null to disable automatic expiration and require explicit cleanup.\n                Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject\n                null timeout when the underlying workload provider does not support non-expiring sandboxes.\n            env (CreateSandboxRequestEnv | Unset): Environment variables to inject into the sandbox runtime. Example:\n                {'API_KEY': 'secret-key', 'DEBUG': 'true', 'LOG_LEVEL': 'info'}.\n            metadata (CreateSandboxRequestMetadata | Unset): Custom key-value metadata for management, filtering, and\n                tagging.\n                Use \"name\" key for a human-readable identifier.\n                 Example: {'name': 'Data Processing Sandbox', 'project': 'data-processing', 'team': 'ml', 'environment':\n                'staging'}.\n            network_policy (NetworkPolicy | Unset): Egress network policy matching the sidecar `/policy` request body.\n                If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n                object or null results in allow-all behavior at startup.\n            volumes (list[Volume] | Unset): Storage mounts for the sandbox. Each volume entry specifies a named backend-\n                specific\n                storage source and common mount settings. Exactly one backend type must be specified\n                per volume entry.\n            extensions (CreateSandboxRequestExtensions | Unset): Opaque container for provider-specific or transient\n                parameters not supported by the core API.\n\n                **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard\n                parameters should be proposed as core API fields.\n\n                **Best Practices**:\n                - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.\n                - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.\n    \"\"\"\n\n    image: ImageSpec\n    resource_limits: ResourceLimits\n    entrypoint: list[str]\n    timeout: int | None | Unset = UNSET\n    env: CreateSandboxRequestEnv | Unset = UNSET\n    metadata: CreateSandboxRequestMetadata | Unset = UNSET\n    network_policy: NetworkPolicy | Unset = UNSET\n    volumes: list[Volume] | Unset = UNSET\n    extensions: CreateSandboxRequestExtensions | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        image = self.image.to_dict()\n\n        resource_limits = self.resource_limits.to_dict()\n\n        entrypoint = self.entrypoint\n\n        timeout: int | None | Unset\n        if isinstance(self.timeout, Unset):\n            timeout = UNSET\n        else:\n            timeout = self.timeout\n\n        env: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.env, Unset):\n            env = self.env.to_dict()\n\n        metadata: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.metadata, Unset):\n            metadata = self.metadata.to_dict()\n\n        network_policy: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.network_policy, Unset):\n            network_policy = self.network_policy.to_dict()\n\n        volumes: list[dict[str, Any]] | Unset = UNSET\n        if not isinstance(self.volumes, Unset):\n            volumes = []\n            for volumes_item_data in self.volumes:\n                volumes_item = volumes_item_data.to_dict()\n                volumes.append(volumes_item)\n\n        extensions: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.extensions, Unset):\n            extensions = self.extensions.to_dict()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"image\": image,\n                \"resourceLimits\": resource_limits,\n                \"entrypoint\": entrypoint,\n            }\n        )\n        if timeout is not UNSET:\n            field_dict[\"timeout\"] = timeout\n        if env is not UNSET:\n            field_dict[\"env\"] = env\n        if metadata is not UNSET:\n            field_dict[\"metadata\"] = metadata\n        if network_policy is not UNSET:\n            field_dict[\"networkPolicy\"] = network_policy\n        if volumes is not UNSET:\n            field_dict[\"volumes\"] = volumes\n        if extensions is not UNSET:\n            field_dict[\"extensions\"] = extensions\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.create_sandbox_request_env import CreateSandboxRequestEnv\n        from ..models.create_sandbox_request_extensions import CreateSandboxRequestExtensions\n        from ..models.create_sandbox_request_metadata import CreateSandboxRequestMetadata\n        from ..models.image_spec import ImageSpec\n        from ..models.network_policy import NetworkPolicy\n        from ..models.resource_limits import ResourceLimits\n        from ..models.volume import Volume\n\n        d = dict(src_dict)\n        image = ImageSpec.from_dict(d.pop(\"image\"))\n\n        resource_limits = ResourceLimits.from_dict(d.pop(\"resourceLimits\"))\n\n        entrypoint = cast(list[str], d.pop(\"entrypoint\"))\n\n        def _parse_timeout(data: object) -> int | None | Unset:\n            if data is None:\n                return data\n            if isinstance(data, Unset):\n                return data\n            return cast(int | None | Unset, data)\n\n        timeout = _parse_timeout(d.pop(\"timeout\", UNSET))\n\n        _env = d.pop(\"env\", UNSET)\n        env: CreateSandboxRequestEnv | Unset\n        if isinstance(_env, Unset):\n            env = UNSET\n        else:\n            env = CreateSandboxRequestEnv.from_dict(_env)\n\n        _metadata = d.pop(\"metadata\", UNSET)\n        metadata: CreateSandboxRequestMetadata | Unset\n        if isinstance(_metadata, Unset):\n            metadata = UNSET\n        else:\n            metadata = CreateSandboxRequestMetadata.from_dict(_metadata)\n\n        _network_policy = d.pop(\"networkPolicy\", UNSET)\n        network_policy: NetworkPolicy | Unset\n        if isinstance(_network_policy, Unset):\n            network_policy = UNSET\n        else:\n            network_policy = NetworkPolicy.from_dict(_network_policy)\n\n        _volumes = d.pop(\"volumes\", UNSET)\n        volumes: list[Volume] | Unset = UNSET\n        if _volumes is not UNSET:\n            volumes = []\n            for volumes_item_data in _volumes:\n                volumes_item = Volume.from_dict(volumes_item_data)\n\n                volumes.append(volumes_item)\n\n        _extensions = d.pop(\"extensions\", UNSET)\n        extensions: CreateSandboxRequestExtensions | Unset\n        if isinstance(_extensions, Unset):\n            extensions = UNSET\n        else:\n            extensions = CreateSandboxRequestExtensions.from_dict(_extensions)\n\n        create_sandbox_request = cls(\n            image=image,\n            resource_limits=resource_limits,\n            entrypoint=entrypoint,\n            timeout=timeout,\n            env=env,\n            metadata=metadata,\n            network_policy=network_policy,\n            volumes=volumes,\n            extensions=extensions,\n        )\n\n        create_sandbox_request.additional_properties = d\n        return create_sandbox_request\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_env.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"CreateSandboxRequestEnv\")\n\n\n@_attrs_define\nclass CreateSandboxRequestEnv:\n    \"\"\"Environment variables to inject into the sandbox runtime.\n\n    Example:\n        {'API_KEY': 'secret-key', 'DEBUG': 'true', 'LOG_LEVEL': 'info'}\n\n    \"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        create_sandbox_request_env = cls()\n\n        create_sandbox_request_env.additional_properties = d\n        return create_sandbox_request_env\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_extensions.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"CreateSandboxRequestExtensions\")\n\n\n@_attrs_define\nclass CreateSandboxRequestExtensions:\n    \"\"\"Opaque container for provider-specific or transient parameters not supported by the core API.\n\n    **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard\n    parameters should be proposed as core API fields.\n\n    **Best Practices**:\n    - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.\n    - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.\n\n    \"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        create_sandbox_request_extensions = cls()\n\n        create_sandbox_request_extensions.additional_properties = d\n        return create_sandbox_request_extensions\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_request_metadata.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"CreateSandboxRequestMetadata\")\n\n\n@_attrs_define\nclass CreateSandboxRequestMetadata:\n    \"\"\"Custom key-value metadata for management, filtering, and tagging.\n    Use \"name\" key for a human-readable identifier.\n\n        Example:\n            {'name': 'Data Processing Sandbox', 'project': 'data-processing', 'team': 'ml', 'environment': 'staging'}\n\n    \"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        create_sandbox_request_metadata = cls()\n\n        create_sandbox_request_metadata.additional_properties = d\n        return create_sandbox_request_metadata\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\nfrom dateutil.parser import isoparse\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.create_sandbox_response_metadata import CreateSandboxResponseMetadata\n    from ..models.sandbox_status import SandboxStatus\n\n\nT = TypeVar(\"T\", bound=\"CreateSandboxResponse\")\n\n\n@_attrs_define\nclass CreateSandboxResponse:\n    \"\"\"Response from creating a new sandbox. Contains essential information without image and updatedAt.\n\n    Attributes:\n        id (str): Unique sandbox identifier\n        status (SandboxStatus): Detailed status information with lifecycle state and transition details\n        created_at (datetime.datetime): Sandbox creation timestamp\n        entrypoint (list[str]): Entry process specification from creation request\n        metadata (CreateSandboxResponseMetadata | Unset): Custom metadata from creation request\n        expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual\n            cleanup is enabled.\n    \"\"\"\n\n    id: str\n    status: SandboxStatus\n    created_at: datetime.datetime\n    entrypoint: list[str]\n    metadata: CreateSandboxResponseMetadata | Unset = UNSET\n    expires_at: datetime.datetime | None | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        id = self.id\n\n        status = self.status.to_dict()\n\n        created_at = self.created_at.isoformat()\n\n        entrypoint = self.entrypoint\n\n        metadata: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.metadata, Unset):\n            metadata = self.metadata.to_dict()\n\n        expires_at: None | str | Unset\n        if isinstance(self.expires_at, Unset):\n            expires_at = UNSET\n        elif isinstance(self.expires_at, datetime.datetime):\n            expires_at = self.expires_at.isoformat()\n        else:\n            expires_at = self.expires_at\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"id\": id,\n                \"status\": status,\n                \"createdAt\": created_at,\n                \"entrypoint\": entrypoint,\n            }\n        )\n        if metadata is not UNSET:\n            field_dict[\"metadata\"] = metadata\n        if expires_at is not UNSET:\n            field_dict[\"expiresAt\"] = expires_at\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.create_sandbox_response_metadata import CreateSandboxResponseMetadata\n        from ..models.sandbox_status import SandboxStatus\n\n        d = dict(src_dict)\n        id = d.pop(\"id\")\n\n        status = SandboxStatus.from_dict(d.pop(\"status\"))\n\n        created_at = isoparse(d.pop(\"createdAt\"))\n\n        entrypoint = cast(list[str], d.pop(\"entrypoint\"))\n\n        _metadata = d.pop(\"metadata\", UNSET)\n        metadata: CreateSandboxResponseMetadata | Unset\n        if isinstance(_metadata, Unset) or _metadata is None:\n            metadata = UNSET\n        else:\n            metadata = CreateSandboxResponseMetadata.from_dict(_metadata)\n\n        def _parse_expires_at(data: object) -> datetime.datetime | None | Unset:\n            if data is None:\n                return data\n            if isinstance(data, Unset):\n                return data\n            try:\n                if not isinstance(data, str):\n                    raise TypeError()\n                expires_at_type_0 = isoparse(data)\n\n                return expires_at_type_0\n            except (TypeError, ValueError, AttributeError, KeyError):\n                pass\n            return cast(datetime.datetime | None | Unset, data)\n\n        expires_at = _parse_expires_at(d.pop(\"expiresAt\", UNSET))\n\n        create_sandbox_response = cls(\n            id=id,\n            status=status,\n            created_at=created_at,\n            entrypoint=entrypoint,\n            metadata=metadata,\n            expires_at=expires_at,\n        )\n\n        create_sandbox_response.additional_properties = d\n        return create_sandbox_response\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/create_sandbox_response_metadata.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"CreateSandboxResponseMetadata\")\n\n\n@_attrs_define\nclass CreateSandboxResponseMetadata:\n    \"\"\"Custom metadata from creation request\"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        create_sandbox_response_metadata = cls()\n\n        create_sandbox_response_metadata.additional_properties = d\n        return create_sandbox_response_metadata\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/endpoint.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.endpoint_headers import EndpointHeaders\n\n\nT = TypeVar(\"T\", bound=\"Endpoint\")\n\n\n@_attrs_define\nclass Endpoint:\n    \"\"\"Endpoint for accessing a service running in the sandbox.\n    The service must be listening on the specified port inside the sandbox for the endpoint to be available.\n\n        Attributes:\n            endpoint (str): Public URL to access the service from outside the sandbox.\n                Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port}\n                Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080\n            headers (EndpointHeaders | Unset): Requests targeting the sandbox must include the corresponding header(s).\n    \"\"\"\n\n    endpoint: str\n    headers: EndpointHeaders | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        endpoint = self.endpoint\n\n        headers: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.headers, Unset):\n            headers = self.headers.to_dict()\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"endpoint\": endpoint,\n            }\n        )\n        if headers is not UNSET:\n            field_dict[\"headers\"] = headers\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.endpoint_headers import EndpointHeaders\n\n        d = dict(src_dict)\n        endpoint = d.pop(\"endpoint\")\n\n        _headers = d.pop(\"headers\", UNSET)\n        headers: EndpointHeaders | Unset\n        if isinstance(_headers, Unset):\n            headers = UNSET\n        else:\n            headers = EndpointHeaders.from_dict(_headers)\n\n        endpoint = cls(\n            endpoint=endpoint,\n            headers=headers,\n        )\n\n        return endpoint\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/endpoint_headers.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"EndpointHeaders\")\n\n\n@_attrs_define\nclass EndpointHeaders:\n    \"\"\"Requests targeting the sandbox must include the corresponding header(s).\"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        endpoint_headers = cls()\n\n        endpoint_headers.additional_properties = d\n        return endpoint_headers\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/error_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nT = TypeVar(\"T\", bound=\"ErrorResponse\")\n\n\n@_attrs_define\nclass ErrorResponse:\n    \"\"\"Standard error response for all non-2xx HTTP responses.\n    HTTP status code indicates the error category; code and message provide details.\n\n        Attributes:\n            code (str): Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR).\n                Use this for programmatic error handling.\n            message (str): Human-readable error message describing what went wrong and how to fix it.\n    \"\"\"\n\n    code: str\n    message: str\n\n    def to_dict(self) -> dict[str, Any]:\n        code = self.code\n\n        message = self.message\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"code\": code,\n                \"message\": message,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        code = d.pop(\"code\")\n\n        message = d.pop(\"message\")\n\n        error_response = cls(\n            code=code,\n            message=message,\n        )\n\n        return error_response\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/host.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nT = TypeVar(\"T\", bound=\"Host\")\n\n\n@_attrs_define\nclass Host:\n    \"\"\"Host path bind mount backend. Maps a directory on the host filesystem\n    into the container. Only available when the runtime supports host mounts.\n\n    Security note: Host paths are restricted by server-side allowlist.\n    Users must specify paths under permitted prefixes.\n\n        Attributes:\n            path (str): Absolute path on the host filesystem to mount.\n                Must start with '/' and be under an allowed prefix.\n    \"\"\"\n\n    path: str\n\n    def to_dict(self) -> dict[str, Any]:\n        path = self.path\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"path\": path,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        path = d.pop(\"path\")\n\n        host = cls(\n            path=path,\n        )\n\n        return host\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.image_spec_auth import ImageSpecAuth\n\n\nT = TypeVar(\"T\", bound=\"ImageSpec\")\n\n\n@_attrs_define\nclass ImageSpec:\n    \"\"\"Container image specification for sandbox provisioning.\n\n    Supports public registry images and private registry images with authentication.\n\n        Attributes:\n            uri (str): Container image URI in standard format.\n\n                Examples:\n                  - \"python:3.11\" (Docker Hub)\n                  - \"ubuntu:22.04\"\n                  - \"gcr.io/my-project/model-server:v1.0\"\n                  - \"private-registry.company.com:5000/app:latest\"\n            auth (ImageSpecAuth | Unset): Registry authentication credentials (required for private registries)\n    \"\"\"\n\n    uri: str\n    auth: ImageSpecAuth | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        uri = self.uri\n\n        auth: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.auth, Unset):\n            auth = self.auth.to_dict()\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"uri\": uri,\n            }\n        )\n        if auth is not UNSET:\n            field_dict[\"auth\"] = auth\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.image_spec_auth import ImageSpecAuth\n\n        d = dict(src_dict)\n        uri = d.pop(\"uri\")\n\n        _auth = d.pop(\"auth\", UNSET)\n        auth: ImageSpecAuth | Unset\n        if isinstance(_auth, Unset) or _auth is None:\n            auth = UNSET\n        else:\n            auth = ImageSpecAuth.from_dict(_auth)\n\n        image_spec = cls(\n            uri=uri,\n            auth=auth,\n        )\n\n        return image_spec\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/image_spec_auth.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"ImageSpecAuth\")\n\n\n@_attrs_define\nclass ImageSpecAuth:\n    \"\"\"Registry authentication credentials (required for private registries)\n\n    Attributes:\n        username (str | Unset): Registry username or service account\n        password (str | Unset): Registry password or authentication token\n    \"\"\"\n\n    username: str | Unset = UNSET\n    password: str | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        username = self.username\n\n        password = self.password\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update({})\n        if username is not UNSET:\n            field_dict[\"username\"] = username\n        if password is not UNSET:\n            field_dict[\"password\"] = password\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        username = d.pop(\"username\", UNSET)\n\n        password = d.pop(\"password\", UNSET)\n\n        image_spec_auth = cls(\n            username=username,\n            password=password,\n        )\n\n        return image_spec_auth\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/list_sandboxes_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nif TYPE_CHECKING:\n    from ..models.pagination_info import PaginationInfo\n    from ..models.sandbox import Sandbox\n\n\nT = TypeVar(\"T\", bound=\"ListSandboxesResponse\")\n\n\n@_attrs_define\nclass ListSandboxesResponse:\n    \"\"\"\n    Attributes:\n        items (list[Sandbox]):\n        pagination (PaginationInfo): Pagination metadata for list responses\n    \"\"\"\n\n    items: list[Sandbox]\n    pagination: PaginationInfo\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        items = []\n        for items_item_data in self.items:\n            items_item = items_item_data.to_dict()\n            items.append(items_item)\n\n        pagination = self.pagination.to_dict()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"items\": items,\n                \"pagination\": pagination,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.pagination_info import PaginationInfo\n        from ..models.sandbox import Sandbox\n\n        d = dict(src_dict)\n        items = []\n        _items = d.pop(\"items\")\n        for items_item_data in _items:\n            items_item = Sandbox.from_dict(items_item_data)\n\n            items.append(items_item)\n\n        pagination = PaginationInfo.from_dict(d.pop(\"pagination\"))\n\n        list_sandboxes_response = cls(\n            items=items,\n            pagination=pagination,\n        )\n\n        list_sandboxes_response.additional_properties = d\n        return list_sandboxes_response\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/network_policy.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..models.network_policy_default_action import NetworkPolicyDefaultAction\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.network_rule import NetworkRule\n\n\nT = TypeVar(\"T\", bound=\"NetworkPolicy\")\n\n\n@_attrs_define\nclass NetworkPolicy:\n    \"\"\"Egress network policy matching the sidecar `/policy` request body.\n    If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n    object or null results in allow-all behavior at startup.\n\n        Attributes:\n            default_action (NetworkPolicyDefaultAction | Unset): Default action when no egress rule matches. Defaults to\n                \"deny\".\n            egress (list[NetworkRule] | Unset): List of egress rules evaluated in order.\n    \"\"\"\n\n    default_action: NetworkPolicyDefaultAction | Unset = UNSET\n    egress: list[NetworkRule] | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        default_action: str | Unset = UNSET\n        if not isinstance(self.default_action, Unset):\n            default_action = self.default_action.value\n\n        egress: list[dict[str, Any]] | Unset = UNSET\n        if not isinstance(self.egress, Unset):\n            egress = []\n            for egress_item_data in self.egress:\n                egress_item = egress_item_data.to_dict()\n                egress.append(egress_item)\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update({})\n        if default_action is not UNSET:\n            field_dict[\"defaultAction\"] = default_action\n        if egress is not UNSET:\n            field_dict[\"egress\"] = egress\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.network_rule import NetworkRule\n\n        d = dict(src_dict)\n        _default_action = d.pop(\"defaultAction\", UNSET)\n        default_action: NetworkPolicyDefaultAction | Unset\n        if isinstance(_default_action, Unset):\n            default_action = UNSET\n        else:\n            default_action = NetworkPolicyDefaultAction(_default_action)\n\n        _egress = d.pop(\"egress\", UNSET)\n        egress: list[NetworkRule] | Unset = UNSET\n        if _egress is not UNSET:\n            egress = []\n            for egress_item_data in _egress:\n                egress_item = NetworkRule.from_dict(egress_item_data)\n\n                egress.append(egress_item)\n\n        network_policy = cls(\n            default_action=default_action,\n            egress=egress,\n        )\n\n        return network_policy\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/network_policy_default_action.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass NetworkPolicyDefaultAction(str, Enum):\n    ALLOW = \"allow\"\n    DENY = \"deny\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/network_rule.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..models.network_rule_action import NetworkRuleAction\n\nT = TypeVar(\"T\", bound=\"NetworkRule\")\n\n\n@_attrs_define\nclass NetworkRule:\n    \"\"\"\n    Attributes:\n        action (NetworkRuleAction): Whether to allow or deny matching targets.\n        target (str): FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n            IP/CIDR not yet supported in the egress MVP.\n    \"\"\"\n\n    action: NetworkRuleAction\n    target: str\n\n    def to_dict(self) -> dict[str, Any]:\n        action = self.action.value\n\n        target = self.target\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"action\": action,\n                \"target\": target,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        action = NetworkRuleAction(d.pop(\"action\"))\n\n        target = d.pop(\"target\")\n\n        network_rule = cls(\n            action=action,\n            target=target,\n        )\n\n        return network_rule\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/network_rule_action.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass NetworkRuleAction(str, Enum):\n    ALLOW = \"allow\"\n    DENY = \"deny\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\n\nfrom ..models.ossfs_version import OSSFSVersion\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"OSSFS\")\n\n\n@_attrs_define\nclass OSSFS:\n    \"\"\"Alibaba Cloud OSS mount backend via ossfs.\n\n    The runtime mounts a host-side OSS path under `storage.ossfs_mount_root`\n    and bind-mounts the resolved path into the sandbox container.\n    Prefix selection is expressed via `Volume.subPath`.\n    In Docker runtime, OSSFS backend requires OpenSandbox Server to run on a Linux host with FUSE support.\n\n        Attributes:\n            bucket (str): OSS bucket name.\n            endpoint (str): OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`).\n            access_key_id (str): OSS access key ID for inline credentials mode.\n            access_key_secret (str): OSS access key secret for inline credentials mode.\n            version (OSSFSVersion | Unset): ossfs major version used by runtime mount integration. Default:\n                OSSFSVersion.VALUE_1.\n            options (list[str] | Unset): Additional ossfs mount options.\n                Runtime encodes options by `version`:\n                - `1.0`: mounts with `ossfs ... -o <option>`\n                - `2.0`: mounts with `ossfs2 mount ... -c <config-file>` and encodes options as `--<option>` lines in the config\n                file\n                Option values must be provided as raw payloads without leading `-`.\n    \"\"\"\n\n    bucket: str\n    endpoint: str\n    access_key_id: str\n    access_key_secret: str\n    version: OSSFSVersion | Unset = OSSFSVersion.VALUE_1\n    options: list[str] | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        bucket = self.bucket\n\n        endpoint = self.endpoint\n\n        access_key_id = self.access_key_id\n\n        access_key_secret = self.access_key_secret\n\n        version: str | Unset = UNSET\n        if not isinstance(self.version, Unset):\n            version = self.version.value\n\n        options: list[str] | Unset = UNSET\n        if not isinstance(self.options, Unset):\n            options = self.options\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"bucket\": bucket,\n                \"endpoint\": endpoint,\n                \"accessKeyId\": access_key_id,\n                \"accessKeySecret\": access_key_secret,\n            }\n        )\n        if version is not UNSET:\n            field_dict[\"version\"] = version\n        if options is not UNSET:\n            field_dict[\"options\"] = options\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        bucket = d.pop(\"bucket\")\n\n        endpoint = d.pop(\"endpoint\")\n\n        access_key_id = d.pop(\"accessKeyId\")\n\n        access_key_secret = d.pop(\"accessKeySecret\")\n\n        _version = d.pop(\"version\", UNSET)\n        version: OSSFSVersion | Unset\n        if isinstance(_version, Unset):\n            version = UNSET\n        else:\n            version = OSSFSVersion(_version)\n\n        options = cast(list[str], d.pop(\"options\", UNSET))\n\n        ossfs = cls(\n            bucket=bucket,\n            endpoint=endpoint,\n            access_key_id=access_key_id,\n            access_key_secret=access_key_secret,\n            version=version,\n            options=options,\n        )\n\n        return ossfs\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs_version.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom enum import Enum\n\n\nclass OSSFSVersion(str, Enum):\n    VALUE_0 = \"1.0\"\n    VALUE_1 = \"2.0\"\n\n    def __str__(self) -> str:\n        return str(self.value)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pagination_info.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"PaginationInfo\")\n\n\n@_attrs_define\nclass PaginationInfo:\n    \"\"\"Pagination metadata for list responses\n\n    Attributes:\n        page (int): Current page number\n        page_size (int): Number of items per page\n        total_items (int): Total number of items matching the filter\n        total_pages (int): Total number of pages\n        has_next_page (bool): Whether there are more pages after the current one\n    \"\"\"\n\n    page: int\n    page_size: int\n    total_items: int\n    total_pages: int\n    has_next_page: bool\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        page = self.page\n\n        page_size = self.page_size\n\n        total_items = self.total_items\n\n        total_pages = self.total_pages\n\n        has_next_page = self.has_next_page\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"page\": page,\n                \"pageSize\": page_size,\n                \"totalItems\": total_items,\n                \"totalPages\": total_pages,\n                \"hasNextPage\": has_next_page,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        page = d.pop(\"page\")\n\n        page_size = d.pop(\"pageSize\")\n\n        total_items = d.pop(\"totalItems\")\n\n        total_pages = d.pop(\"totalPages\")\n\n        has_next_page = d.pop(\"hasNextPage\")\n\n        pagination_info = cls(\n            page=page,\n            page_size=page_size,\n            total_items=total_items,\n            total_pages=total_pages,\n            has_next_page=has_next_page,\n        )\n\n        pagination_info.additional_properties = d\n        return pagination_info\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pvc.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nT = TypeVar(\"T\", bound=\"PVC\")\n\n\n@_attrs_define\nclass PVC:\n    \"\"\"Platform-managed named volume backend. A runtime-neutral abstraction\n    for referencing a pre-existing, platform-managed named volume.\n\n    - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.\n    - Docker: maps to a Docker named volume (created via `docker volume create`).\n\n    The volume must already exist on the target platform before sandbox\n    creation.\n\n        Attributes:\n            claim_name (str): Name of the volume on the target platform.\n                In Kubernetes this is the PVC name; in Docker this is the named\n                volume name. Must be a valid DNS label.\n    \"\"\"\n\n    claim_name: str\n\n    def to_dict(self) -> dict[str, Any]:\n        claim_name = self.claim_name\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"claimName\": claim_name,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        claim_name = d.pop(\"claimName\")\n\n        pvc = cls(\n            claim_name=claim_name,\n        )\n\n        return pvc\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/renew_sandbox_expiration_request.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom dateutil.parser import isoparse\n\nT = TypeVar(\"T\", bound=\"RenewSandboxExpirationRequest\")\n\n\n@_attrs_define\nclass RenewSandboxExpirationRequest:\n    \"\"\"\n    Attributes:\n        expires_at (datetime.datetime): New absolute expiration time in UTC (RFC 3339 format).\n            Must be in the future and after the current expiresAt time.\n\n            Example: \"2025-11-16T14:30:45Z\"\n    \"\"\"\n\n    expires_at: datetime.datetime\n\n    def to_dict(self) -> dict[str, Any]:\n        expires_at = self.expires_at.isoformat()\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"expiresAt\": expires_at,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        expires_at = isoparse(d.pop(\"expiresAt\"))\n\n        renew_sandbox_expiration_request = cls(\n            expires_at=expires_at,\n        )\n\n        return renew_sandbox_expiration_request\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/renew_sandbox_expiration_response.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom dateutil.parser import isoparse\n\nT = TypeVar(\"T\", bound=\"RenewSandboxExpirationResponse\")\n\n\n@_attrs_define\nclass RenewSandboxExpirationResponse:\n    \"\"\"\n    Attributes:\n        expires_at (datetime.datetime): The new absolute expiration time in UTC (RFC 3339 format).\n\n            Example: \"2025-11-16T14:30:45Z\"\n    \"\"\"\n\n    expires_at: datetime.datetime\n\n    def to_dict(self) -> dict[str, Any]:\n        expires_at = self.expires_at.isoformat()\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"expiresAt\": expires_at,\n            }\n        )\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        expires_at = isoparse(d.pop(\"expiresAt\"))\n\n        renew_sandbox_expiration_response = cls(\n            expires_at=expires_at,\n        )\n\n        return renew_sandbox_expiration_response\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/resource_limits.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"ResourceLimits\")\n\n\n@_attrs_define\nclass ResourceLimits:\n    \"\"\"Runtime resource constraints as key-value pairs. Similar to Kubernetes resource specifications,\n    allows flexible definition of resource limits. Common resource types include:\n    - `cpu`: CPU allocation in millicores (e.g., \"250m\" for 0.25 CPU cores)\n    - `memory`: Memory allocation in bytes or human-readable format (e.g., \"512Mi\", \"1Gi\")\n    - `gpu`: Number of GPU devices (e.g., \"1\")\n\n    New resource types can be added without API changes.\n\n        Example:\n            {'cpu': '500m', 'memory': '512Mi', 'gpu': '1'}\n\n    \"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        resource_limits = cls()\n\n        resource_limits.additional_properties = d\n        return resource_limits\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar, cast\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\nfrom dateutil.parser import isoparse\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.image_spec import ImageSpec\n    from ..models.sandbox_metadata import SandboxMetadata\n    from ..models.sandbox_status import SandboxStatus\n\n\nT = TypeVar(\"T\", bound=\"Sandbox\")\n\n\n@_attrs_define\nclass Sandbox:\n    \"\"\"Runtime execution environment provisioned from a container image\n\n    Attributes:\n        id (str): Unique sandbox identifier\n        image (ImageSpec): Container image specification for sandbox provisioning.\n\n            Supports public registry images and private registry images with authentication.\n        status (SandboxStatus): Detailed status information with lifecycle state and transition details\n        entrypoint (list[str]): The command to execute as the sandbox's entry process.\n            Always present in responses since entrypoint is required in creation requests.\n        created_at (datetime.datetime): Sandbox creation timestamp\n        metadata (SandboxMetadata | Unset): Custom metadata from creation request\n        expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual\n            cleanup is enabled.\n    \"\"\"\n\n    id: str\n    image: ImageSpec\n    status: SandboxStatus\n    entrypoint: list[str]\n    created_at: datetime.datetime\n    metadata: SandboxMetadata | Unset = UNSET\n    expires_at: datetime.datetime | None | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        id = self.id\n\n        image = self.image.to_dict()\n\n        status = self.status.to_dict()\n\n        entrypoint = self.entrypoint\n\n        created_at = self.created_at.isoformat()\n\n        metadata: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.metadata, Unset):\n            metadata = self.metadata.to_dict()\n\n        expires_at: None | str | Unset\n        if isinstance(self.expires_at, Unset):\n            expires_at = UNSET\n        elif isinstance(self.expires_at, datetime.datetime):\n            expires_at = self.expires_at.isoformat()\n        else:\n            expires_at = self.expires_at\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"id\": id,\n                \"image\": image,\n                \"status\": status,\n                \"entrypoint\": entrypoint,\n                \"createdAt\": created_at,\n            }\n        )\n        if metadata is not UNSET:\n            field_dict[\"metadata\"] = metadata\n        if expires_at is not UNSET:\n            field_dict[\"expiresAt\"] = expires_at\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.image_spec import ImageSpec\n        from ..models.sandbox_metadata import SandboxMetadata\n        from ..models.sandbox_status import SandboxStatus\n\n        d = dict(src_dict)\n        id = d.pop(\"id\")\n\n        image = ImageSpec.from_dict(d.pop(\"image\"))\n\n        status = SandboxStatus.from_dict(d.pop(\"status\"))\n\n        entrypoint = cast(list[str], d.pop(\"entrypoint\"))\n\n        created_at = isoparse(d.pop(\"createdAt\"))\n\n        _metadata = d.pop(\"metadata\", UNSET)\n        metadata: SandboxMetadata | Unset\n        if isinstance(_metadata, Unset) or _metadata is None:\n            metadata = UNSET\n        else:\n            metadata = SandboxMetadata.from_dict(_metadata)\n\n        def _parse_expires_at(data: object) -> datetime.datetime | None | Unset:\n            if data is None:\n                return data\n            if isinstance(data, Unset):\n                return data\n            try:\n                if not isinstance(data, str):\n                    raise TypeError()\n                expires_at_type_0 = isoparse(data)\n\n                return expires_at_type_0\n            except (TypeError, ValueError, AttributeError, KeyError):\n                pass\n            return cast(datetime.datetime | None | Unset, data)\n\n        expires_at = _parse_expires_at(d.pop(\"expiresAt\", UNSET))\n\n        sandbox = cls(\n            id=id,\n            image=image,\n            status=status,\n            entrypoint=entrypoint,\n            created_at=created_at,\n            metadata=metadata,\n            expires_at=expires_at,\n        )\n\n        sandbox.additional_properties = d\n        return sandbox\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_metadata.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\n\nT = TypeVar(\"T\", bound=\"SandboxMetadata\")\n\n\n@_attrs_define\nclass SandboxMetadata:\n    \"\"\"Custom metadata from creation request\"\"\"\n\n    additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        sandbox_metadata = cls()\n\n        sandbox_metadata.additional_properties = d\n        return sandbox_metadata\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> str:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: str) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox_status.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nimport datetime\nfrom collections.abc import Mapping\nfrom typing import Any, TypeVar\n\nfrom attrs import define as _attrs_define\nfrom attrs import field as _attrs_field\nfrom dateutil.parser import isoparse\n\nfrom ..types import UNSET, Unset\n\nT = TypeVar(\"T\", bound=\"SandboxStatus\")\n\n\n@_attrs_define\nclass SandboxStatus:\n    \"\"\"Detailed status information with lifecycle state and transition details\n\n    Attributes:\n        state (str): High-level lifecycle state of the sandbox.\n\n            Common state values:\n            - Pending: Sandbox is being provisioned\n            - Running: Sandbox is running and ready to accept requests\n            - Pausing: Sandbox is in the process of pausing\n            - Paused: Sandbox has been paused while retaining its state\n            - Stopping: Sandbox is being terminated\n            - Terminated: Sandbox has been successfully terminated\n            - Failed: Sandbox encountered a critical error\n\n            State transitions:\n            - Pending → Running (after creation completes)\n            - Running → Pausing (when pause is requested)\n            - Pausing → Paused (pause operation completes)\n            - Paused → Running (when resume is requested)\n            - Running/Paused → Stopping (when kill is requested or TTL expires)\n            - Stopping → Terminated (kill/timeout operation completes)\n            - Pending/Running/Paused → Failed (on error)\n\n            Note: New state values may be added in future versions.\n            Clients should handle unknown state values gracefully.\n        reason (str | Unset): Short machine-readable reason code for the current state.\n            Examples: \"user_delete\", \"ttl_expiry\", \"provision_timeout\", \"runtime_error\"\n        message (str | Unset): Human-readable message describing the current state or reason for state transition\n        last_transition_at (datetime.datetime | Unset): Timestamp of the last state transition\n    \"\"\"\n\n    state: str\n    reason: str | Unset = UNSET\n    message: str | Unset = UNSET\n    last_transition_at: datetime.datetime | Unset = UNSET\n    additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)\n\n    def to_dict(self) -> dict[str, Any]:\n        state = self.state\n\n        reason = self.reason\n\n        message = self.message\n\n        last_transition_at: str | Unset = UNSET\n        if not isinstance(self.last_transition_at, Unset):\n            last_transition_at = self.last_transition_at.isoformat()\n\n        field_dict: dict[str, Any] = {}\n        field_dict.update(self.additional_properties)\n        field_dict.update(\n            {\n                \"state\": state,\n            }\n        )\n        if reason is not UNSET:\n            field_dict[\"reason\"] = reason\n        if message is not UNSET:\n            field_dict[\"message\"] = message\n        if last_transition_at is not UNSET:\n            field_dict[\"lastTransitionAt\"] = last_transition_at\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        d = dict(src_dict)\n        state = d.pop(\"state\")\n\n        reason = d.pop(\"reason\", UNSET)\n\n        message = d.pop(\"message\", UNSET)\n\n        _last_transition_at = d.pop(\"lastTransitionAt\", UNSET)\n        last_transition_at: datetime.datetime | Unset\n        if isinstance(_last_transition_at, Unset) or _last_transition_at is None:\n            last_transition_at = UNSET\n        else:\n            last_transition_at = isoparse(_last_transition_at)\n\n        sandbox_status = cls(\n            state=state,\n            reason=reason,\n            message=message,\n            last_transition_at=last_transition_at,\n        )\n\n        sandbox_status.additional_properties = d\n        return sandbox_status\n\n    @property\n    def additional_keys(self) -> list[str]:\n        return list(self.additional_properties.keys())\n\n    def __getitem__(self, key: str) -> Any:\n        return self.additional_properties[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        self.additional_properties[key] = value\n\n    def __delitem__(self, key: str) -> None:\n        del self.additional_properties[key]\n\n    def __contains__(self, key: str) -> bool:\n        return key in self.additional_properties\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom __future__ import annotations\n\nfrom collections.abc import Mapping\nfrom typing import TYPE_CHECKING, Any, TypeVar\n\nfrom attrs import define as _attrs_define\n\nfrom ..types import UNSET, Unset\n\nif TYPE_CHECKING:\n    from ..models.host import Host\n    from ..models.ossfs import OSSFS\n    from ..models.pvc import PVC\n\n\nT = TypeVar(\"T\", bound=\"Volume\")\n\n\n@_attrs_define\nclass Volume:\n    \"\"\"Storage mount definition for a sandbox. Each volume entry contains:\n    - A unique name identifier\n    - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields\n    - Common mount settings (mountPath, readOnly, subPath)\n\n        Attributes:\n            name (str): Unique identifier for the volume within the sandbox.\n                Must be a valid DNS label (lowercase alphanumeric, hyphens allowed, max 63 chars).\n            mount_path (str): Absolute path inside the container where the volume is mounted.\n                Must start with '/'.\n            host (Host | Unset): Host path bind mount backend. Maps a directory on the host filesystem\n                into the container. Only available when the runtime supports host mounts.\n\n                Security note: Host paths are restricted by server-side allowlist.\n                Users must specify paths under permitted prefixes.\n            pvc (PVC | Unset): Platform-managed named volume backend. A runtime-neutral abstraction\n                for referencing a pre-existing, platform-managed named volume.\n\n                - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.\n                - Docker: maps to a Docker named volume (created via `docker volume create`).\n\n                The volume must already exist on the target platform before sandbox\n                creation.\n            ossfs (OSSFS | Unset): Alibaba Cloud OSS mount backend via ossfs.\n\n                The runtime mounts a host-side OSS path under `storage.ossfs_mount_root`\n                and bind-mounts the resolved path into the sandbox container.\n                Prefix selection is expressed via `Volume.subPath`.\n                In Docker runtime, OSSFS backend requires OpenSandbox Server to run on a Linux host with FUSE support.\n            read_only (bool | Unset): If true, the volume is mounted as read-only. Defaults to false (read-write).\n                 Default: False.\n            sub_path (str | Unset): Optional subdirectory under the backend path to mount.\n                For `ossfs` backend, this field is used as the bucket prefix.\n                Must be a relative path without '..' components.\n    \"\"\"\n\n    name: str\n    mount_path: str\n    host: Host | Unset = UNSET\n    pvc: PVC | Unset = UNSET\n    ossfs: OSSFS | Unset = UNSET\n    read_only: bool | Unset = False\n    sub_path: str | Unset = UNSET\n\n    def to_dict(self) -> dict[str, Any]:\n        name = self.name\n\n        mount_path = self.mount_path\n\n        host: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.host, Unset):\n            host = self.host.to_dict()\n\n        pvc: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.pvc, Unset):\n            pvc = self.pvc.to_dict()\n\n        ossfs: dict[str, Any] | Unset = UNSET\n        if not isinstance(self.ossfs, Unset):\n            ossfs = self.ossfs.to_dict()\n\n        read_only = self.read_only\n\n        sub_path = self.sub_path\n\n        field_dict: dict[str, Any] = {}\n\n        field_dict.update(\n            {\n                \"name\": name,\n                \"mountPath\": mount_path,\n            }\n        )\n        if host is not UNSET:\n            field_dict[\"host\"] = host\n        if pvc is not UNSET:\n            field_dict[\"pvc\"] = pvc\n        if ossfs is not UNSET:\n            field_dict[\"ossfs\"] = ossfs\n        if read_only is not UNSET:\n            field_dict[\"readOnly\"] = read_only\n        if sub_path is not UNSET:\n            field_dict[\"subPath\"] = sub_path\n\n        return field_dict\n\n    @classmethod\n    def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:\n        from ..models.host import Host\n        from ..models.ossfs import OSSFS\n        from ..models.pvc import PVC\n\n        d = dict(src_dict)\n        name = d.pop(\"name\")\n\n        mount_path = d.pop(\"mountPath\")\n\n        _host = d.pop(\"host\", UNSET)\n        host: Host | Unset\n        if isinstance(_host, Unset):\n            host = UNSET\n        else:\n            host = Host.from_dict(_host)\n\n        _pvc = d.pop(\"pvc\", UNSET)\n        pvc: PVC | Unset\n        if isinstance(_pvc, Unset):\n            pvc = UNSET\n        else:\n            pvc = PVC.from_dict(_pvc)\n\n        _ossfs = d.pop(\"ossfs\", UNSET)\n        ossfs: OSSFS | Unset\n        if isinstance(_ossfs, Unset):\n            ossfs = UNSET\n        else:\n            ossfs = OSSFS.from_dict(_ossfs)\n\n        read_only = d.pop(\"readOnly\", UNSET)\n\n        sub_path = d.pop(\"subPath\", UNSET)\n\n        volume = cls(\n            name=name,\n            mount_path=mount_path,\n            host=host,\n            pvc=pvc,\n            ossfs=ossfs,\n            read_only=read_only,\n            sub_path=sub_path,\n        )\n\n        return volume\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/py.typed",
    "content": "# Marker file for PEP 561"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/api/lifecycle/types.py",
    "content": "#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n\"\"\"Contains some shared types for properties\"\"\"\n\nfrom collections.abc import Mapping, MutableMapping\nfrom http import HTTPStatus\nfrom typing import IO, BinaryIO, Generic, Literal, TypeVar\n\nfrom attrs import define\n\n\nclass Unset:\n    def __bool__(self) -> Literal[False]:\n        return False\n\n\nUNSET: Unset = Unset()\n\n# The types that `httpx.Client(files=)` can accept, copied from that library.\nFileContent = IO[bytes] | bytes | str\nFileTypes = (\n    # (filename, file (or bytes), content_type)\n    tuple[str | None, FileContent, str | None]\n    # (filename, file (or bytes), content_type, headers)\n    | tuple[str | None, FileContent, str | None, Mapping[str, str]]\n)\nRequestFiles = list[tuple[str, FileTypes]]\n\n\n@define\nclass File:\n    \"\"\"Contains information for file uploads\"\"\"\n\n    payload: BinaryIO\n    file_name: str | None = None\n    mime_type: str | None = None\n\n    def to_tuple(self) -> FileTypes:\n        \"\"\"Return a tuple representation that httpx will accept for multipart/form-data\"\"\"\n        return self.file_name, self.payload, self.mime_type\n\n\nT = TypeVar(\"T\")\n\n\n@define\nclass Response(Generic[T]):\n    \"\"\"A response from an endpoint\"\"\"\n\n    status_code: HTTPStatus\n    content: bytes\n    headers: MutableMapping[str, str]\n    parsed: T | None\n\n\n__all__ = [\"UNSET\", \"File\", \"FileTypes\", \"RequestFiles\", \"Response\", \"Unset\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/config/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nConfiguration module for OpenSandbox SDK.\n\"\"\"\n\nfrom opensandbox.config.connection import ConnectionConfig\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\n\n__all__ = [\"ConnectionConfig\", \"ConnectionConfigSync\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/config/connection.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nConnection configuration for OpenSandbox operations.\n\"\"\"\n\nimport os\nfrom datetime import timedelta\n\nimport httpx  # type: ignore[reportMissingImports]\nfrom pydantic import (  # type: ignore[reportMissingImports]\n    BaseModel,\n    ConfigDict,\n    Field,\n    PrivateAttr,\n    field_validator,\n)\n\n\nclass ConnectionConfig(BaseModel):\n    \"\"\"\n    Sandbox operations connection configuration.\n\n    Transport lifecycle:\n    - If `transport` is NOT provided, the SDK creates a default `httpx.AsyncHTTPTransport`\n      per Sandbox/Manager instance. In this case, `Sandbox.close()` / `SandboxManager.close()`\n      will close the transport.\n    - If `transport` IS provided by the user, the SDK will NOT close it; the user owns it.\n\n    Note:\n    - Async transports are generally expected to be used within a single asyncio event loop.\n      If your test runner creates multiple event loops (common in pytest-asyncio defaults),\n      avoid sharing a single `ConnectionConfig(transport=...)` instance across loops.\n    \"\"\"\n\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    _owns_transport: bool = PrivateAttr(default=True)\n\n    api_key: str | None = Field(\n        default=None, description=\"API key for authentication with sandbox service\"\n    )\n    domain: str | None = Field(\n        default=None, description=\"Base domain for the sandbox management API\"\n    )\n    protocol: str = Field(default=\"http\", description=\"Protocol to use (http/https)\")\n    request_timeout: timedelta = Field(\n        default=timedelta(seconds=30),\n        description=\"Timeout for HTTP requests to the management API\",\n    )\n    debug: bool = Field(\n        default=False, description=\"Enable debug logging for HTTP requests\"\n    )\n    user_agent: str = Field(\n        default=\"OpenSandbox-Python-SDK/0.1.5\", description=\"User agent string\"\n    )\n    headers: dict[str, str] = Field(\n        default_factory=dict, description=\"User defined headers\"\n    )\n    transport: httpx.AsyncBaseTransport | None = Field(\n        default=None,\n        description=(\n            \"Shared httpx transport instance used by all HTTP clients within a \"\n            \"Sandbox/Manager instance. Pass a custom transport (e.g. AsyncHTTPTransport \"\n            \"with custom settings) to control connection pooling, proxies, retries, etc.\"\n        ),\n    )\n    use_server_proxy: bool = Field(\n        default=False,\n        description=(\n            \"Using sandbox server as proxy for process execd requests\"\n            \"It's useful when client sdk can't access the created sandbox directly\"\n        ),\n    )\n\n    # Environment variable names\n    _ENV_API_KEY = \"OPEN_SANDBOX_API_KEY\"\n    _ENV_DOMAIN = \"OPEN_SANDBOX_DOMAIN\"\n    _DEFAULT_DOMAIN = \"localhost:8080\"\n    _API_VERSION = \"v1\"\n\n    def model_post_init(self, __context: object) -> None:\n        # If the user explicitly provided `transport`, the SDK must not close it.\n        self._owns_transport = \"transport\" not in self.model_fields_set\n\n    def with_transport_if_missing(self) -> \"ConnectionConfig\":\n        \"\"\"\n        Ensure a transport exists for this SDK resource.\n\n        If `transport` is missing, return a copy with a default transport and\n        mark it as SDK-owned. If present, return self unchanged.\n        \"\"\"\n        if self.transport is not None:\n            return self\n        transport = httpx.AsyncHTTPTransport(\n            limits=httpx.Limits(\n                max_connections=100,\n                max_keepalive_connections=20,\n                keepalive_expiry=30.0,\n            ),\n        )\n        config = self.model_copy(update={\"transport\": transport})\n        config._owns_transport = True\n        return config\n\n    async def close_transport_if_owned(self) -> None:\n        \"\"\"Close the transport only if it was created by default_factory.\"\"\"\n        if self.transport is None or not self._owns_transport:\n            return\n        try:\n            await self.transport.aclose()\n        except Exception:\n            # Avoid raising during cleanup paths\n            pass\n\n    @field_validator(\"protocol\")\n    @classmethod\n    def protocol_must_be_valid(cls, v: str) -> str:\n        v = v.lower()\n        if v not in (\"http\", \"https\"):\n            raise ValueError(\"Protocol must be 'http' or 'https'\")\n        return v\n\n    @field_validator(\"request_timeout\")\n    @classmethod\n    def timeout_must_be_positive(cls, v: timedelta) -> timedelta:\n        if v.total_seconds() <= 0:\n            raise ValueError(f\"Request timeout must be positive, got: {v}\")\n        return v\n\n    def get_api_key(self) -> str:\n        \"\"\"\n        Get API key from config or environment variable.\n        Returns:\n            API key string (may be empty if not configured)\n        Note: An empty API key may cause authentication failures.\n        Consider checking if the key is set before making API calls.\n        \"\"\"\n        return self.api_key or os.getenv(self._ENV_API_KEY, \"\")\n\n    def get_domain(self) -> str:\n        \"\"\"Get domain from config or environment variable.\"\"\"\n        return self.domain or os.getenv(self._ENV_DOMAIN, self._DEFAULT_DOMAIN)\n\n    def get_base_url(self) -> str:\n        \"\"\"Get the full base URL for API requests.\"\"\"\n        domain = self.get_domain()\n        # Allow domain to override protocol if it explicitly starts with a scheme\n        if domain.startswith(\"http://\") or domain.startswith(\n            \"https://\"\n        ):\n            return f\"{domain}/{self._API_VERSION}\"\n        return f\"{self.protocol}://{domain}/{self._API_VERSION}\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/config/connection_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous connection configuration for OpenSandbox SDK.\n\nThis mirrors ConnectionConfig (async) but uses httpx sync transports.\n\"\"\"\n\nimport os\nfrom datetime import timedelta\n\nimport httpx\nfrom pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator\n\n\nclass ConnectionConfigSync(BaseModel):\n    \"\"\"\n    Synchronous connection configuration shared across all sync SDK HTTP clients.\n\n    Ownership rules:\n    - If `transport` is not provided, the SDK creates a default HTTPTransport per\n      Sandbox/Manager instance and will close it.\n    - If `transport` is provided, the SDK will NOT close it (user owns it).\n    \"\"\"\n\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    _owns_transport: bool = PrivateAttr(default=True)\n\n    api_key: str | None = Field(\n        default=None, description=\"API key for authentication with sandbox service\"\n    )\n    domain: str | None = Field(\n        default=None, description=\"Base domain for the sandbox management API\"\n    )\n    protocol: str = Field(default=\"http\", description=\"Protocol to use (http/https)\")\n    request_timeout: timedelta = Field(\n        default=timedelta(seconds=30),\n        description=\"Timeout for HTTP requests to the management API\",\n    )\n    debug: bool = Field(default=False, description=\"Enable debug logging for HTTP requests\")\n    user_agent: str = Field(\n        default=\"OpenSandbox-Python-SDK/0.1.5\", description=\"User agent string\"\n    )\n    headers: dict[str, str] = Field(default_factory=dict, description=\"User defined headers\")\n\n    transport: httpx.BaseTransport | None = Field(\n        default=None,\n        description=(\n            \"Shared httpx transport instance used by all HTTP clients within a \"\n            \"Sandbox/Manager instance. Pass a custom transport (e.g. HTTPTransport \"\n            \"with custom limits/proxies) to control connection pooling, proxies, retries, etc.\"\n        ),\n    )\n    use_server_proxy: bool = Field(\n        default=False,\n        description=(\n            \"Using sandbox server as proxy for process execd requests\"\n            \"It's useful when client sdk can't access the created sandbox directly\"\n        ),\n    )\n\n    _ENV_API_KEY = \"OPEN_SANDBOX_API_KEY\"\n    _ENV_DOMAIN = \"OPEN_SANDBOX_DOMAIN\"\n    _DEFAULT_DOMAIN = \"localhost:8080\"\n    _API_VERSION = \"v1\"\n\n    def model_post_init(self, __context: object) -> None:\n        self._owns_transport = \"transport\" not in self.model_fields_set\n\n    def with_transport_if_missing(self) -> \"ConnectionConfigSync\":\n        \"\"\"\n        Ensure a transport exists for this SDK resource.\n\n        If `transport` is missing, return a copy with a default transport and\n        mark it as SDK-owned. If present, return self unchanged.\n        \"\"\"\n        if self.transport is not None:\n            return self\n        transport = httpx.HTTPTransport(\n            limits=httpx.Limits(\n                max_connections=100,\n                max_keepalive_connections=20,\n                keepalive_expiry=30.0,\n            ),\n        )\n        config = self.model_copy(update={\"transport\": transport})\n        config._owns_transport = True\n        return config\n\n    def close_transport_if_owned(self) -> None:\n        \"\"\"Close the transport only if it was created by default_factory.\"\"\"\n        if self.transport is None or not self._owns_transport:\n            return\n        try:\n            self.transport.close()\n        except Exception:\n            pass\n\n    @field_validator(\"protocol\")\n    @classmethod\n    def protocol_must_be_valid(cls, v: str) -> str:\n        v = v.lower()\n        if v not in (\"http\", \"https\"):\n            raise ValueError(\"Protocol must be 'http' or 'https'\")\n        return v\n\n    @field_validator(\"request_timeout\")\n    @classmethod\n    def timeout_must_be_positive(cls, v: timedelta) -> timedelta:\n        if v.total_seconds() <= 0:\n            raise ValueError(f\"Request timeout must be positive, got: {v}\")\n        return v\n\n    def get_api_key(self) -> str:\n        return self.api_key or os.getenv(self._ENV_API_KEY, \"\")\n\n    def get_domain(self) -> str:\n        return self.domain or os.getenv(self._ENV_DOMAIN, self._DEFAULT_DOMAIN)\n\n    def get_base_url(self) -> str:\n        domain = self.get_domain()\n        if domain.startswith(\"http://\") or domain.startswith(\"https://\"):\n            return f\"{domain}/{self._API_VERSION}\"\n        return f\"{self.protocol}://{domain}/{self._API_VERSION}\"\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/constants.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nShared constants for the Sandbox SDK.\n\"\"\"\n\nDEFAULT_EXECD_PORT = 44772\nDEFAULT_EGRESS_PORT = 18080\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/exceptions/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nException definitions for OpenSandbox SDK.\n\"\"\"\n\nfrom opensandbox.exceptions.sandbox import (\n    InvalidArgumentException,\n    SandboxApiException,\n    SandboxError,\n    SandboxException,\n    SandboxInternalException,\n    SandboxReadyTimeoutException,\n    SandboxUnhealthyException,\n)\n\n__all__ = [\n    \"SandboxException\",\n    \"SandboxApiException\",\n    \"SandboxInternalException\",\n    \"SandboxUnhealthyException\",\n    \"SandboxReadyTimeoutException\",\n    \"InvalidArgumentException\",\n    \"SandboxError\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/exceptions/sandbox.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox-related exception definitions.\n\"\"\"\n\n\nclass SandboxError:\n    \"\"\"\n    Defines standardized common error codes and messages for the Sandbox SDK.\n    \"\"\"\n\n    INTERNAL_UNKNOWN_ERROR = \"INTERNAL_UNKNOWN_ERROR\"\n    READY_TIMEOUT = \"READY_TIMEOUT\"\n    UNHEALTHY = \"UNHEALTHY\"\n    INVALID_ARGUMENT = \"INVALID_ARGUMENT\"\n    UNEXPECTED_RESPONSE = \"UNEXPECTED_RESPONSE\"\n\n    def __init__(self, code: str, message: str | None = None) -> None:\n        self.code = code\n        self.message = message\n\n    def __repr__(self) -> str:\n        return f\"SandboxError(code='{self.code}', message='{self.message}')\"\n\n\nclass SandboxException(Exception):\n    \"\"\"\n    Base exception class for all sandbox-related errors.\n\n    This is the root exception class that all other sandbox exceptions inherit from.\n    It provides a consistent error structure across the SDK.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n        error: SandboxError | None = None,\n        request_id: str | None = None,\n    ) -> None:\n        super().__init__(message)\n        self.__cause__ = cause\n        self.error = error or SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR)\n        self.request_id = request_id\n\n\nclass SandboxApiException(SandboxException):\n    \"\"\"\n    Thrown when the Sandbox API returns an error response (e.g., HTTP 4xx or 5xx)\n    or meets unexpected error when calling API.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n        status_code: int | None = None,\n        error: SandboxError | None = None,\n        request_id: str | None = None,\n    ) -> None:\n        super().__init__(\n            message,\n            cause,\n            error or SandboxError(SandboxError.UNEXPECTED_RESPONSE),\n            request_id=request_id,\n        )\n        self.status_code = status_code\n\n\nclass SandboxInternalException(SandboxException):\n    \"\"\"\n    Thrown when an unexpected internal error occurs within the SDK.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n    ) -> None:\n        super().__init__(\n            message, cause, SandboxError(SandboxError.INTERNAL_UNKNOWN_ERROR)\n        )\n\n\nclass SandboxUnhealthyException(SandboxException):\n    \"\"\"\n    Thrown when the sandbox is determined to be unhealthy.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n    ) -> None:\n        super().__init__(message, cause, SandboxError(SandboxError.UNHEALTHY, message))\n\n\nclass SandboxReadyTimeoutException(SandboxException):\n    \"\"\"\n    Thrown when the operation times out waiting for the sandbox to become ready.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n    ) -> None:\n        super().__init__(\n            message, cause, SandboxError(SandboxError.READY_TIMEOUT, message)\n        )\n\n\nclass InvalidArgumentException(SandboxException):\n    \"\"\"\n    Thrown when an invalid argument is provided to an SDK method.\n    Similar to ValueError but within the SDK's exception hierarchy.\n    \"\"\"\n\n    def __init__(\n        self,\n        message: str | None = None,\n        cause: Exception | None = None,\n    ) -> None:\n        super().__init__(\n            message, cause, SandboxError(SandboxError.INVALID_ARGUMENT, message)\n        )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/manager.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox management interface for administrative operations.\n\nThis module provides a centralized interface for managing sandbox instances,\nenabling administrative operations and sandbox discovery following the Kotlin SDK pattern.\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta, timezone\n\nfrom opensandbox.adapters.factory import AdapterFactory\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import (\n    PagedSandboxInfos,\n    SandboxFilter,\n    SandboxInfo,\n    SandboxRenewResponse,\n)\nfrom opensandbox.services.sandbox import Sandboxes\n\nlogger = logging.getLogger(__name__)\n\n\nclass SandboxManager:\n    \"\"\"\n    Sandbox management interface for administrative operations and monitoring sandbox instances.\n\n    This class provides a centralized interface for managing sandbox instances,\n    enabling administrative operations and sandbox discovery.\n    It focuses on high-level management operations rather than individual sandbox interactions.\n\n    Key Features:\n\n    - **Sandbox Discovery**: List and filter sandbox instances by various criteria\n    - **Administrative Operations**: Individual sandbox management operations\n    - **Connection Pool Management**: Efficient HTTP client reuse for multiple operations\n\n    Usage Example:\n\n    ```python\n    # Create manager\n    manager = await SandboxManager.create(connection_config=connection_config)\n\n    # List all running sandboxes\n    running_sandboxes = await manager.list_sandbox_infos(\n        SandboxFilter(states=[\"RUNNING\"])\n    )\n\n    # Individual operations\n    sandbox_id = \"sandbox-id\"\n    await manager.get_sandbox_info(sandbox_id)\n    await manager.pause_sandbox(sandbox_id)\n    await manager.resume_sandbox(sandbox_id)\n    await manager.kill_sandbox(sandbox_id)\n\n    # Cleanup\n    await manager.close()\n    ```\n\n    **Note**: This class is designed for administrative operations.\n    For individual sandbox interactions, use the Sandbox class directly.\n    \"\"\"\n\n    def __init__(\n        self,\n        sandbox_service: Sandboxes,\n        connection_config: ConnectionConfig,\n    ) -> None:\n        \"\"\"\n        Internal constructor for SandboxManager.\n\n        Note: Use SandboxManager.create() instead.\n\n        Args:\n            sandbox_service: Service for sandbox operations\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n        \"\"\"\n        self._sandbox_service = sandbox_service\n        self._connection_config = connection_config\n\n    @property\n    def connection_config(self) -> ConnectionConfig:\n        \"\"\"Provides access to the connection configuration (including shared transport).\"\"\"\n        return self._connection_config\n\n    @classmethod\n    async def create(\n        cls, connection_config: ConnectionConfig | None = None\n    ) -> \"SandboxManager\":\n        \"\"\"\n        Creates a SandboxManager instance with the provided configuration.\n\n        Args:\n            connection_config: Connection configuration for the manager.\n                             If None, default configuration will be used.\n\n        Returns:\n            SandboxManager: Configured sandbox manager instance\n        \"\"\"\n        config = (connection_config or ConnectionConfig()).with_transport_if_missing()\n        factory = AdapterFactory(config)\n        sandbox_service = factory.create_sandbox_service()\n        return cls(sandbox_service, config)\n\n    async def list_sandbox_infos(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        \"\"\"\n        List sandboxes with filtering options.\n\n        Args:\n            filter: Filter criteria for sandbox listing\n\n        Returns:\n            Paged sandbox information matching the filter criteria\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        return await self._sandbox_service.list_sandboxes(filter)\n\n    async def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        \"\"\"\n        Get information for a single sandbox by its ID.\n\n        Args:\n            sandbox_id: Sandbox ID to retrieve information for\n\n        Returns:\n            SandboxInfo for the specified sandbox\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.debug(f\"Getting info for sandbox: {sandbox_id}\")\n        return await self._sandbox_service.get_sandbox_info(sandbox_id)\n\n    async def kill_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Terminate a single sandbox.\n\n        Args:\n            sandbox_id: Sandbox ID to terminate\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(f\"Terminating sandbox: {sandbox_id}\")\n        await self._sandbox_service.kill_sandbox(sandbox_id)\n        logger.info(f\"Successfully terminated sandbox: {sandbox_id}\")\n\n    async def renew_sandbox(self, sandbox_id: str, timeout: timedelta) -> SandboxRenewResponse:\n        \"\"\"\n        Renew expiration time for a single sandbox.\n\n        The new expiration time will be set to the current time plus the provided duration.\n\n        Args:\n            sandbox_id: Sandbox ID to renew\n            timeout: Duration to add to the current time to set the new expiration\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        # Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.\n        new_expiration = datetime.now(timezone.utc) + timeout\n        logger.info(f\"Renew expiration for sandbox {sandbox_id} to {new_expiration}\")\n        return await self._sandbox_service.renew_sandbox_expiration(\n            sandbox_id, new_expiration\n        )\n\n    async def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a single sandbox while preserving its state.\n\n        Args:\n            sandbox_id: Sandbox ID to pause\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(f\"Pausing sandbox: {sandbox_id}\")\n        await self._sandbox_service.pause_sandbox(sandbox_id)\n\n    async def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a previously paused sandbox.\n\n        Args:\n            sandbox_id: Sandbox ID to resume\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(f\"Resuming sandbox: {sandbox_id}\")\n        await self._sandbox_service.resume_sandbox(sandbox_id)\n\n    async def close(self) -> None:\n        \"\"\"\n        Close local resources associated with this sandbox manager.\n\n        This method closes HTTP client resources and other local resources.\n\n        Note: This method logs errors but does not raise exceptions to avoid\n        issues in context manager cleanup.\n        \"\"\"\n        try:\n            # Close transport only when SDK owns it (default transport).\n            await self._connection_config.close_transport_if_owned()\n        except Exception as e:\n            logger.warning(\n                f\"Error closing resources for sandbox manager: {e}\",\n                exc_info=True\n            )\n\n    async def __aenter__(self) -> \"SandboxManager\":\n        \"\"\"Async context manager entry.\"\"\"\n        return self\n\n    async def __aexit__(\n        self,\n        exc_type: type[BaseException] | None,\n        exc_val: BaseException | None,\n        exc_tb: object,\n    ) -> None:\n        \"\"\"Async context manager exit.\"\"\"\n        await self.close()\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/models/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nOpenSandbox data models.\n\nCore Pydantic models for sandbox operations.\n\"\"\"\n\nfrom opensandbox.models.execd import (\n    CommandLogs,\n    CommandStatus,\n    Execution,\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionInit,\n    ExecutionLogs,\n    ExecutionResult,\n    OutputMessage,\n)\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import (\n    PVC,\n    Host,\n    NetworkPolicy,\n    NetworkRule,\n    PagedSandboxInfos,\n    PaginationInfo,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageAuth,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxMetrics,\n    SandboxState,\n    SandboxStatus,\n    Volume,\n)\n\n__all__ = [\n    # Execution models\n    \"Execution\",\n    \"ExecutionLogs\",\n    \"OutputMessage\",\n    \"ExecutionResult\",\n    \"ExecutionError\",\n    \"ExecutionComplete\",\n    \"ExecutionInit\",\n    \"CommandStatus\",\n    \"CommandLogs\",\n    # Filesystem models\n    \"EntryInfo\",\n    \"WriteEntry\",\n    \"MoveEntry\",\n    \"SetPermissionEntry\",\n    \"ContentReplaceEntry\",\n    \"SearchEntry\",\n    # Sandbox models\n    \"SandboxInfo\",\n    \"SandboxStatus\",\n    \"SandboxState\",\n    \"NetworkPolicy\",\n    \"NetworkRule\",\n    \"SandboxCreateResponse\",\n    \"SandboxEndpoint\",\n    \"SandboxImageSpec\",\n    \"SandboxImageAuth\",\n    \"SandboxFilter\",\n    \"SandboxMetrics\",\n    \"PagedSandboxInfos\",\n    \"PaginationInfo\",\n    # Volume models\n    \"Volume\",\n    \"Host\",\n    \"PVC\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/models/execd.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nExecution-related data models.\n\nModels for code execution, results, and output handling.\n\"\"\"\n\nfrom collections.abc import Awaitable, Callable\nfrom datetime import datetime, timedelta\nfrom typing import Any\n\nfrom pydantic import BaseModel, ConfigDict, Field, model_validator\n\n\nclass OutputMessage(BaseModel):\n    \"\"\"\n    Output message from code execution.\n\n    Represents a single output message from either stdout or stderr streams\n    during code execution, including timing information.\n    \"\"\"\n\n    text: str = Field(description=\"The text content of the output message\")\n    timestamp: int = Field(\n        description=\"Unix timestamp in milliseconds when message was generated\"\n    )\n    is_error: bool = Field(\n        default=False, description=\"True if message came from stderr\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass ExecutionResult(BaseModel):\n    \"\"\"\n    Result of code execution.\n\n    Represents a single output result from code execution, which may include\n    text content, formatting information, and timing data.\n    \"\"\"\n\n    text: str | None = Field(default=None, description=\"UTF-8 encoded text content\")\n    timestamp: int = Field(\n        description=\"Unix timestamp in milliseconds when result was created\"\n    )\n    extra_properties: dict[str, str] = Field(\n        default_factory=dict,\n        description=\"Additional result content in UTF-8 format\",\n        alias=\"extra_properties\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass ExecutionError(BaseModel):\n    \"\"\"\n    Error information when code execution fails.\n\n    Contains detailed error information following standard error reporting format,\n    including error type, message, timing, and stack trace for debugging purposes.\n    \"\"\"\n\n    name: str = Field(\n        description=\"Error name/type (e.g., 'SyntaxError', 'RuntimeError')\"\n    )\n    value: str = Field(description=\"Error message explaining what went wrong\")\n    timestamp: int = Field(\n        description=\"Unix timestamp in milliseconds when error occurred\"\n    )\n    traceback: list[str] = Field(default_factory=list, description=\"Stack trace lines\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass ExecutionLogs(BaseModel):\n    \"\"\"\n    Container for execution output logs.\n\n    Separates standard output and error output streams for better organization\n    and allows users to process different types of output appropriately.\n    \"\"\"\n\n    stdout: list[\"OutputMessage\"] = Field(\n        default_factory=list, description=\"Standard output messages\"\n    )\n    stderr: list[\"OutputMessage\"] = Field(\n        default_factory=list, description=\"Standard error messages\"\n    )\n\n    def add_stdout(self, message: OutputMessage) -> None:\n        \"\"\"Add a message to standard output log.\"\"\"\n        self.stdout.append(message)\n\n    def add_stderr(self, message: OutputMessage) -> None:\n        \"\"\"Add a message to standard error log.\"\"\"\n        self.stderr.append(message)\n\n\nclass ExecutionComplete(BaseModel):\n    \"\"\"\n    Execution completion event.\n\n    Represents the completion of a code execution,\n    including timing information about when the execution finished.\n    \"\"\"\n\n    timestamp: int = Field(description=\"Unix timestamp when execution completed\")\n    execution_time_in_millis: int = Field(\n        description=\"Execution time in milliseconds\", alias=\"execution_time_in_millis\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass ExecutionInit(BaseModel):\n    \"\"\"\n    Execution initialization event.\n\n    Represents the initialization of a code execution.\n    \"\"\"\n\n    id: str = Field(description=\"Execution identifier\")\n    timestamp: int = Field(description=\"Unix timestamp when execution started\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass Execution(BaseModel):\n    \"\"\"\n    Complete code execution session.\n\n    This is the main model that tracks the entire lifecycle of code execution,\n    including results, errors, and output logs. It serves as the central container\n    for all execution-related data that is exposed to users.\n    \"\"\"\n\n    id: str | None = Field(default=None, description=\"Unique execution identifier\")\n    execution_count: int | None = Field(\n        default=None,\n        description=\"Sequential execution counter\",\n        alias=\"execution_count\",\n    )\n    result: list[\"ExecutionResult\"] = Field(\n        default_factory=list, description=\"Execution results\"\n    )\n    error: ExecutionError | None = Field(\n        default=None, description=\"Error information if failed\"\n    )\n    logs: ExecutionLogs = Field(\n        default_factory=ExecutionLogs, description=\"Output logs\"\n    )\n\n    def add_result(self, result: ExecutionResult) -> None:\n        \"\"\"Add a new execution result.\"\"\"\n        self.result.append(result)\n\n    @property\n    def text(self) -> str:\n        \"\"\"Return combined stdout and result text.\n\n        Includes both stdout log messages and execution results,\n        stripping trailing newlines from each chunk to avoid double\n        line breaks when messages already contain trailing newlines\n        (e.g. code-interpreter streaming output).\n        \"\"\"\n        chunks: list[str] = []\n\n        for msg in self.logs.stdout:\n            chunks.append(msg.text.rstrip(\"\\n\"))\n\n        for res in self.result:\n            if res.text:\n                chunks.append(res.text.rstrip(\"\\n\"))\n\n        return \"\\n\".join(chunks)\n\n    def __str__(self) -> str:\n        \"\"\"Return a human-readable summary of the execution.\"\"\"\n        parts: list[str] = []\n\n        if self.logs.stdout or self.result:\n            parts.append(self.text)\n\n        if self.logs.stderr:\n            stderr_text = \"\\n\".join(msg.text.rstrip(\"\\n\") for msg in self.logs.stderr)\n            parts.append(f\"[stderr]\\n{stderr_text}\")\n\n        if self.error:\n            parts.append(f\"[error] {self.error.name}: {self.error.value}\")\n\n        return \"\\n\".join(parts)\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\n# Type aliases for async handlers\nAsyncOutputHandler = Callable[[Any], Awaitable[None]]\n\n\nclass ExecutionHandlers(BaseModel):\n    \"\"\"\n    Async handlers for code execution output processing.\n\n    Provides optional async callback handlers for different types of execution events.\n    All handlers are async functions that will be awaited when events occur.\n\n    Example:\n        ```python\n        async def handle_stdout(msg: OutputMessage):\n            print(f\"Output: {msg.text}\")\n            # Can perform async operations\n            await log_to_database(msg.text)\n\n        handlers = ExecutionHandlers(\n            on_stdout=handle_stdout,\n            on_stderr=lambda msg: print(f\"Error: {msg.text}\"),\n        )\n        ```\n    \"\"\"\n\n    on_stdout: AsyncOutputHandler | None = Field(\n        default=None, description=\"Async handler for stdout messages\"\n    )\n    on_stderr: AsyncOutputHandler | None = Field(\n        default=None, description=\"Async handler for stderr messages\"\n    )\n    on_result: AsyncOutputHandler | None = Field(\n        default=None, description=\"Async handler for execution results\"\n    )\n    on_execution_complete: AsyncOutputHandler | None = Field(\n        default=None,\n        description=\"Async handler for execution completion\",\n        alias=\"on_execution_complete\",\n    )\n    on_error: AsyncOutputHandler | None = Field(\n        default=None, description=\"Async handler for execution errors\"\n    )\n    on_init: AsyncOutputHandler | None = Field(\n        default=None, description=\"Async handler for execution init\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)\n\n\nclass RunCommandOpts(BaseModel):\n    \"\"\"\n    Parameters for command execution.\n    \"\"\"\n\n    background: bool = Field(\n        default=False, description=\"Whether to run in background (detached)\"\n    )\n    working_directory: str | None = Field(\n        default=None,\n        description=\"Directory to execute command in\",\n        alias=\"working_directory\",\n    )\n    timeout: timedelta | None = Field(\n        default=None,\n        description=\"Maximum execution time; server will terminate the command when reached. If omitted, the server will not enforce any timeout.\",\n    )\n    uid: int | None = Field(\n        default=None,\n        ge=0,\n        description=\"Unix user ID used to run the command process.\",\n    )\n    gid: int | None = Field(\n        default=None,\n        ge=0,\n        description=\"Unix group ID used to run the command process. Requires uid to be set.\",\n    )\n    envs: dict[str, str] | None = Field(\n        default=None,\n        description=\"Environment variables injected into the command process.\",\n    )\n\n    @model_validator(mode=\"after\")\n    def validate_uid_gid_dependency(self) -> \"RunCommandOpts\":\n        \"\"\"Ensure gid is not used without uid to match server contract.\"\"\"\n        if self.gid is not None and self.uid is None:\n            raise ValueError(\"uid is required when gid is provided\")\n        return self\n\n    model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)\n\n\nclass CommandStatus(BaseModel):\n    \"\"\"\n    Command execution status for foreground/background commands.\n    \"\"\"\n\n    id: str | None = Field(default=None, description=\"Command ID\")\n    content: str | None = Field(default=None, description=\"Original command content\")\n    running: bool | None = Field(\n        default=None, description=\"True if command is still running\"\n    )\n    exit_code: int | None = Field(\n        default=None, description=\"Exit code if the command has finished\"\n    )\n    error: str | None = Field(\n        default=None, description=\"Error message if the command failed\"\n    )\n    started_at: datetime | None = Field(\n        default=None, description=\"Command start time (RFC3339)\", alias=\"started_at\"\n    )\n    finished_at: datetime | None = Field(\n        default=None, description=\"Command finish time (RFC3339)\", alias=\"finished_at\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass CommandLogs(BaseModel):\n    \"\"\"\n    Background command logs with optional tail cursor for incremental reads.\n    \"\"\"\n\n    content: str = Field(description=\"Raw stdout/stderr content\")\n    cursor: int | None = Field(\n        default=None,\n        description=\"Latest tail cursor for incremental reads\",\n    )\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/models/execd_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous execution-related models.\n\nThis mirrors `opensandbox.models.execd` but uses synchronous handlers.\nCore data models (Execution, OutputMessage, etc.) are reused from the async module.\n\"\"\"\n\nfrom collections.abc import Callable\nfrom typing import Any\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\nSyncOutputHandler = Callable[[Any], None]\n\n\nclass ExecutionHandlersSync(BaseModel):\n    \"\"\"\n    Synchronous handlers for streaming execution output.\n    \"\"\"\n\n    on_stdout: SyncOutputHandler | None = Field(default=None)\n    on_stderr: SyncOutputHandler | None = Field(default=None)\n    on_result: SyncOutputHandler | None = Field(default=None)\n    on_execution_complete: SyncOutputHandler | None = Field(default=None, alias=\"on_execution_complete\")\n    on_error: SyncOutputHandler | None = Field(default=None)\n    on_init: SyncOutputHandler | None = Field(default=None)\n\n    model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/models/filesystem.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFilesystem-related data models.\n\nModels for file operations, directory listings, and filesystem metadata.\n\"\"\"\n\nfrom datetime import datetime\nfrom io import IOBase\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\n\n\nclass EntryInfo(BaseModel):\n    \"\"\"\n    Metadata information for a file or directory entry.\n\n    Contains complete filesystem metadata including path, permissions, ownership,\n    size, and timestamp information for files and directories in the sandbox.\n    \"\"\"\n\n    path: str = Field(description=\"Absolute path of the file or directory\")\n    mode: int = Field(description=\"Unix file mode/permissions as integer (e.g., 644)\")\n    owner: str = Field(description=\"Owner username of the file or directory\")\n    group: str = Field(description=\"Group name of the file or directory\")\n    size: int = Field(description=\"Size of the file in bytes (0 for directories)\")\n    modified_at: datetime = Field(\n        description=\"Timestamp when entry was last modified\", alias=\"modified_at\"\n    )\n    created_at: datetime = Field(\n        description=\"Timestamp when entry was created\", alias=\"created_at\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass WriteEntry(BaseModel):\n    \"\"\"\n    Request to write content to a file.\n\n    Creates or overwrites a file with the specified content, permissions, and ownership.\n    Supports both text and binary data through flexible data parameter.\n    \"\"\"\n\n    path: str = Field(description=\"Destination file path where content will be written\")\n    data: str | bytes | IOBase | None = Field(\n        default=None, description=\"Content to write - can be str or bytes\"\n    )\n    mode: int = Field(default=755, description=\"Unix file permissions as integer\")\n    owner: str | None = Field(default=None, description=\"Owner username to set\")\n    group: str | None = Field(default=None, description=\"Group name to set\")\n    encoding: str = Field(\n        default=\"utf-8\", description=\"Character encoding for string data\"\n    )\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    @field_validator(\"path\")\n    @classmethod\n    def path_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Path cannot be blank\")\n        return v\n\n    @field_validator(\"mode\")\n    @classmethod\n    def mode_must_be_non_negative(cls, v: int) -> int:\n        if v < 0:\n            raise ValueError(\"Mode must be non-negative\")\n        return v\n\n    @field_validator(\"encoding\")\n    @classmethod\n    def encoding_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Encoding cannot be blank\")\n        return v\n\n\nclass MoveEntry(BaseModel):\n    \"\"\"\n    Request to move/rename a file or directory.\n\n    Moves a file or directory from one location to another within the sandbox filesystem.\n    Can be used for both renaming (same directory) and moving (different directory).\n    \"\"\"\n\n    src: str = Field(\n        description=\"Source path of the file or directory to move\", alias=\"source\"\n    )\n    dest: str = Field(\n        description=\"Destination path where the file or directory should be moved\",\n        alias=\"destination\",\n    )\n\n    @field_validator(\"src\")\n    @classmethod\n    def src_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Source path cannot be blank\")\n        return v\n\n    @field_validator(\"dest\")\n    @classmethod\n    def dest_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Destination path cannot be blank\")\n        return v\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SetPermissionEntry(BaseModel):\n    \"\"\"\n    Request to set permissions/ownership of a file or directory.\n\n    Updates the permissions and/or ownership of an existing file or directory\n    without modifying its content. Only specified properties will be changed.\n    \"\"\"\n\n    path: str = Field(description=\"Target path of the file or directory to modify\")\n    owner: str | None = Field(default=None, description=\"New owner username\")\n    group: str | None = Field(default=None, description=\"New group name\")\n    mode: int = Field(default=755, description=\"New Unix file permissions as integer\")\n\n    @field_validator(\"path\")\n    @classmethod\n    def path_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Path cannot be blank\")\n        return v\n\n    @field_validator(\"mode\")\n    @classmethod\n    def mode_must_be_non_negative(cls, v: int) -> int:\n        if v < 0:\n            raise ValueError(\"Mode must be non-negative\")\n        return v\n\n\nclass ContentReplaceEntry(BaseModel):\n    \"\"\"\n    Request to replace content within a file.\n\n    Performs string replacement within a file by finding exact matches of the old content\n    and replacing them with new content. Only affects string matches, preserving the rest.\n    \"\"\"\n\n    path: str = Field(description=\"Target file path containing content to replace\")\n    old_content: str = Field(\n        description=\"Exact string content to find and replace\", alias=\"old_content\"\n    )\n    new_content: str = Field(\n        description=\"Replacement string content to substitute\", alias=\"new_content\"\n    )\n\n    @field_validator(\"path\")\n    @classmethod\n    def path_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Path cannot be blank\")\n        return v\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SearchEntry(BaseModel):\n    \"\"\"\n    Request to search for files matching a pattern.\n\n    Searches the filesystem starting from the specified path to find files\n    that match the given pattern. Used for file discovery and filtering.\n    \"\"\"\n\n    path: str = Field(description=\"Starting directory path for the search\")\n    pattern: str = Field(\n        description=\"Search pattern (supports glob patterns like *.py, *.txt)\"\n    )\n\n    @field_validator(\"path\")\n    @classmethod\n    def path_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Path cannot be blank\")\n        return v\n\n    @field_validator(\"pattern\")\n    @classmethod\n    def pattern_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Pattern cannot be blank\")\n        return v\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/models/sandboxes.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox-related data models.\n\nModels for sandbox creation, configuration, status, and lifecycle management.\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Literal\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator\n\n\nclass SandboxImageAuth(BaseModel):\n    \"\"\"\n    Authentication credentials for container registries.\n    \"\"\"\n\n    username: str = Field(description=\"Registry username\")\n    password: str = Field(description=\"Registry password or access token\")\n\n    @field_validator(\"username\")\n    @classmethod\n    def username_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Username cannot be blank\")\n        return v\n\n    @field_validator(\"password\")\n    @classmethod\n    def password_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Password cannot be blank\")\n        return v\n\n\nclass SandboxImageSpec(BaseModel):\n    \"\"\"\n    Specification for a sandbox container image.\n\n    Usage:\n        # Simple creation with just image\n        spec = SandboxImageSpec(\"python:3.11\")\n\n        # With private registry auth\n        spec = SandboxImageSpec(\n            \"my-registry.com/image:tag\",\n            auth=SandboxImageAuth(username=\"user\", password=\"pass\")\n        )\n    \"\"\"\n\n    image: str = Field(\n        description=\"Image reference (e.g., 'ubuntu:22.04', 'python:3.11')\"\n    )\n    auth: SandboxImageAuth | None = Field(\n        default=None, description=\"Authentication for private registries\"\n    )\n\n    def __init__(\n        self, image: str | None = None, *, auth: SandboxImageAuth | None = None, **data: object\n    ) -> None:\n        \"\"\"\n        Initialize SandboxImageSpec.\n\n        Args:\n            image: Container image reference (positional or keyword)\n            auth: Optional authentication for private registries\n        \"\"\"\n        if image is not None:\n            data[\"image\"] = image\n        if auth is not None:\n            data[\"auth\"] = auth\n        super().__init__(**data)\n\n    @field_validator(\"image\")\n    @classmethod\n    def image_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Image cannot be blank\")\n        return v\n\n\nclass NetworkRule(BaseModel):\n    \"\"\"\n    Egress rule for matching network targets.\n    \"\"\"\n\n    action: Literal[\"allow\", \"deny\"] = Field(\n        description='Whether to allow or deny matching targets. One of \"allow\" or \"deny\".'\n    )\n    target: str = Field(\n        description='FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").'\n    )\n\n    @field_validator(\"target\")\n    @classmethod\n    def target_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Network rule target cannot be blank\")\n        return v\n\n\nclass NetworkPolicy(BaseModel):\n    \"\"\"\n    Egress network policy matching the sidecar `/policy` request body.\n    \"\"\"\n\n    default_action: Literal[\"allow\", \"deny\"] | None = Field(\n        default=\"deny\",\n        description='Default action when no rule matches. Defaults to \"deny\".',\n        alias=\"defaultAction\",\n    )\n    egress: list[NetworkRule] | None = Field(\n        default=None,\n        description=\"List of egress rules evaluated in order.\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\n# ============================================================================\n# Volume Models\n# ============================================================================\n\n\nclass Host(BaseModel):\n    \"\"\"\n    Host path bind mount backend.\n\n    Maps a directory on the host filesystem into the container.\n    Only available when the runtime supports host mounts.\n    \"\"\"\n\n    path: str = Field(\n        description=\"Absolute path on the host filesystem to mount.\"\n    )\n\n    @field_validator(\"path\")\n    @classmethod\n    def path_must_be_absolute(cls, v: str) -> str:\n        if not v.startswith(\"/\"):\n            raise ValueError(\"Host path must be an absolute path starting with '/'\")\n        return v\n\n\nclass PVC(BaseModel):\n    \"\"\"\n    Kubernetes PersistentVolumeClaim mount backend.\n\n    References an existing PVC in the same namespace as the sandbox pod.\n    Only available in Kubernetes runtime.\n    \"\"\"\n\n    claim_name: str = Field(\n        description=\"Name of the PersistentVolumeClaim in the same namespace.\",\n        alias=\"claimName\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n    @field_validator(\"claim_name\")\n    @classmethod\n    def claim_name_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"PVC claim name cannot be blank\")\n        return v\n\n\nclass OSSFS(BaseModel):\n    \"\"\"Alibaba Cloud OSS mount backend via ossfs.\"\"\"\n\n    bucket: str = Field(description=\"OSS bucket name.\")\n    endpoint: str = Field(description=\"OSS endpoint (e.g., oss-cn-hangzhou.aliyuncs.com).\")\n    version: Literal[\"1.0\", \"2.0\"] = Field(\n        default=\"2.0\",\n        description=\"ossfs major version used by runtime mount integration.\",\n    )\n    options: list[str] | None = Field(\n        default=None,\n        description=\"Additional ossfs mount options.\",\n    )\n    access_key_id: str | None = Field(\n        default=None,\n        alias=\"accessKeyId\",\n        description=\"OSS access key ID for inline credentials mode.\",\n    )\n    access_key_secret: str | None = Field(\n        default=None,\n        alias=\"accessKeySecret\",\n        description=\"OSS access key secret for inline credentials mode.\",\n    )\n    model_config = ConfigDict(populate_by_name=True)\n\n    @model_validator(mode=\"after\")\n    def validate_inline_credentials(self) -> \"OSSFS\":\n        if not self.access_key_id or not self.access_key_secret:\n            raise ValueError(\n                \"OSSFS inline credentials are required: accessKeyId and accessKeySecret.\"\n            )\n        return self\n\n\nclass Volume(BaseModel):\n    \"\"\"\n    Storage mount definition for a sandbox.\n\n    Each volume entry contains:\n    - A unique name identifier\n    - Exactly one backend (host, pvc, ossfs) with backend-specific fields\n    - Common mount settings (mount_path, read_only, sub_path)\n\n    Usage:\n        # Host path mount (read-write by default)\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n        )\n\n        # PVC mount (read-only)\n        volume = Volume(\n            name=\"models\",\n            pvc=PVC(claim_name=\"shared-models-pvc\"),\n            mount_path=\"/mnt/models\",\n            read_only=True,\n        )\n    \"\"\"\n\n    name: str = Field(\n        description=\"Unique identifier for the volume within the sandbox.\"\n    )\n    host: Host | None = Field(\n        default=None,\n        description=\"Host path bind mount backend.\",\n    )\n    pvc: PVC | None = Field(\n        default=None,\n        description=\"Kubernetes PersistentVolumeClaim mount backend.\",\n    )\n    ossfs: OSSFS | None = Field(\n        default=None,\n        description=\"OSSFS mount backend.\",\n    )\n    mount_path: str = Field(\n        description=\"Absolute path inside the container where the volume is mounted.\",\n        alias=\"mountPath\",\n    )\n    read_only: bool = Field(\n        default=False,\n        description=\"If true, the volume is mounted as read-only. Defaults to false (read-write).\",\n        alias=\"readOnly\",\n    )\n    sub_path: str | None = Field(\n        default=None,\n        description=\"Optional subdirectory under the backend path to mount.\",\n        alias=\"subPath\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_must_not_be_empty(cls, v: str) -> str:\n        if not v.strip():\n            raise ValueError(\"Volume name cannot be blank\")\n        return v\n\n    @field_validator(\"mount_path\")\n    @classmethod\n    def mount_path_must_be_absolute(cls, v: str) -> str:\n        if not v.startswith(\"/\"):\n            raise ValueError(\"Mount path must be an absolute path starting with '/'\")\n        return v\n\n    @model_validator(mode=\"after\")\n    def validate_exactly_one_backend(self) -> \"Volume\":\n        \"\"\"Ensure exactly one backend (host, pvc, or ossfs) is specified.\"\"\"\n        backends = [self.host, self.pvc, self.ossfs]\n        specified = [b for b in backends if b is not None]\n        if len(specified) == 0:\n            raise ValueError(\n                \"Exactly one backend (host, pvc, ossfs) must be specified, but none was provided.\"\n            )\n        if len(specified) > 1:\n            raise ValueError(\n                \"Exactly one backend (host, pvc, ossfs) must be specified, but multiple were provided.\"\n            )\n        return self\n\n\nclass SandboxStatus(BaseModel):\n    \"\"\"\n    Status information for a sandbox.\n    \"\"\"\n\n    state: str = Field(\n        description=\"Current state (e.g., RUNNING, PENDING, PAUSED, TERMINATED)\"\n    )\n    reason: str | None = Field(\n        default=None, description=\"Short reason code for current state\"\n    )\n    message: str | None = Field(\n        default=None, description=\"Human-readable status message\"\n    )\n    last_transition_at: datetime | None = Field(\n        default=None,\n        description=\"Timestamp of last state transition\",\n        alias=\"last_transition_at\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxInfo(BaseModel):\n    \"\"\"\n    Detailed information about a sandbox instance.\n    \"\"\"\n\n    id: str = Field(description=\"Unique identifier of the sandbox\")\n    status: SandboxStatus = Field(description=\"Current status of the sandbox\")\n    entrypoint: list[str] = Field(\n        description=\"Command line arguments used to start the sandbox\"\n    )\n    expires_at: datetime | None = Field(\n        default=None,\n        description=\"Scheduled termination timestamp. Null means manual cleanup mode.\",\n        alias=\"expires_at\",\n    )\n    created_at: datetime = Field(description=\"Creation timestamp\", alias=\"created_at\")\n    image: SandboxImageSpec | None = Field(\n        default=None, description=\"Image specification used to create sandbox\"\n    )\n    metadata: dict[str, str] | None = Field(default=None, description=\"Custom metadata\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxCreateResponse(BaseModel):\n    \"\"\"\n    Response returned when a sandbox is created.\n    \"\"\"\n\n    id: str = Field(description=\"Unique identifier of the newly created sandbox\")\n\n\nclass SandboxRenewResponse(BaseModel):\n    \"\"\"\n    Response returned when renewing a sandbox expiration time.\n    \"\"\"\n\n    expires_at: datetime = Field(\n        description=\"The new absolute expiration time in UTC (RFC 3339 format).\",\n        alias=\"expires_at\",\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxEndpoint(BaseModel):\n    \"\"\"\n    Connection endpoint information for a sandbox.\n    \"\"\"\n\n    endpoint: str = Field(description=\"Sandbox connection endpoint\")\n    headers: dict[str, str] = Field(\n        default_factory=dict,\n        description=\"Headers that must be included on every request targeting this endpoint (e.g. when the server requires them for routing or auth). Empty if not required.\",\n    )\n\n\nclass PaginationInfo(BaseModel):\n    \"\"\"\n    Pagination metadata.\n    \"\"\"\n\n    page: int = Field(description=\"Current page number (0-indexed)\")\n    page_size: int = Field(description=\"Number of items per page\", alias=\"page_size\")\n    total_items: int = Field(\n        description=\"Total number of items across all pages\", alias=\"total_items\"\n    )\n    total_pages: int = Field(description=\"Total number of pages\", alias=\"total_pages\")\n    has_next_page: bool = Field(\n        description=\"True if there is a next page available\", alias=\"has_next_page\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass PagedSandboxInfos(BaseModel):\n    \"\"\"\n    A paginated list of sandbox information.\n    \"\"\"\n\n    sandbox_infos: list[SandboxInfo] = Field(\n        description=\"List of sandbox details for current page\", alias=\"sandbox_infos\"\n    )\n    pagination: PaginationInfo = Field(description=\"Pagination metadata\")\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxFilter(BaseModel):\n    \"\"\"\n    Filter criteria for listing sandboxes.\n    \"\"\"\n\n    states: list[str] | None = Field(\n        default=None, description=\"Filter by sandbox states\"\n    )\n    metadata: dict[str, str] | None = Field(\n        default=None, description=\"Filter by metadata key-value pairs\"\n    )\n    page_size: int | None = Field(\n        default=None, description=\"Number of items per page\", alias=\"page_size\"\n    )\n    page: int | None = Field(default=None, description=\"Page number (0-indexed)\")\n\n    @field_validator(\"page_size\")\n    @classmethod\n    def page_size_must_be_positive(cls, v: int | None) -> int | None:\n        if v is not None and v <= 0:\n            raise ValueError(\"Page size must be positive\")\n        return v\n\n    @field_validator(\"page\")\n    @classmethod\n    def page_must_be_non_negative(cls, v: int | None) -> int | None:\n        if v is not None and v < 0:\n            raise ValueError(\"Page must be non-negative\")\n        return v\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxMetrics(BaseModel):\n    \"\"\"\n    Real-time resource usage metrics for a sandbox.\n    \"\"\"\n\n    cpu_count: float = Field(\n        description=\"Number of CPU cores available/allocated\", alias=\"cpu_count\"\n    )\n    cpu_used_percentage: float = Field(\n        description=\"Current CPU usage as percentage (0.0 - 100.0)\",\n        alias=\"cpu_used_percentage\",\n    )\n    memory_total_in_mib: float = Field(\n        description=\"Total memory available in Mebibytes\", alias=\"memory_total_in_mib\"\n    )\n    memory_used_in_mib: float = Field(\n        description=\"Memory currently used in Mebibytes\", alias=\"memory_used_in_mib\"\n    )\n    timestamp: int = Field(\n        description=\"Timestamp of metric collection (Unix epoch milliseconds)\"\n    )\n\n    model_config = ConfigDict(populate_by_name=True)\n\n\nclass SandboxState:\n    \"\"\"High-level lifecycle state of the sandbox.\n\n    This class provides constant string values for sandbox states.\n    Note that the sandbox service may introduce new states in future\n    versions; clients should handle unknown string values gracefully.\n\n    Common States:\n        PENDING (str): Sandbox is being provisioned.\n        RUNNING (str): Sandbox is running and ready to accept requests.\n        PAUSING (str): Sandbox is in the process of pausing.\n        PAUSED (str): Sandbox has been paused while retaining its state.\n        STOPPING (str): Sandbox is being terminated.\n        TERMINATED (str): Sandbox has been successfully terminated.\n        FAILED (str): Sandbox encountered a critical error.\n        UNKNOWN (str): State is unknown or unsupported by the current version.\n\n    State Transitions:\n        - Pending -> Running: After creation completes.\n        - Running -> Pausing: When pause is requested.\n        - Pausing -> Paused: After pause operation completes.\n        - Paused -> Running: When resume is requested.\n        - Running/Paused -> Stopping: When kill is requested or TTL expires.\n        - Stopping -> Terminated: After kill/timeout operation completes.\n        - Pending/Running/Paused -> Failed: On critical error.\n    \"\"\"\n\n    PENDING = \"Pending\"\n    RUNNING = \"Running\"\n    PAUSING = \"Pausing\"\n    PAUSED = \"Paused\"\n    STOPPING = \"Stopping\"\n    TERMINATED = \"Terminated\"\n    FAILED = \"Failed\"\n    UNKNOWN = \"Unknown\"\n\n    @classmethod\n    def values(cls) -> set[str]:\n        \"\"\"Returns a set of all known state values.\"\"\"\n        return {\n            v for k, v in cls.__dict__.items()\n            if k.isupper() and not k.startswith(\"_\")\n        }\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/py.typed",
    "content": ""
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sandbox.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nMain Sandbox client implementation.\n\"\"\"\n\nimport asyncio\nimport logging\nimport time\nfrom collections.abc import Awaitable, Callable\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom opensandbox.adapters.factory import AdapterFactory\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.constants import DEFAULT_EGRESS_PORT, DEFAULT_EXECD_PORT\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxException,\n    SandboxInternalException,\n    SandboxReadyTimeoutException,\n)\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    NetworkRule,\n    SandboxEndpoint,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxMetrics,\n    SandboxRenewResponse,\n    Volume,\n)\nfrom opensandbox.services import (\n    Commands,\n    Egress,\n    Filesystem,\n    Health,\n    Metrics,\n    Sandboxes,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass Sandbox:\n    \"\"\"\n    Main entrypoint for the Open Sandbox SDK providing secure, isolated execution environments.\n\n    This class provides a comprehensive interface for interacting with containerized sandbox\n    environments, combining lifecycle management with high-level operations for file system\n    access, command execution, and real-time monitoring.\n\n    Key Features:\n\n    - **Secure Isolation**: Complete Linux OS access in isolated containers\n    - **File System Operations**: Create, read, update, delete files and directories\n    - **Multi-language Execution**: Support for Python, Java, Bash, and other languages\n    - **Real-time Command Execution**: Streaming output with timeout handling\n    - **Resource Management**: CPU, memory, and storage constraints\n    - **Lifecycle Management**: Create, pause, resume, terminate operations\n    - **Health Monitoring**: Automatic readiness detection and status tracking\n\n    Usage Example:\n\n    ```python\n    from opensandbox.models.sandboxes import SandboxImageSpec, SandboxImageAuth\n    from opensandbox.models.execd import RunCommandOpts\n\n    # Create with simple image (positional argument)\n    sandbox = await Sandbox.create(\n        \"python:3.11\",\n        resource={\"cpu\": \"1\", \"memory\": \"500Mi\"},\n        timeout=timedelta(minutes=30)\n    )\n\n    # Or with private registry auth\n    sandbox = await Sandbox.create(\n        SandboxImageSpec(\n            \"my-registry.com/my-image:latest\",\n            auth=SandboxImageAuth(username=\"user\", password=\"pass\")\n        ),\n    )\n\n    # Use the sandbox\n    await sandbox.files.write_file(\"script.py\", \"print('Hello World')\")\n    result = await sandbox.commands.run(\"python script.py\")\n    print(result.logs.stdout[0].text)  # Output: Hello World\n\n    # Always clean up resources\n    await sandbox.kill()\n    await sandbox.close()\n    ```\n    \"\"\"\n\n    def __init__(\n        self,\n        sandbox_id: str,\n        sandbox_service: Sandboxes,\n        filesystem_service: Filesystem,\n        command_service: Commands,\n        health_service: Health,\n        metrics_service: Metrics,\n        egress_service: Egress,\n        connection_config: ConnectionConfig,\n        custom_health_check: Callable[[\"Sandbox\"], Awaitable[bool]] | None = None,\n    ) -> None:\n        \"\"\"\n        Internal constructor for Sandbox. Use Sandbox.create() or Sandbox.connect() instead.\n        \"\"\"\n        self.id = sandbox_id\n        self._sandbox_service = sandbox_service\n        self._filesystem_service = filesystem_service\n        self._command_service = command_service\n        self._health_service = health_service\n        self._metrics_service = metrics_service\n        self._egress_service = egress_service\n        self._connection_config = connection_config\n        self._custom_health_check = custom_health_check\n\n    @property\n    def files(self) -> Filesystem:\n        \"\"\"\n        Provides access to file system operations within the sandbox.\n\n        Allows writing, reading, listing, and deleting files and directories.\n        \"\"\"\n        return self._filesystem_service\n\n    @property\n    def commands(self) -> Commands:\n        \"\"\"\n        Provides access to command execution operations.\n\n        Allows running shell commands, capturing output, and managing processes.\n        \"\"\"\n        return self._command_service\n\n    @property\n    def metrics(self) -> Metrics:\n        \"\"\"\n        Provides access to sandbox metrics and monitoring.\n\n        Allows retrieving resource usage statistics (CPU, memory) and other performance metrics.\n        \"\"\"\n        return self._metrics_service\n\n    @property\n    def connection_config(self) -> ConnectionConfig:\n        \"\"\"Provides access to the connection configuration (including shared transport).\"\"\"\n        return self._connection_config\n\n    async def get_info(self) -> SandboxInfo:\n        \"\"\"\n        Get the current status of this sandbox.\n\n        Returns:\n            Current sandbox status including state and metadata\n\n        Raises:\n            SandboxException: if status cannot be retrieved\n        \"\"\"\n        return await self._sandbox_service.get_sandbox_info(self.id)\n\n    async def get_endpoint(self, port: int) -> SandboxEndpoint:\n        \"\"\"\n        Get a specific network endpoint for this sandbox.\n\n        Args:\n            port: The port number to get the endpoint for\n\n        Returns:\n            Endpoint information including connection details\n\n        Raises:\n            SandboxException: if endpoint cannot be retrieved\n        \"\"\"\n        return await self._sandbox_service.get_sandbox_endpoint(\n            self.id, port, self.connection_config.use_server_proxy\n        )\n\n    async def get_metrics(self) -> SandboxMetrics:\n        \"\"\"\n        Get the current resource usage metrics for this sandbox.\n\n        Returns:\n            Current sandbox metrics including CPU, memory, and I/O statistics\n\n        Raises:\n            SandboxException: if metrics cannot be retrieved\n        \"\"\"\n        return await self._metrics_service.get_metrics(self.id)\n\n    async def renew(self, timeout: timedelta) -> SandboxRenewResponse:\n        \"\"\"\n        Renew the sandbox expiration time to delay automatic termination.\n\n        The new expiration time will be set to the current time plus the provided duration.\n\n        Args:\n            timeout: Duration to add to the current time to set the new expiration\n\n        Returns:\n            Renew response including the new expiration time.\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        # Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.\n        new_expiration = datetime.now(timezone.utc) + timeout\n        logger.info(\n            f\"Renewing sandbox {self.id} timeout, estimated expiration: {new_expiration}\"\n        )\n        return await self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)\n\n    async def get_egress_policy(self) -> NetworkPolicy:\n        \"\"\"\n        Get current egress policy for this sandbox.\n        \"\"\"\n        return await self._egress_service.get_policy()\n\n    async def patch_egress_rules(self, rules: list[NetworkRule]) -> None:\n        \"\"\"\n        Patch egress rules for this sandbox using sidecar merge semantics.\n\n        Rules in this patch payload take priority over existing rules with the\n        same target. Existing rules for other targets remain unchanged. Within a\n        single patch payload, the first rule for a target wins.\n\n        This operation does not replace the entire policy and does not change\n        the current defaultAction.\n        \"\"\"\n        await self._egress_service.patch_rules(rules)\n\n    async def pause(self) -> None:\n        \"\"\"\n        Pause the sandbox while preserving its state.\n\n        The sandbox will transition to PAUSED state and can be resumed later.\n        All running processes will be suspended.\n\n        Raises:\n            SandboxException: if pause operation fails\n        \"\"\"\n        logger.info(f\"Pausing sandbox: {self.id}\")\n        await self._sandbox_service.pause_sandbox(self.id)\n\n\n    async def kill(self) -> None:\n        \"\"\"\n        Send a termination signal to the remote sandbox instance.\n\n        This is an irreversible operation that stops the sandbox immediately.\n\n        Note: This method does NOT close the local resources. Use close() or\n        async context manager to clean up local resources.\n\n        Raises:\n            SandboxException: if termination fails\n        \"\"\"\n        logger.info(f\"Killing sandbox: {self.id}\")\n        await self._sandbox_service.kill_sandbox(self.id)\n\n    async def close(self) -> None:\n        \"\"\"\n        Close local resources associated with this sandbox.\n\n        This method closes HTTP client resources and other local resources.\n        It does NOT terminate the remote sandbox instance. Call kill() first\n        if you want to terminate the remote sandbox.\n\n        Note: This method logs errors but does not raise exceptions to avoid\n        issues in context manager cleanup.\n        \"\"\"\n        try:\n            # Close transport only when SDK owns it (default transport).\n            await self._connection_config.close_transport_if_owned()\n            logger.debug(f\"Closed resources for sandbox {self.id}\")\n        except Exception as e:\n            logger.warning(\n                f\"Error closing resources for sandbox {self.id}: {e}\",\n                exc_info=True\n            )\n\n    async def is_healthy(self) -> bool:\n        \"\"\"\n        Check if the sandbox is healthy and responsive.\n\n        Returns:\n            True if sandbox is healthy, False otherwise\n        \"\"\"\n        if self._custom_health_check:\n            return await self._custom_health_check(self)\n        return await self._ping()\n\n    async def _ping(self) -> bool:\n        \"\"\"Check if the sandbox is alive.\"\"\"\n        try:\n            return await self._health_service.ping(self.id)\n        except Exception:\n            return False\n\n    async def check_ready(\n        self,\n        timeout: timedelta,\n        polling_interval: timedelta,\n    ) -> None:\n        \"\"\"\n        Wait for the sandbox to pass health checks with polling.\n\n        Args:\n            timeout: Maximum time to wait for health check to pass\n            polling_interval: Time between health check attempts\n\n        Raises:\n            SandboxReadyTimeoutException: if health check doesn't pass within timeout\n            SandboxException: if health check fails\n        \"\"\"\n        logger.info(\n            f\"Waiting for sandbox {self.id} to pass health check (timeout: {timeout.total_seconds()}s)\"\n        )\n\n        deadline = time.time() + timeout.total_seconds()\n        attempt = 0\n        last_exception: Exception | None = None\n\n        while time.time() < deadline:\n            attempt += 1\n            logger.debug(f\"Health check attempt #{attempt} for sandbox {self.id}\")\n\n            try:\n                is_healthy = await self.is_healthy()\n                if is_healthy:\n                    logger.info(\n                        f\"Sandbox {self.id} passed health check after {attempt} attempts\"\n                    )\n                    return\n                last_exception = None\n                logger.debug(f\"Health check attempt #{attempt} returned false\")\n            except Exception as e:\n                last_exception = e\n                is_healthy = False\n                logger.debug(\n                    f\"Health check attempt #{attempt} failed with exception: {e}\"\n                )\n\n            if not is_healthy:\n                await asyncio.sleep(polling_interval.total_seconds())\n\n        error_detail = (\n            f\"Last error: {last_exception}\"\n            if last_exception\n            else \"Health check returned false continuously\"\n        )\n        connection_detail = (\n            f\"ConnectionConfig(domain={self.connection_config.get_domain()}, \"\n            f\"use_server_proxy={self.connection_config.use_server_proxy})\"\n        )\n        if self.connection_config.use_server_proxy:\n            hint = (\n                \"Hint: server proxy mode is enabled. Check server-to-sandbox connectivity \"\n                \"and server API key/auth configuration.\"\n            )\n        else:\n            hint = (\n                \"Hint: direct sandbox endpoint access is enabled. If the SDK cannot directly \"\n                \"reach sandbox network/ports, set ConnectionConfig(use_server_proxy=True). \"\n                \"For Docker bridge deployments where server runs in a container, also configure \"\n                \"server [docker].host_ip to a host-reachable address.\"\n            )\n\n        final_message = (\n            f\"Sandbox health check timed out after {timeout.total_seconds()}s \"\n            f\"({attempt} attempts). {error_detail}. {connection_detail}. {hint}\"\n        )\n\n        logger.error(final_message)\n        raise SandboxReadyTimeoutException(final_message)\n\n    @classmethod\n    async def create(\n        cls,\n        image: SandboxImageSpec | str,\n        *,\n        timeout: timedelta | None = timedelta(minutes=10),\n        ready_timeout: timedelta = timedelta(seconds=30),\n        env: dict[str, str] | None = None,\n        metadata: dict[str, str] | None = None,\n        resource: dict[str, str] | None = None,\n        network_policy: NetworkPolicy | None = None,\n        extensions: dict[str, str] | None = None,\n        entrypoint: list[str] | None = None,\n        volumes: list[Volume] | None = None,\n        connection_config: ConnectionConfig | None = None,\n        health_check: Callable[[\"Sandbox\"], Awaitable[bool]] | None = None,\n        health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n        skip_health_check: bool = False,\n    ) -> \"Sandbox\":\n        \"\"\"\n        Create a new sandbox instance with the specified configuration.\n\n        Args:\n            image: Container image specification including image reference and optional auth\n            timeout: Maximum sandbox lifetime. Pass None to require explicit cleanup.\n            ready_timeout: Maximum time to wait for sandbox to become ready\n            env: Environment variables for the sandbox\n            metadata: Custom metadata for the sandbox\n            resource: Resource limits (CPU, memory, etc.)\n            network_policy: Optional outbound network policy (egress).\n            extensions: Opaque extension parameters passed through to the server as-is.\n                Prefer namespaced keys (e.g. ``storage.id``).\n            entrypoint: Command to run as entrypoint\n            volumes: Optional list of volume mounts for persistent storage.\n                Each volume specifies a backend (host path or PVC) and mount configuration.\n            connection_config: Connection configuration\n            health_check: Custom async health check function\n            health_check_polling_interval: Time between health check attempts\n            skip_health_check: If True, do NOT wait for sandbox readiness/health; returned instance may not be ready yet.\n\n        Returns:\n            Fully configured and ready Sandbox instance\n\n        Raises:\n            SandboxException: if sandbox creation or initialization fails\n        \"\"\"\n        config = (connection_config or ConnectionConfig()).with_transport_if_missing()\n        entrypoint = entrypoint or [\"tail\", \"-f\", \"/dev/null\"]\n        env = env or {}\n        metadata = metadata or {}\n        resource = resource or {\"cpu\": \"1\", \"memory\": \"2Gi\"}\n        extensions = extensions or {}\n\n        if isinstance(image, str):\n            image = SandboxImageSpec(image=image)\n\n        timeout_log = \"manual-cleanup\" if timeout is None else f\"{timeout.total_seconds()}s\"\n        logger.info(\n            \"Creating sandbox with image: %s (timeout: %s)\",\n            image.image,\n            timeout_log,\n        )\n        factory = AdapterFactory(config)\n        sandbox_id: str | None = None\n        sandbox_service: Sandboxes | None = None\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            response = await sandbox_service.create_sandbox(\n                image,\n                entrypoint,\n                env,\n                metadata,\n                timeout,\n                resource,\n                network_policy,\n                extensions,\n                volumes,\n            )\n            sandbox_id = response.id\n\n            execd_endpoint = await sandbox_service.get_sandbox_endpoint(\n                response.id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = await sandbox_service.get_sandbox_endpoint(\n                response.id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=response.id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                await sandbox.check_ready(ready_timeout, health_check_polling_interval)\n                logger.info(\"Sandbox %s is ready\", sandbox.id)\n            else:\n                logger.info(\n                    \"Sandbox %s created (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox.id,\n                )\n\n            return sandbox\n        except Exception as e:\n            if sandbox_id and sandbox_service:\n                try:\n                    logger.warning(\n                        \"Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: %s\",\n                        sandbox_id,\n                    )\n                    await sandbox_service.kill_sandbox(sandbox_id)\n                except Exception as cleanup_ex:\n                    logger.error(\n                        \"Failed to clean up sandbox %s after creation failure\",\n                        sandbox_id,\n                        exc_info=cleanup_ex,\n                    )\n\n            await config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            logger.error(\"Unexpected exception during sandbox creation\", exc_info=e)\n            raise SandboxInternalException(\n                f\"Internal exception when creating sandbox: {e}\"\n            ) from e\n\n    @classmethod\n    async def connect(\n        cls,\n        sandbox_id: str,\n        connection_config: ConnectionConfig | None = None,\n        health_check: Callable[[\"Sandbox\"], Awaitable[bool]] | None = None,\n        connect_timeout: timedelta = timedelta(seconds=30),\n        health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n        skip_health_check: bool = False,\n    ) -> \"Sandbox\":\n        \"\"\"\n        Connect to an existing sandbox instance by ID.\n\n        Args:\n            sandbox_id: ID of the existing sandbox\n            connection_config: Connection configuration\n            health_check: Custom async health check function\n            connect_timeout: Max time to wait for sandbox readiness/health after connecting.\n            health_check_polling_interval: Polling interval used while waiting for readiness/health.\n            skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.\n\n        Returns:\n            Connected Sandbox instance\n\n        Raises:\n            InvalidArgumentException: if required configuration is missing\n            SandboxException: if sandbox connection fails\n        \"\"\"\n        if not sandbox_id:\n            raise InvalidArgumentException(\"Sandbox ID must be specified\")\n        # Accept any string identifier.\n        sandbox_id = str(sandbox_id)\n\n        config = (connection_config or ConnectionConfig()).with_transport_if_missing()\n\n        logger.info(f\"Connecting to sandbox: {sandbox_id}\")\n        factory = AdapterFactory(config)\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            execd_endpoint = await sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = await sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=sandbox_id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                await sandbox.check_ready(connect_timeout, health_check_polling_interval)\n            else:\n                logger.info(\n                    \"Connected to sandbox %s (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox_id,\n                )\n\n            logger.info(\"Connected to sandbox %s\", sandbox_id)\n            return sandbox\n        except Exception as e:\n            await config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            logger.error(\"Unexpected exception during sandbox connection\", exc_info=e)\n            raise SandboxInternalException(f\"Failed to connect to sandbox: {e}\") from e\n\n    @classmethod\n    async def resume(\n            cls,\n            sandbox_id: str,\n            connection_config: ConnectionConfig | None = None,\n            health_check: Callable[[\"Sandbox\"], Awaitable[bool]] | None = None,\n            resume_timeout: timedelta = timedelta(seconds=30),\n            health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n            skip_health_check: bool = False,\n    ) -> \"Sandbox\":\n        \"\"\"\n        Resume a paused sandbox by ID and return a new, usable Sandbox instance.\n\n        This method performs the server-side resume operation, then re-resolves the execd endpoint\n        (which may change across pause/resume on some backends), rebuilds service adapters, and\n        optionally waits for readiness/health.\n\n        Args:\n            sandbox_id: ID of the paused sandbox to resume.\n            connection_config: Connection configuration (shared transport, headers, timeouts).\n            health_check: Optional custom async health check function (falls back to ping).\n            resume_timeout: Max time to wait for sandbox readiness/health after resuming.\n            health_check_polling_interval: Polling interval used while waiting for readiness/health.\n            skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.\n        \"\"\"\n        if not sandbox_id:\n            raise InvalidArgumentException(\"Sandbox ID must be specified\")\n        # Accept any string identifier.\n        sandbox_id = str(sandbox_id)\n\n        config = (connection_config or ConnectionConfig()).with_transport_if_missing()\n\n        logger.info(\"Resuming sandbox: %s\", sandbox_id)\n        factory = AdapterFactory(config)\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            await sandbox_service.resume_sandbox(sandbox_id)\n\n            execd_endpoint = await sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = await sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=sandbox_id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                await sandbox.check_ready(resume_timeout, health_check_polling_interval)\n            else:\n                logger.info(\n                    \"Resumed sandbox %s (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox_id,\n                )\n\n            return sandbox\n        except Exception as e:\n            await config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            logger.error(\"Unexpected exception during sandbox resume\", exc_info=e)\n            raise SandboxInternalException(f\"Failed to resume sandbox: {e}\") from e\n\n    async def __aenter__(self) -> \"Sandbox\":\n        \"\"\"Async context manager entry.\"\"\"\n        return self\n\n    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:\n        \"\"\"Async context manager exit.\"\"\"\n        await self.close()\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nOpenSandbox service interfaces.\n\nProtocol definitions for sandbox services.\n\"\"\"\n\nfrom opensandbox.services.command import Commands\nfrom opensandbox.services.egress import Egress\nfrom opensandbox.services.filesystem import Filesystem\nfrom opensandbox.services.health import Health\nfrom opensandbox.services.metrics import Metrics\nfrom opensandbox.services.sandbox import Sandboxes\n\n__all__ = [\n    \"Commands\",\n    \"Egress\",\n    \"Filesystem\",\n    \"Health\",\n    \"Metrics\",\n    \"Sandboxes\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/command.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nCommand service interface.\n\nProtocol for sandbox command execution operations.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.execd import (\n    CommandLogs,\n    CommandStatus,\n    Execution,\n    ExecutionHandlers,\n    RunCommandOpts,\n)\n\n\nclass Commands(Protocol):\n    \"\"\"\n    Command execution service for sandbox environments.\n\n    This service provides secure command execution capabilities within sandbox\n    environments, with support for streaming output, timeout handling, and\n    session management.\n    \"\"\"\n\n    async def run(\n        self,\n        command: str,\n        *,\n        opts: RunCommandOpts | None = None,\n        handlers: ExecutionHandlers | None = None,\n    ) -> Execution:\n        \"\"\"\n        Execute a shell command in the sandbox environment.\n\n        The command can be executed in foreground (streaming) or background mode\n        based on the request configuration.\n\n        Args:\n            command: Shell command text to execute\n            opts: Command execution options (e.g. background, working_directory)\n            handlers: Optional async handlers for streaming events (stdout/stderr/result/init/complete/error)\n\n        Returns:\n            An Execution handle representing the running command instance\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupt and terminate a running command execution.\n\n        This sends a termination signal (usually SIGTERM/SIGKILL) to the process\n        associated with the given execution ID.\n\n        Args:\n            execution_id: Unique identifier of the execution to interrupt\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def get_command_status(self, execution_id: str) -> CommandStatus:\n        \"\"\"\n        Get the current running status for a command.\n\n        Args:\n            execution_id: Unique identifier of the execution to query\n\n        Returns:\n            CommandStatus describing running state and exit code if available\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def get_background_command_logs(\n        self, execution_id: str, cursor: int | None = None\n    ) -> CommandLogs:\n        \"\"\"\n        Get background command logs (non-streamed).\n\n        Args:\n            execution_id: Unique identifier of the execution to query\n            cursor: Optional line cursor for incremental reads\n\n        Returns:\n            CommandLogs containing raw output and latest cursor\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/egress.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nEgress service interface.\n\nProtocol for direct egress sidecar operations.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\n\n\nclass Egress(Protocol):\n    \"\"\"Direct runtime egress policy service.\"\"\"\n\n    async def get_policy(self) -> NetworkPolicy:\n        \"\"\"\n        Retrieve the current egress policy from the sidecar.\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def patch_rules(self, rules: list[NetworkRule]) -> None:\n        \"\"\"\n        Patch egress rules via the sidecar policy API.\n\n        Merge semantics:\n        - Incoming rules take priority over existing rules with the same target.\n        - Existing rules for other targets remain in place.\n        - Within one patch payload, the first rule for a target wins.\n        - The current defaultAction is preserved.\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/filesystem.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nFilesystem service interface.\n\nProtocol for sandbox filesystem operations.\n\"\"\"\nfrom collections.abc import AsyncIterator\nfrom io import IOBase\nfrom typing import Protocol\n\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\n\n\nclass Filesystem(Protocol):\n    \"\"\"\n    Filesystem operations service for sandbox environments.\n\n    This service provides comprehensive file system management capabilities\n    within sandbox environments, including file operations, directory management,\n    and metadata handling with proper security controls.\n    \"\"\"\n\n    async def read_file(\n        self,\n        path: str,\n        *,\n        encoding: str = \"utf-8\",\n        range_header: str | None = None,\n    ) -> str:\n        \"\"\"\n        Read the content of a file as a string with specified encoding.\n\n        Args:\n            path: The absolute or relative path to the file to read\n            encoding: Character encoding for the file content (default: UTF-8)\n            range_header: HTTP byte range to read (e.g., \"bytes=0-1023\")\n\n        Returns:\n            The file content as a string\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def read_bytes(\n        self,\n        path: str,\n        *,\n        range_header: str | None = None,\n    ) -> bytes:\n        \"\"\"\n        Read the content of a file as bytes.\n\n        Args:\n            path: The absolute or relative path to the file to read\n            range_header: HTTP byte range to read (e.g., \"bytes=0-1023\")\n\n        Returns:\n            The file content as bytes\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def read_bytes_stream(\n        self,\n        path: str,\n        *,\n        chunk_size: int = 64 * 1024,\n        range_header: str | None = None,\n    ) -> AsyncIterator[bytes]:\n        \"\"\"\n        Stream file content as bytes chunks (read_* naming).\n        \"\"\"\n        ...\n\n    async def write_files(self, entries: list[WriteEntry]) -> None:\n        \"\"\"\n        Write content to files based on the provided write entries.\n\n        Args:\n            entries: List of WriteEntry objects specifying files to write and their content\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def write_file(\n        self,\n        path: str,\n        data: str | bytes | IOBase,\n        *,\n        encoding: str = \"utf-8\",\n        mode: int = 755,\n        owner: str | None = None,\n        group: str | None = None,\n    ) -> None:\n        \"\"\"\n        Write content to a single file (convenience method).\n\n        Args:\n            path: Destination file path\n            data: Content to write\n            encoding: Character encoding\n            mode: Unix file permissions\n            owner: Owner username\n            group: Group name\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def create_directories(self, entries: list[WriteEntry]) -> None:\n        \"\"\"\n        Create directories based on the provided entries.\n\n        Args:\n            entries: List of WriteEntry objects specifying directories to create\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def delete_files(self, paths: list[str]) -> None:\n        \"\"\"\n        Delete the specified files.\n\n        Args:\n            paths: List of file paths to delete\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def delete_directories(self, paths: list[str]) -> None:\n        \"\"\"\n        Delete the specified directories.\n\n        Args:\n            paths: List of directory paths to delete\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def move_files(self, entries: list[MoveEntry]) -> None:\n        \"\"\"\n        Move files from source to destination paths.\n\n        Args:\n            entries: List of MoveEntry objects specifying source and destination paths\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def set_permissions(self, entries: list[SetPermissionEntry]) -> None:\n        \"\"\"\n        Set file system permissions for the specified entries.\n\n        Args:\n            entries: List of SetPermissionEntry objects specifying files and their new permissions\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def replace_contents(self, entries: list[ContentReplaceEntry]) -> None:\n        \"\"\"\n        Replace content in files based on search and replace patterns.\n\n        Args:\n            entries: List of ContentReplaceEntry objects specifying replacement operations\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def search(self, entry: SearchEntry) -> list[EntryInfo]:\n        \"\"\"\n        Search for files and directories based on the specified criteria.\n\n        Args:\n            entry: SearchEntry object containing search parameters and criteria\n\n        Returns:\n            List of EntryInfo objects containing metadata for matching files/directories\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def get_file_info(self, paths: list[str]) -> dict[str, EntryInfo]:\n        \"\"\"\n        Retrieve file information for the specified paths.\n\n        Args:\n            paths: List of file/directory paths to get information for\n\n        Returns:\n            Map where keys are file paths and values are EntryInfo objects containing file metadata\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/health.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nHealth service interface.\n\nProtocol for sandbox health monitoring operations.\n\"\"\"\n\nfrom typing import Protocol\n\n\nclass Health(Protocol):\n    \"\"\"\n    Health monitoring service for sandbox environments.\n\n    This service provides health checking and monitoring capabilities\n    for sandbox instances.\n    \"\"\"\n\n    async def ping(self, sandbox_id: str) -> bool:\n        \"\"\"\n        Check if a sandbox is alive and responsive.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Returns:\n            True if the sandbox is healthy, False otherwise\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/metrics.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nMetrics service interface.\n\nProtocol for sandbox metrics and monitoring operations.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import SandboxMetrics\n\n\nclass Metrics(Protocol):\n    \"\"\"\n    Metrics and monitoring service for sandbox environments.\n\n    This service provides resource usage monitoring and performance\n    metrics for sandbox instances.\n    \"\"\"\n\n    async def get_metrics(self, sandbox_id: str) -> SandboxMetrics:\n        \"\"\"\n        Retrieve real-time metrics for a sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Returns:\n            Current sandbox metrics including CPU, memory, and I/O statistics\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/services/sandbox.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSandbox service interface.\n\nProtocol for sandbox lifecycle management operations.\n\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    PagedSandboxInfos,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxRenewResponse,\n    Volume,\n)\n\n\nclass Sandboxes(Protocol):\n    \"\"\"\n    Core sandbox lifecycle management service.\n\n    This service provides a clean abstraction over sandbox creation, management,\n    and termination operations, completely isolating business logic from API implementation details.\n    \"\"\"\n\n    async def create_sandbox(\n        self,\n        spec: SandboxImageSpec,\n        entrypoint: list[str],\n        env: dict[str, str],\n        metadata: dict[str, str],\n        timeout: timedelta | None,\n        resource: dict[str, str],\n        network_policy: NetworkPolicy | None,\n        extensions: dict[str, str],\n        volumes: list[Volume] | None,\n    ) -> SandboxCreateResponse:\n        \"\"\"\n        Create a new sandbox with the specified configuration.\n\n        Args:\n            spec: Container image specification for provisioning the sandbox.\n            entrypoint: Command to run as the sandbox's main process.\n            env: Environment variables injected into the sandbox runtime.\n            metadata: User-defined metadata used for management and filtering.\n            timeout: Sandbox lifetime. Pass None to create a sandbox that requires explicit cleanup.\n            resource: Runtime resource limits (e.g. cpu/memory). Exact semantics are server-defined.\n            network_policy: Optional outbound network policy (egress).\n            extensions: Opaque extension parameters passed through to the server as-is.\n                Prefer namespaced keys (e.g. ``storage.id``).\n            volumes: Optional list of volume mounts for persistent storage.\n\n        Returns:\n            Sandbox create response\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        \"\"\"\n        Retrieve information about an existing sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Returns:\n            Current sandbox information\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def list_sandboxes(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        \"\"\"\n        List sandboxes with optional filtering.\n\n        Args:\n            filter: Optional filter criteria\n\n        Returns:\n            List of sandbox information matching the filter\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def get_sandbox_endpoint(\n        self, sandbox_id: str, port: int, use_server_proxy: bool = False\n    ) -> SandboxEndpoint:\n        \"\"\"\n        Get sandbox endpoint.\n\n        Args:\n            sandbox_id: Sandbox ID\n            port: Endpoint port number\n            use_server_proxy: Whether to use server proxy for endpoint\n\n        Returns:\n            Target sandbox endpoint\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a running sandbox, preserving its state.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a paused sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def renew_sandbox_expiration(\n        self, sandbox_id: str, new_expiration_time: datetime\n    ) -> SandboxRenewResponse:\n        \"\"\"\n        Renew the expiration time of a sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n            new_expiration_time: New expiration timestamp\n\n        Returns:\n            Renew response including the new expiration time.\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n\n    async def kill_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Terminate a sandbox and release all associated resources.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous OpenSandbox SDK entrypoints.\n\"\"\"\n\nfrom opensandbox.sync.manager import SandboxManagerSync\nfrom opensandbox.sync.sandbox import SandboxSync\n\n__all__ = [\"SandboxSync\", \"SandboxManagerSync\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous adapter implementations.\n\"\"\"\n\nfrom opensandbox.sync.adapters.command_adapter import CommandsAdapterSync\nfrom opensandbox.sync.adapters.egress_adapter import EgressAdapterSync\nfrom opensandbox.sync.adapters.factory import AdapterFactorySync\nfrom opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync\nfrom opensandbox.sync.adapters.health_adapter import HealthAdapterSync\nfrom opensandbox.sync.adapters.metrics_adapter import MetricsAdapterSync\nfrom opensandbox.sync.adapters.sandboxes_adapter import SandboxesAdapterSync\n\n__all__ = [\n    \"CommandsAdapterSync\",\n    \"EgressAdapterSync\",\n    \"FilesystemAdapterSync\",\n    \"HealthAdapterSync\",\n    \"MetricsAdapterSync\",\n    \"SandboxesAdapterSync\",\n    \"AdapterFactorySync\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous command adapter implementation (including SSE streaming).\n\"\"\"\n\nimport json\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.execution_converter import (\n    ExecutionConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n)\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.execd import (\n    CommandLogs,\n    CommandStatus,\n    Execution,\n    RunCommandOpts,\n)\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.adapters.converter.execution_event_dispatcher import (\n    ExecutionEventDispatcherSync,\n)\nfrom opensandbox.sync.services.command import CommandsSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass CommandsAdapterSync(CommandsSync):\n    \"\"\"\n    Synchronous implementation of :class:`~opensandbox.sync.services.command.CommandsSync`.\n\n    This adapter wraps openapi-python-client generated clients for simple operations and\n    uses direct ``httpx`` streaming for SSE (Server-Sent Events) command execution output.\n    \"\"\"\n\n    RUN_COMMAND_PATH = \"/command\"\n\n    def __init__(self, connection_config: ConnectionConfigSync, execd_endpoint: SandboxEndpoint) -> None:\n        \"\"\"\n        Initialize the command adapter (sync).\n\n        Args:\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n            execd_endpoint: Endpoint for execd service\n        \"\"\"\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n\n        from opensandbox.api.execd import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        self._client = Client(base_url=base_url, timeout=timeout)\n\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n        # SSE client (read timeout disabled); endpoint headers already in headers\n        sse_headers = {\n            **headers,\n            \"Accept\": \"text/event-stream\",\n            \"Cache-Control\": \"no-cache\",\n        }\n        self._sse_client = httpx.Client(\n            headers=sse_headers,\n            timeout=httpx.Timeout(\n                connect=timeout_seconds,\n                read=None,\n                write=timeout_seconds,\n                pool=None,\n            ),\n            transport=self.connection_config.transport,\n        )\n\n    def _get_execd_url(self, path: str) -> str:\n        \"\"\"Build URL for execd endpoint.\"\"\"\n        return f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    def run(\n        self,\n        command: str,\n        *,\n        opts: RunCommandOpts | None = None,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution:\n        if not command.strip():\n            raise InvalidArgumentException(\"Command cannot be empty\")\n\n        try:\n            opts = opts or RunCommandOpts()\n            json_body = ExecutionConverter.to_api_run_command_json(command, opts)\n            url = self._get_execd_url(self.RUN_COMMAND_PATH)\n\n            execution = Execution(id=None, execution_count=None, result=[], error=None)\n            dispatcher = ExecutionEventDispatcherSync(execution, handlers)\n\n            with self._sse_client.stream(\"POST\", url, json=json_body) as response:\n                if response.status_code != 200:\n                    response.read()\n                    raise SandboxApiException(\n                        message=f\"Failed to run command. Status code: {response.status_code}\",\n                        status_code=response.status_code,\n                        request_id=extract_request_id(response.headers),\n                    )\n\n                for line in response.iter_lines():\n                    if not line or not line.strip():\n                        continue\n                    data = line\n                    if data.startswith(\"data:\"):\n                        data = data[5:].strip()\n                    try:\n                        event_dict = json.loads(data)\n                        event_node = EventNode(**event_dict)\n                        dispatcher.dispatch(event_node)\n                    except Exception as e:\n                        logger.error(\"Failed to parse SSE line: %s\", line, exc_info=e)\n\n            return execution\n\n        except Exception as e:\n            logger.error(\"Failed to run command (length: %s)\", len(command), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupt a running command execution.\n\n        Args:\n            execution_id: Execution id returned by execd for the running command\n        \"\"\"\n        try:\n            from opensandbox.api.execd.api.command import interrupt_command\n\n            response_obj = interrupt_command.sync_detailed(client=self._client, id=execution_id)\n            handle_api_error(response_obj, \"Interrupt command\")\n        except Exception as e:\n            logger.error(\"Failed to interrupt command\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_command_status(self, execution_id: str) -> CommandStatus:\n        \"\"\"Get the current running status for a command.\"\"\"\n        try:\n            from opensandbox.adapters.converter.command_model_converter import (\n                to_command_status,\n            )\n            from opensandbox.adapters.converter.response_handler import require_parsed\n            from opensandbox.api.execd.api.command import get_command_status\n            from opensandbox.api.execd.models import CommandStatusResponse\n\n            response_obj = get_command_status.sync_detailed(\n                client=self._client,\n                id=execution_id,\n            )\n            handle_api_error(response_obj, \"Get command status\")\n            parsed = require_parsed(response_obj, CommandStatusResponse, \"Get command status\")\n            return to_command_status(parsed)\n        except Exception as e:\n            logger.error(\"Failed to get command status\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_background_command_logs(\n        self, execution_id: str, cursor: int | None = None\n    ) -> CommandLogs:\n        \"\"\"Get background command logs (non-streamed).\"\"\"\n        try:\n            from opensandbox.adapters.converter.response_handler import require_parsed\n            from opensandbox.api.execd.api.command import get_background_command_logs\n            from opensandbox.api.execd.types import UNSET\n\n            response_obj = get_background_command_logs.sync_detailed(\n                client=self._client,\n                id=execution_id,\n                cursor=cursor if cursor is not None else UNSET,\n            )\n            handle_api_error(response_obj, \"Get command logs\")\n            content = require_parsed(response_obj, str, \"Get command logs\")\n            cursor_header = response_obj.headers.get(\"EXECD-COMMANDS-TAIL-CURSOR\")\n            next_cursor = None\n            if cursor_header:\n                try:\n                    next_cursor = int(cursor_header)\n                except ValueError:\n                    next_cursor = None\n            return CommandLogs(content=content, cursor=next_cursor)\n        except Exception as e:\n            logger.error(\"Failed to get command logs\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/converter/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom opensandbox.sync.adapters.converter.execution_event_dispatcher import (\n    ExecutionEventDispatcherSync,\n)\n\n__all__ = [\"ExecutionEventDispatcherSync\"]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/converter/execution_event_dispatcher.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous dispatcher for processing execution events.\n\"\"\"\n\nfrom opensandbox.adapters.converter.event_node import EventNode\nfrom opensandbox.models.execd import (\n    Execution,\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionInit,\n    ExecutionResult,\n    OutputMessage,\n)\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\n\n\nclass ExecutionEventDispatcherSync:\n    \"\"\"\n    Dispatches events from the server stream to the Execution object and sync handlers.\n    \"\"\"\n\n    def __init__(self, execution: Execution, handlers: ExecutionHandlersSync | None = None) -> None:\n        self.execution = execution\n        self.handlers = handlers\n\n    def dispatch(self, event_node: EventNode) -> None:\n        event_type = event_node.type\n        timestamp = event_node.timestamp\n\n        if event_type == \"stdout\":\n            self._handle_stdout(event_node, timestamp)\n        elif event_type == \"stderr\":\n            self._handle_stderr(event_node, timestamp)\n        elif event_type == \"result\":\n            self._handle_result(event_node, timestamp)\n        elif event_type == \"error\":\n            self._handle_error(event_node, timestamp)\n        elif event_type == \"execution_complete\":\n            self._handle_execution_complete(event_node, timestamp)\n        elif event_type == \"init\":\n            self._handle_init(event_node, timestamp)\n        elif event_type == \"execution_count\":\n            if event_node.execution_count is not None:\n                self.execution.execution_count = event_node.execution_count\n\n    def _handle_init(self, event_node: EventNode, timestamp: int) -> None:\n        execution_id = event_node.text or \"\"\n        init_event = ExecutionInit(id=execution_id, timestamp=timestamp)\n        self.execution.id = init_event.id\n        if self.handlers and self.handlers.on_init:\n            self.handlers.on_init(init_event)\n\n    def _handle_stdout(self, event_node: EventNode, timestamp: int) -> None:\n        message = OutputMessage(text=event_node.text or \"\", timestamp=timestamp, is_error=False)\n        self.execution.logs.add_stdout(message)\n        if self.handlers and self.handlers.on_stdout:\n            self.handlers.on_stdout(message)\n\n    def _handle_stderr(self, event_node: EventNode, timestamp: int) -> None:\n        message = OutputMessage(text=event_node.text or \"\", timestamp=timestamp, is_error=True)\n        self.execution.logs.add_stderr(message)\n        if self.handlers and self.handlers.on_stderr:\n            self.handlers.on_stderr(message)\n\n    def _handle_result(self, event_node: EventNode, timestamp: int) -> None:\n        result_text = event_node.results.get_text() if event_node.results else \"\"\n        result = ExecutionResult(text=result_text, timestamp=timestamp)\n        self.execution.add_result(result)\n        if self.handlers and self.handlers.on_result:\n            self.handlers.on_result(result)\n\n    def _handle_error(self, event_node: EventNode, timestamp: int) -> None:\n        if not event_node.error:\n            return\n        error_data = event_node.error\n        error = ExecutionError(\n            name=error_data.name or \"\",\n            value=error_data.value or \"\",\n            timestamp=timestamp,\n            traceback=error_data.traceback,\n        )\n        self.execution.error = error\n        if self.handlers and self.handlers.on_error:\n            self.handlers.on_error(error)\n\n    def _handle_execution_complete(self, event_node: EventNode, timestamp: int) -> None:\n        complete = ExecutionComplete(\n            timestamp=timestamp,\n            execution_time_in_millis=event_node.execution_time_in_millis or 0,\n        )\n        if self.handlers and self.handlers.on_execution_complete:\n            self.handlers.on_execution_complete(complete)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous direct egress sidecar adapter implementation.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import ExceptionConverter\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule, SandboxEndpoint\nfrom opensandbox.sync.services.egress import EgressSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass EgressAdapterSync(EgressSync):\n    \"\"\"Blocking direct egress sidecar adapter using the generated egress client.\"\"\"\n\n    def __init__(self, connection_config: ConnectionConfigSync, endpoint: SandboxEndpoint) -> None:\n        self.connection_config = connection_config\n        self.endpoint = endpoint\n        from opensandbox.api.egress import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.endpoint.endpoint}\"\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.endpoint.headers,\n        }\n\n        self._client = Client(\n            base_url=base_url,\n            timeout=timeout,\n        )\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n    def get_policy(self) -> NetworkPolicy:\n        try:\n            from opensandbox.api.egress.api.policy import get_policy\n            from opensandbox.api.egress.models.network_policy import (\n                NetworkPolicy as ApiNetworkPolicy,\n            )\n            from opensandbox.api.egress.models.policy_status_response import (\n                PolicyStatusResponse,\n            )\n            from opensandbox.api.egress.types import Unset\n\n            response_obj = get_policy.sync_detailed(client=self._client)\n            handle_api_error(response_obj, \"Get egress policy\")\n            parsed = require_parsed(response_obj, PolicyStatusResponse, \"Get egress policy\")\n            policy = parsed.policy\n            if isinstance(policy, Unset):\n                raise ValueError(\"Egress policy response missing policy payload\")\n            if not isinstance(policy, ApiNetworkPolicy):\n                raise TypeError(f\"Expected NetworkPolicy, got {type(policy).__name__}\")\n            return NetworkPolicy.model_validate(policy.to_dict())\n        except Exception as e:\n            logger.error(\"Failed to get egress policy from endpoint %s\", self.endpoint.endpoint, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def patch_rules(self, rules: list[NetworkRule]) -> None:\n        try:\n            from opensandbox.api.egress.api.policy import patch_policy\n            from opensandbox.api.egress.models.network_rule import (\n                NetworkRule as ApiNetworkRule,\n            )\n            from opensandbox.api.egress.models.network_rule_action import (\n                NetworkRuleAction,\n            )\n\n            response_obj = patch_policy.sync_detailed(\n                client=self._client,\n                body=[\n                    ApiNetworkRule(\n                        action=NetworkRuleAction(rule.action),\n                        target=rule.target,\n                    )\n                    for rule in rules\n                ],\n            )\n            handle_api_error(response_obj, \"Patch egress rules\")\n        except Exception as e:\n            logger.error(\"Failed to patch egress policy via endpoint %s\", self.endpoint.endpoint, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous service factory for creating sync adapter instances.\n\"\"\"\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.adapters.command_adapter import CommandsAdapterSync\nfrom opensandbox.sync.adapters.egress_adapter import EgressAdapterSync\nfrom opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync\nfrom opensandbox.sync.adapters.health_adapter import HealthAdapterSync\nfrom opensandbox.sync.adapters.metrics_adapter import MetricsAdapterSync\nfrom opensandbox.sync.adapters.sandboxes_adapter import SandboxesAdapterSync\nfrom opensandbox.sync.services import (\n    CommandsSync,\n    EgressSync,\n    FilesystemSync,\n    HealthSync,\n    MetricsSync,\n    SandboxesSync,\n)\n\n\nclass AdapterFactorySync:\n    def __init__(self, connection_config: ConnectionConfigSync) -> None:\n        self.connection_config = connection_config\n\n    def create_sandbox_service(self) -> SandboxesSync:\n        return SandboxesAdapterSync(self.connection_config)\n\n    def create_filesystem_service(self, endpoint: SandboxEndpoint) -> FilesystemSync:\n        return FilesystemAdapterSync(self.connection_config, endpoint)\n\n    def create_command_service(self, endpoint: SandboxEndpoint) -> CommandsSync:\n        return CommandsAdapterSync(self.connection_config, endpoint)\n\n    def create_egress_service(self, endpoint: SandboxEndpoint) -> EgressSync:\n        return EgressAdapterSync(self.connection_config, endpoint)\n\n    def create_health_service(self, endpoint: SandboxEndpoint) -> HealthSync:\n        return HealthAdapterSync(self.connection_config, endpoint)\n\n    def create_metrics_service(self, endpoint: SandboxEndpoint) -> MetricsSync:\n        return MetricsAdapterSync(self.connection_config, endpoint)\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous filesystem service adapter implementation.\n\"\"\"\n\nimport json\nimport logging\nfrom collections.abc import Iterator\nfrom io import IOBase, TextIOBase\nfrom typing import TypedDict\nfrom urllib.parse import quote\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.filesystem_model_converter import (\n    FilesystemModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    extract_request_id,\n    handle_api_error,\n)\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.services.filesystem import FilesystemSync\n\nlogger = logging.getLogger(__name__)\n\nclass _DownloadRequest(TypedDict):\n    url: str\n    params: dict[str, str] | None\n    headers: dict[str, str]\n\n\nclass FilesystemAdapterSync(FilesystemSync):\n    FILESYSTEM_UPLOAD_PATH = \"/files/upload\"\n    FILESYSTEM_DOWNLOAD_PATH = \"/files/download\"\n\n    def __init__(self, connection_config: ConnectionConfigSync, execd_endpoint: SandboxEndpoint) -> None:\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        base_url = self._get_execd_base_url()\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client = Client(base_url=base_url, timeout=timeout)\n        self._client.set_httpx_client(self._httpx_client)\n\n    def _get_execd_base_url(self) -> str:\n        return f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}\"\n\n    def _get_execd_url(self, path: str) -> str:\n        return f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}{path}\"\n\n    def _build_download_request(self, path: str, range_header: str | None = None) -> _DownloadRequest:\n        encoded_path = quote(path, safe=\"/\")\n        url = f\"{self._get_execd_url(self.FILESYSTEM_DOWNLOAD_PATH)}?path={encoded_path}\"\n        headers: dict[str, str] = {}\n        if range_header:\n            headers[\"Range\"] = range_header\n        return {\"url\": url, \"params\": None, \"headers\": headers}\n\n    def read_file(\n        self,\n        path: str,\n        *,\n        encoding: str = \"utf-8\",\n        range_header: str | None = None,\n    ) -> str:\n        content = self.read_bytes(path, range_header=range_header)\n        return content.decode(encoding)\n\n    def read_bytes(self, path: str, *, range_header: str | None = None) -> bytes:\n        logger.debug(\"Reading file as bytes: %s\", path)\n        try:\n            request_data = self._build_download_request(path, range_header)\n            if request_data[\"params\"] is None:\n                response = self._httpx_client.get(\n                    request_data[\"url\"],\n                    headers=request_data[\"headers\"],\n                )\n            else:\n                response = self._httpx_client.get(\n                    request_data[\"url\"],\n                    headers=request_data[\"headers\"],\n                    params=request_data[\"params\"],\n                )\n            response.raise_for_status()\n            return response.content\n        except Exception as e:\n            logger.error(\"Failed to read file %s\", path, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def read_bytes_stream(\n        self, path: str, *, chunk_size: int = 64 * 1024, range_header: str | None = None\n    ) -> Iterator[bytes]:\n        logger.debug(\"Streaming file as bytes: %s (chunk_size=%s)\", path, chunk_size)\n        request_data = self._build_download_request(path, range_header)\n        url = request_data[\"url\"]\n        params = request_data[\"params\"]\n        headers = request_data[\"headers\"]\n\n        if params is None:\n            request = self._httpx_client.build_request(\"GET\", url, headers=headers)\n        else:\n            request = self._httpx_client.build_request(\n                \"GET\",\n                url,\n                headers=headers,\n                params=params,\n            )\n        response = self._httpx_client.send(request, stream=True)\n\n        if response.status_code >= 300:\n            try:\n                response.read()\n            finally:\n                response.close()\n            raise SandboxApiException(\n                f\"Failed to stream file {path}: {response.status_code}\",\n                status_code=response.status_code,\n                request_id=extract_request_id(response.headers),\n            )\n\n        def _iter() -> Iterator[bytes]:\n            try:\n                yield from response.iter_bytes(chunk_size=chunk_size)\n            finally:\n                response.close()\n\n        return _iter()\n\n    def write_files(self, entries: list[WriteEntry]) -> None:\n        if not entries:\n            return\n        logger.debug(\"Writing %s files\", len(entries))\n        try:\n            multipart_parts = []\n            for entry in entries:\n                if not entry.path:\n                    raise InvalidArgumentException(\"File path cannot be null\")\n                if entry.data is None:\n                    raise InvalidArgumentException(\"File data cannot be null\")\n\n                metadata = {\n                    \"path\": entry.path,\n                    \"owner\": entry.owner,\n                    \"group\": entry.group,\n                    \"mode\": entry.mode,\n                }\n                multipart_parts.append((\"metadata\", (\"metadata\", json.dumps(metadata), \"application/json\")))\n\n                content: bytes | str | IOBase\n                content_type: str\n                if isinstance(entry.data, bytes):\n                    content = entry.data\n                    content_type = \"application/octet-stream\"\n                elif isinstance(entry.data, str):\n                    encoding = entry.encoding or \"utf-8\"\n                    content = entry.data\n                    content_type = f\"text/plain; charset={encoding}\"\n                elif isinstance(entry.data, IOBase):\n                    if isinstance(entry.data, TextIOBase):\n                        raise InvalidArgumentException(\n                            \"File stream must be binary (opened with 'rb'). Text streams are not supported.\"\n                        )\n                    content = entry.data\n                    content_type = \"application/octet-stream\"\n                else:\n                    raise InvalidArgumentException(f\"Unsupported file data type: {type(entry.data)}\")\n\n                multipart_parts.append((\"file\", (entry.path, content, content_type)))\n\n            url = self._get_execd_url(self.FILESYSTEM_UPLOAD_PATH)\n            response = self._httpx_client.post(url, files=multipart_parts)\n            response.raise_for_status()\n        except Exception as e:\n            logger.error(\"Failed to write %s files\", len(entries), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def write_file(\n        self,\n        path: str,\n        data: str | bytes | IOBase,\n        *,\n        encoding: str = \"utf-8\",\n        mode: int = 755,\n        owner: str | None = None,\n        group: str | None = None,\n    ) -> None:\n        entry = WriteEntry(path=path, data=data, mode=mode, owner=owner, group=group, encoding=encoding)\n        self.write_files([entry])\n\n    def create_directories(self, entries: list[WriteEntry]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import make_dirs\n\n            response_obj = make_dirs.sync_detailed(\n                client=self._client,\n                body=FilesystemModelConverter.to_api_make_dirs_body(entries),\n            )\n            handle_api_error(response_obj, \"Create directories\")\n        except Exception as e:\n            logger.error(\"Failed to create directories\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def delete_files(self, paths: list[str]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import remove_files\n\n            response_obj = remove_files.sync_detailed(client=self._client, path=paths)\n            handle_api_error(response_obj, \"Delete files\")\n        except Exception as e:\n            logger.error(\"Failed to delete %s files\", len(paths), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def delete_directories(self, paths: list[str]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import remove_dirs\n\n            response_obj = remove_dirs.sync_detailed(client=self._client, path=paths)\n            handle_api_error(response_obj, \"Delete directories\")\n        except Exception as e:\n            logger.error(\"Failed to delete %s directories\", len(paths), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def move_files(self, entries: list[MoveEntry]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import rename_files\n\n            rename_items = FilesystemModelConverter.to_api_rename_file_items(entries)\n            response_obj = rename_files.sync_detailed(client=self._client, body=rename_items)\n            handle_api_error(response_obj, \"Move files\")\n        except Exception as e:\n            logger.error(\"Failed to move files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def set_permissions(self, entries: list[SetPermissionEntry]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import chmod_files\n\n            response_obj = chmod_files.sync_detailed(\n                client=self._client,\n                body=FilesystemModelConverter.to_api_chmod_files_body(entries),\n            )\n            handle_api_error(response_obj, \"Set permissions\")\n        except Exception as e:\n            logger.error(\"Failed to set permissions\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def replace_contents(self, entries: list[ContentReplaceEntry]) -> None:\n        try:\n            from opensandbox.api.execd.api.filesystem import replace_content\n\n            response_obj = replace_content.sync_detailed(\n                client=self._client,\n                body=FilesystemModelConverter.to_api_replace_content_body(entries),\n            )\n            handle_api_error(response_obj, \"Replace contents\")\n        except Exception as e:\n            logger.error(\"Failed to replace contents\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def search(self, entry: SearchEntry) -> list[EntryInfo]:\n        try:\n            from opensandbox.api.execd.api.filesystem import search_files\n            from opensandbox.api.execd.models import FileInfo\n\n            response_obj = search_files.sync_detailed(\n                client=self._client,\n                path=entry.path,\n                pattern=entry.pattern,\n            )\n            handle_api_error(response_obj, \"Search files\")\n            parsed = response_obj.parsed\n            if not parsed:\n                return []\n            if isinstance(parsed, list) and all(isinstance(x, FileInfo) for x in parsed):\n                return FilesystemModelConverter.to_entry_info_list(parsed)\n            raise SandboxApiException(\n                message=\"Search files failed: unexpected response type\",\n                request_id=extract_request_id(getattr(response_obj, \"headers\", None)),\n            )\n        except Exception as e:\n            logger.error(\"Failed to search files\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_file_info(self, paths: list[str]) -> dict[str, EntryInfo]:\n        try:\n            from opensandbox.api.execd.api.filesystem import get_files_info\n\n            response_obj = get_files_info.sync_detailed(client=self._client, path=paths)\n            handle_api_error(response_obj, \"Get file info\")\n            if not response_obj.parsed:\n                return {}\n            return FilesystemModelConverter.to_entry_info_map(response_obj.parsed)\n        except Exception as e:\n            logger.error(\"Failed to get file info for %s paths\", len(paths), exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/health_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous health service adapter implementation.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.response_handler import handle_api_error\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.services.health import HealthSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass HealthAdapterSync(HealthSync):\n    def __init__(self, connection_config: ConnectionConfigSync, execd_endpoint: SandboxEndpoint) -> None:\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}\"\n        timeout = httpx.Timeout(self.connection_config.request_timeout.total_seconds())\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        self._client = Client(base_url=base_url, timeout=timeout)\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n    def ping(self, sandbox_id: str) -> bool:\n        try:\n            from opensandbox.api.execd.api.health import ping\n\n            response_obj = ping.sync_detailed(client=self._client)\n            handle_api_error(response_obj, \"Ping\")\n            return True\n        except Exception as e:\n            logger.debug(\"Health check failed for sandbox %s: %s\", sandbox_id, e)\n            return False\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/metrics_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous metrics service adapter implementation.\n\"\"\"\n\nimport logging\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.metrics_model_converter import (\n    MetricsModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import SandboxEndpoint, SandboxMetrics\nfrom opensandbox.sync.services.metrics import MetricsSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass MetricsAdapterSync(MetricsSync):\n    def __init__(self, connection_config: ConnectionConfigSync, execd_endpoint: SandboxEndpoint) -> None:\n        self.connection_config = connection_config\n        self.execd_endpoint = execd_endpoint\n        from opensandbox.api.execd import Client\n\n        base_url = f\"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}\"\n        timeout = httpx.Timeout(self.connection_config.request_timeout.total_seconds())\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n            **self.execd_endpoint.headers,\n        }\n\n        self._client = Client(base_url=base_url, timeout=timeout)\n        self._httpx_client = httpx.Client(\n            base_url=base_url,\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n    def get_metrics(self, sandbox_id: str) -> SandboxMetrics:\n        try:\n            from opensandbox.api.execd.api.metric import get_metrics\n            from opensandbox.api.execd.models import Metrics\n\n            response_obj = get_metrics.sync_detailed(client=self._client)\n            handle_api_error(response_obj, \"Get metrics\")\n            parsed = require_parsed(response_obj, Metrics, \"Get metrics\")\n            return MetricsModelConverter.to_sandbox_metrics(parsed)\n        except Exception as e:\n            logger.error(\"Failed to get metrics for sandbox %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/adapters/sandboxes_adapter.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous sandbox service adapter implementation.\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta\n\nimport httpx\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.adapters.converter.sandbox_model_converter import (\n    SandboxModelConverter,\n)\nfrom opensandbox.api.lifecycle.types import UNSET\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    PagedSandboxInfos,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxRenewResponse,\n    Volume,\n)\nfrom opensandbox.sync.services.sandbox import SandboxesSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass SandboxesAdapterSync(SandboxesSync):\n    def __init__(self, connection_config: ConnectionConfigSync) -> None:\n        self.connection_config = connection_config\n        from opensandbox.api.lifecycle import AuthenticatedClient\n\n        api_key = self.connection_config.get_api_key()\n        timeout_seconds = self.connection_config.request_timeout.total_seconds()\n        timeout = httpx.Timeout(timeout_seconds)\n\n        headers = {\n            \"User-Agent\": self.connection_config.user_agent,\n            **self.connection_config.headers,\n        }\n        if api_key:\n            headers[\"OPEN-SANDBOX-API-KEY\"] = api_key\n\n        self._client = AuthenticatedClient(\n            base_url=self.connection_config.get_base_url(),\n            token=api_key or \"\",\n            prefix=\"\",\n            auth_header_name=\"OPEN-SANDBOX-API-KEY\",\n            timeout=timeout,\n        )\n\n        self._httpx_client = httpx.Client(\n            base_url=self.connection_config.get_base_url(),\n            headers=headers,\n            timeout=timeout,\n            transport=self.connection_config.transport,\n        )\n        self._client.set_httpx_client(self._httpx_client)\n\n    def _get_client(self):\n        return self._client\n\n    def create_sandbox(\n        self,\n        spec: SandboxImageSpec,\n        entrypoint: list[str],\n        env: dict[str, str],\n        metadata: dict[str, str],\n        timeout: timedelta | None,\n        resource: dict[str, str],\n        network_policy: NetworkPolicy | None,\n        extensions: dict[str, str],\n        volumes: list[Volume] | None,\n    ) -> SandboxCreateResponse:\n        logger.info(\"Creating sandbox with image: %s\", spec.image)\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import post_sandboxes\n            from opensandbox.api.lifecycle.models import (\n                CreateSandboxResponse as ApiCreateSandboxResponse,\n            )\n\n            create_request = SandboxModelConverter.to_api_create_sandbox_request(\n                spec=spec,\n                entrypoint=entrypoint,\n                env=env,\n                metadata=metadata,\n                timeout=timeout,\n                resource=resource,\n                network_policy=network_policy,\n                extensions=extensions,\n                volumes=volumes,\n            )\n            response_obj = post_sandboxes.sync_detailed(client=self._get_client(), body=create_request)\n            handle_api_error(response_obj, \"Create sandbox\")\n\n            parsed = require_parsed(response_obj, ApiCreateSandboxResponse, \"Create sandbox\")\n            return SandboxModelConverter.to_sandbox_create_response(parsed)\n        except Exception as e:\n            logger.error(\"Failed to create sandbox with image: %s\", spec.image, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import get_sandboxes_sandbox_id\n            from opensandbox.api.lifecycle.models import Sandbox as ApiSandbox\n\n            response_obj = get_sandboxes_sandbox_id.sync_detailed(\n                client=self._get_client(),\n                sandbox_id=sandbox_id,\n            )\n            handle_api_error(response_obj, f\"Get sandbox {sandbox_id}\")\n            parsed = require_parsed(response_obj, ApiSandbox, f\"Get sandbox {sandbox_id}\")\n            return SandboxModelConverter.to_sandbox_info(parsed)\n        except Exception as e:\n            logger.error(\"Failed to get sandbox info: %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def list_sandboxes(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        # metadata double-encoding logic kept identical to async adapter\n        metadata = UNSET\n        if filter.metadata:\n            from urllib.parse import quote\n\n            metadata_parts: list[str] = []\n            for key, value in filter.metadata.items():\n                k1 = quote(key, safe=\"\")\n                v1 = quote(value, safe=\"\")\n                k2 = quote(k1, safe=\"\")\n                v2 = quote(v1, safe=\"\")\n                metadata_parts.append(f\"{k2}={v2}\")\n            metadata = \"&\".join(metadata_parts)\n\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import get_sandboxes\n            from opensandbox.api.lifecycle.models import (\n                ListSandboxesResponse as ApiListSandboxesResponse,\n            )\n            from opensandbox.api.lifecycle.types import UNSET as API_UNSET\n\n            response_obj = get_sandboxes.sync_detailed(\n                client=self._get_client(),\n                state=filter.states if filter.states else API_UNSET,\n                metadata=metadata,\n                page=filter.page if filter.page is not None else API_UNSET,\n                page_size=filter.page_size if filter.page_size is not None else API_UNSET,\n            )\n            handle_api_error(response_obj, \"List sandboxes\")\n            parsed = require_parsed(response_obj, ApiListSandboxesResponse, \"List sandboxes\")\n            return SandboxModelConverter.to_paged_sandbox_infos(parsed)\n        except Exception as e:\n            logger.error(\"Failed to list sandboxes\", exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def get_sandbox_endpoint(\n        self, sandbox_id: str, port: int, use_server_proxy: bool = False\n    ) -> SandboxEndpoint:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                get_sandboxes_sandbox_id_endpoints_port,\n            )\n            from opensandbox.api.lifecycle.models import Endpoint as ApiEndpoint\n\n            response_obj = get_sandboxes_sandbox_id_endpoints_port.sync_detailed(\n                sandbox_id=sandbox_id,\n                port=port,\n                client=self._get_client(),\n                use_server_proxy=use_server_proxy,\n            )\n            handle_api_error(response_obj, f\"Get endpoint for sandbox {sandbox_id} port {port}\")\n            parsed = require_parsed(response_obj, ApiEndpoint, \"Get endpoint\")\n            return SandboxModelConverter.to_sandbox_endpoint(parsed)\n        except Exception as e:\n            logger.error(\"Failed to retrieve sandbox endpoint for sandbox %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def pause_sandbox(self, sandbox_id: str) -> None:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_pause,\n            )\n\n            response_obj = post_sandboxes_sandbox_id_pause.sync_detailed(\n                client=self._get_client(), sandbox_id=sandbox_id\n            )\n            handle_api_error(response_obj, f\"Pause sandbox {sandbox_id}\")\n        except Exception as e:\n            logger.error(\"Failed to pause sandbox: %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def resume_sandbox(self, sandbox_id: str) -> None:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_resume,\n            )\n\n            response_obj = post_sandboxes_sandbox_id_resume.sync_detailed(\n                client=self._get_client(), sandbox_id=sandbox_id\n            )\n            handle_api_error(response_obj, f\"Resume sandbox {sandbox_id}\")\n        except Exception as e:\n            logger.error(\"Failed to resume sandbox: %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def renew_sandbox_expiration(\n        self, sandbox_id: str, new_expiration_time: datetime\n    ) -> SandboxRenewResponse:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                post_sandboxes_sandbox_id_renew_expiration,\n            )\n            from opensandbox.api.lifecycle.models.renew_sandbox_expiration_response import (\n                RenewSandboxExpirationResponse,\n            )\n\n            renew_request = SandboxModelConverter.to_api_renew_request(new_expiration_time)\n            response_obj = post_sandboxes_sandbox_id_renew_expiration.sync_detailed(\n                client=self._get_client(),\n                sandbox_id=sandbox_id,\n                body=renew_request,\n            )\n            handle_api_error(response_obj, f\"Renew sandbox {sandbox_id} expiration\")\n            parsed = require_parsed(\n                response_obj,\n                RenewSandboxExpirationResponse,\n                f\"Renew sandbox {sandbox_id} expiration\",\n            )\n            return SandboxModelConverter.to_sandbox_renew_response(parsed)\n        except Exception as e:\n            logger.error(\"Failed to renew sandbox %s expiration\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n\n    def kill_sandbox(self, sandbox_id: str) -> None:\n        try:\n            from opensandbox.api.lifecycle.api.sandboxes import (\n                delete_sandboxes_sandbox_id,\n            )\n\n            response_obj = delete_sandboxes_sandbox_id.sync_detailed(\n                client=self._get_client(), sandbox_id=sandbox_id\n            )\n            handle_api_error(response_obj, f\"Kill sandbox {sandbox_id}\")\n        except Exception as e:\n            logger.error(\"Failed to kill sandbox: %s\", sandbox_id, exc_info=e)\n            raise ExceptionConverter.to_sandbox_exception(e) from e\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/manager.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous SandboxManager implementation.\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.models.sandboxes import (\n    PagedSandboxInfos,\n    SandboxFilter,\n    SandboxInfo,\n    SandboxRenewResponse,\n)\nfrom opensandbox.sync.adapters.factory import AdapterFactorySync\nfrom opensandbox.sync.services.sandbox import SandboxesSync\n\nlogger = logging.getLogger(__name__)\n\n\nclass SandboxManagerSync:\n    \"\"\"\n    Synchronous sandbox management interface for administrative operations.\n\n    This class mirrors the async :class:`opensandbox.manager.SandboxManager`, but all\n    operations are **blocking** and executed in the current thread.\n\n    It is designed for *fleet* / admin workflows (listing, filtering, controlling sandboxes).\n    For interacting with a single sandbox instance (files/commands/metrics), prefer\n    :class:`opensandbox.sync.sandbox.SandboxSync`.\n\n    Usage Example:\n\n    ```python\n    from opensandbox.models.sandboxes import SandboxFilter\n    from opensandbox.sync.manager import SandboxManagerSync\n\n    manager = SandboxManagerSync.create()\n    infos = manager.list_sandbox_infos(SandboxFilter(states=[\"RUNNING\"]))\n    manager.close()\n    ```\n    \"\"\"\n\n    def __init__(\n        self, sandbox_service: SandboxesSync, connection_config: ConnectionConfigSync\n    ) -> None:\n        \"\"\"\n        Internal constructor for SandboxManagerSync.\n\n        Note: Use :meth:`create` instead.\n\n        Args:\n            sandbox_service: Service for sandbox operations\n            connection_config: Connection configuration (shared transport, headers, timeouts)\n        \"\"\"\n        self._sandbox_service = sandbox_service\n        self._connection_config = connection_config\n\n    @property\n    def connection_config(self) -> ConnectionConfigSync:\n        \"\"\"Provides access to the connection configuration (including shared transport).\"\"\"\n        return self._connection_config\n\n    @classmethod\n    def create(cls, connection_config: ConnectionConfigSync | None = None) -> \"SandboxManagerSync\":\n        \"\"\"\n        Create a SandboxManagerSync instance with the provided configuration (blocking).\n\n        Args:\n            connection_config: Connection configuration for the manager.\n                If None, default configuration will be used.\n\n        Returns:\n            Configured sandbox manager instance\n        \"\"\"\n        config = (connection_config or ConnectionConfigSync()).with_transport_if_missing()\n        factory = AdapterFactorySync(config)\n        sandbox_service = factory.create_sandbox_service()\n        return cls(sandbox_service, config)\n\n    def list_sandbox_infos(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        \"\"\"\n        List sandboxes with filtering options.\n\n        Args:\n            filter: Filter criteria for sandbox listing\n\n        Returns:\n            Paged sandbox information matching the filter criteria\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        return self._sandbox_service.list_sandboxes(filter)\n\n    def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        \"\"\"\n        Get information for a single sandbox by its ID.\n\n        Args:\n            sandbox_id: Sandbox ID to retrieve information for\n\n        Returns:\n            SandboxInfo for the specified sandbox\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.debug(\"Getting info for sandbox: %s\", sandbox_id)\n        return self._sandbox_service.get_sandbox_info(sandbox_id)\n\n    def kill_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Terminate a single sandbox.\n\n        Args:\n            sandbox_id: Sandbox ID to terminate\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(\"Terminating sandbox: %s\", sandbox_id)\n        self._sandbox_service.kill_sandbox(sandbox_id)\n        logger.info(\"Successfully terminated sandbox: %s\", sandbox_id)\n\n    def renew_sandbox(self, sandbox_id: str, timeout: timedelta) -> SandboxRenewResponse:\n        \"\"\"\n        Renew expiration time for a single sandbox.\n\n        The new expiration time will be set to the current time plus the provided duration.\n\n        Args:\n            sandbox_id: Sandbox ID to renew\n            timeout: Duration to add to the current time to set the new expiration\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        # Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.\n        new_expiration = datetime.now(timezone.utc) + timeout\n        logger.info(\"Renew expiration for sandbox %s to %s\", sandbox_id, new_expiration)\n        return self._sandbox_service.renew_sandbox_expiration(sandbox_id, new_expiration)\n\n    def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a single sandbox while preserving its state.\n\n        Args:\n            sandbox_id: Sandbox ID to pause\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(\"Pausing sandbox: %s\", sandbox_id)\n        self._sandbox_service.pause_sandbox(sandbox_id)\n\n    def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a previously paused sandbox.\n\n        Args:\n            sandbox_id: Sandbox ID to resume\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        logger.info(\"Resuming sandbox: %s\", sandbox_id)\n        self._sandbox_service.resume_sandbox(sandbox_id)\n\n    def close(self) -> None:\n        \"\"\"\n        Close local resources associated with this sandbox manager.\n\n        This method closes HTTP client resources and other local resources.\n\n        Note: This method logs errors but does not raise exceptions to avoid\n        issues in context manager cleanup.\n        \"\"\"\n        try:\n            self._connection_config.close_transport_if_owned()\n        except Exception as e:\n            logger.warning(\"Error closing resources for sandbox manager: %s\", e, exc_info=True)\n\n    def __enter__(self) -> \"SandboxManagerSync\":\n        \"\"\"Sync context manager entry.\"\"\"\n        return self\n\n    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:\n        \"\"\"Sync context manager exit.\"\"\"\n        self.close()\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/sandbox.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous Sandbox client implementation.\n\"\"\"\n\nimport logging\nimport time\nfrom collections.abc import Callable\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.constants import DEFAULT_EGRESS_PORT, DEFAULT_EXECD_PORT\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxException,\n    SandboxInternalException,\n    SandboxReadyTimeoutException,\n)\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    NetworkRule,\n    SandboxEndpoint,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxMetrics,\n    SandboxRenewResponse,\n    Volume,\n)\nfrom opensandbox.sync.adapters.factory import AdapterFactorySync\nfrom opensandbox.sync.services import (\n    CommandsSync,\n    EgressSync,\n    FilesystemSync,\n    HealthSync,\n    MetricsSync,\n    SandboxesSync,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass SandboxSync:\n    \"\"\"\n    Main synchronous entrypoint for the Open Sandbox SDK.\n\n    This class mirrors the async :class:`opensandbox.sandbox.Sandbox` API, but all\n    operations are **blocking** and executed in the current thread.\n\n    Key Features:\n\n    - **Secure Isolation**: Complete Linux OS access in isolated containers\n    - **File System Operations**: Create, read, update, delete files and directories\n    - **Multi-language Execution**: Support for Python, Java, Bash, and other languages\n    - **Real-time Command Execution**: Streaming output via SSE (Server-Sent Events)\n    - **Resource Management**: CPU, memory, and storage constraints\n    - **Lifecycle Management**: Create, pause, resume, terminate operations\n    - **Health Monitoring**: Readiness polling and status tracking\n\n    Notes:\n\n    - **Blocking**: Do not call these methods directly from an asyncio event loop thread.\n      If you need non-blocking behavior, prefer the async :class:`~opensandbox.sandbox.Sandbox`.\n    - **Resource cleanup**: :meth:`close` closes *local* HTTP resources only. It does **not**\n      terminate the remote sandbox instance. Call :meth:`kill` to stop the remote sandbox.\n\n    Usage Example:\n\n    ```python\n    from datetime import timedelta\n    from opensandbox.models.sandboxes import SandboxImageSpec\n    from opensandbox.models.execd import RunCommandOpts\n    from opensandbox.sync.sandbox import SandboxSync\n\n    # Create a sandbox (blocking)\n    sandbox = SandboxSync.create(\n        \"python:3.11\",\n        resource={\"cpu\": \"1\", \"memory\": \"500Mi\"},\n        timeout=timedelta(minutes=30),\n    )\n\n    # Use the sandbox\n    sandbox.files.write_file(\"script.py\", \"print('Hello World')\")\n    result = sandbox.commands.run(\"python script.py\")\n\n    # Always clean up resources\n    sandbox.kill()   # terminate remote sandbox\n    sandbox.close()  # close local HTTP resources\n\n    # Or use a context manager for automatic close():\n    with SandboxSync.create(\"python:3.11\") as sandbox:\n        # Note on lifecycle:\n        # - Exiting the context manager will call `sandbox.close()` (local HTTP resources only).\n        # - You must still call `sandbox.kill()` to terminate the remote sandbox instance.\n        sandbox.commands.run(\"python -c \\\"print('hi')\\\"\")\n        sandbox.kill()\n    ```\n    \"\"\"\n\n    def __init__(\n        self,\n        sandbox_id: str,\n        sandbox_service: SandboxesSync,\n        filesystem_service: FilesystemSync,\n        command_service: CommandsSync,\n        health_service: HealthSync,\n        metrics_service: MetricsSync,\n        egress_service: EgressSync,\n        connection_config: ConnectionConfigSync,\n        custom_health_check: Callable[[\"SandboxSync\"], bool] | None = None,\n    ) -> None:\n        \"\"\"\n        Internal constructor for SandboxSync. Use :meth:`create` or :meth:`connect` instead.\n        \"\"\"\n        self.id = sandbox_id\n        self._sandbox_service = sandbox_service\n        self._filesystem_service = filesystem_service\n        self._command_service = command_service\n        self._health_service = health_service\n        self._metrics_service = metrics_service\n        self._egress_service = egress_service\n        self._connection_config = connection_config\n        self._custom_health_check = custom_health_check\n\n    @property\n    def files(self) -> FilesystemSync:\n        \"\"\"\n        Provides access to file system operations within the sandbox.\n\n        Allows writing, reading, listing, and deleting files and directories.\n        \"\"\"\n        return self._filesystem_service\n\n    @property\n    def commands(self) -> CommandsSync:\n        \"\"\"\n        Provides access to command execution operations.\n\n        Supports both one-shot command execution and SSE streaming output.\n        \"\"\"\n        return self._command_service\n\n    @property\n    def metrics(self) -> MetricsSync:\n        \"\"\"\n        Provides access to sandbox metrics and monitoring.\n\n        Allows retrieving resource usage statistics (CPU, memory) and other performance metrics.\n        \"\"\"\n        return self._metrics_service\n\n    @property\n    def connection_config(self) -> ConnectionConfigSync:\n        \"\"\"Provides access to the connection configuration (including shared transport).\"\"\"\n        return self._connection_config\n\n    def get_info(self) -> SandboxInfo:\n        \"\"\"\n        Get the current status of this sandbox.\n\n        Returns:\n            Current sandbox status including state and metadata\n\n        Raises:\n            SandboxException: if status cannot be retrieved\n        \"\"\"\n        return self._sandbox_service.get_sandbox_info(self.id)\n\n    def get_endpoint(self, port: int) -> SandboxEndpoint:\n        \"\"\"\n        Get a specific network endpoint for this sandbox.\n\n        Args:\n            port: The port number to get the endpoint for\n\n        Returns:\n            Endpoint information including connection details\n\n        Raises:\n            SandboxException: if endpoint cannot be retrieved\n        \"\"\"\n        return self._sandbox_service.get_sandbox_endpoint(\n            self.id, port, self.connection_config.use_server_proxy\n        )\n\n    def get_metrics(self) -> SandboxMetrics:\n        \"\"\"\n        Get the current resource usage metrics for this sandbox.\n\n        Returns:\n            Current sandbox metrics including CPU, memory, and I/O statistics\n\n        Raises:\n            SandboxException: if metrics cannot be retrieved\n        \"\"\"\n        return self._metrics_service.get_metrics(self.id)\n\n    def renew(self, timeout: timedelta) -> SandboxRenewResponse:\n        \"\"\"\n        Renew the sandbox expiration time to delay automatic termination.\n\n        The new expiration time will be set to the current time plus the provided duration.\n\n        Args:\n            timeout: Duration to add to the current time to set the new expiration\n\n        Returns:\n            Renew response including the new expiration time.\n\n        Raises:\n            SandboxException: if the operation fails\n        \"\"\"\n        # Use timezone-aware UTC datetime to avoid cross-timezone ambiguity.\n        new_expiration = datetime.now(timezone.utc) + timeout\n        logger.info(\n            \"Renewing sandbox %s timeout, estimated expiration: %s\",\n            self.id,\n            new_expiration,\n        )\n        return self._sandbox_service.renew_sandbox_expiration(self.id, new_expiration)\n\n    def get_egress_policy(self) -> NetworkPolicy:\n        \"\"\"\n        Get current egress policy for this sandbox.\n        \"\"\"\n        return self._egress_service.get_policy()\n\n    def patch_egress_rules(self, rules: list[NetworkRule]) -> None:\n        \"\"\"\n        Patch egress rules for this sandbox using sidecar merge semantics.\n\n        Rules in this patch payload take priority over existing rules with the\n        same target. Existing rules for other targets remain unchanged. Within a\n        single patch payload, the first rule for a target wins.\n\n        This operation does not replace the entire policy and does not change\n        the current defaultAction.\n        \"\"\"\n        self._egress_service.patch_rules(rules)\n\n    def pause(self) -> None:\n        \"\"\"\n        Pause the sandbox while preserving its state.\n\n        The sandbox will transition to PAUSED state and can be resumed later.\n        All running processes will be suspended.\n\n        Raises:\n            SandboxException: if pause operation fails\n        \"\"\"\n        logger.info(\"Pausing sandbox: %s\", self.id)\n        self._sandbox_service.pause_sandbox(self.id)\n\n\n    def kill(self) -> None:\n        \"\"\"\n        Send a termination signal to the remote sandbox instance.\n\n        This is an irreversible operation that stops the sandbox immediately.\n\n        Note: This method does NOT close the local resources. Use :meth:`close` or\n        the sync context manager to clean up local resources.\n\n        Raises:\n            SandboxException: if termination fails\n        \"\"\"\n        logger.info(\"Killing sandbox: %s\", self.id)\n        self._sandbox_service.kill_sandbox(self.id)\n\n    def close(self) -> None:\n        \"\"\"\n        Close local resources associated with this sandbox.\n\n        This method closes HTTP client resources and other local resources.\n        It does NOT terminate the remote sandbox instance. Call :meth:`kill` first\n        if you want to terminate the remote sandbox.\n\n        Note: This method logs errors but does not raise exceptions to avoid\n        issues in context manager cleanup.\n        \"\"\"\n        try:\n            self._connection_config.close_transport_if_owned()\n            logger.debug(\"Closed resources for sandbox %s\", self.id)\n        except Exception as e:\n            logger.warning(\"Error closing resources for sandbox %s: %s\", self.id, e, exc_info=True)\n\n    def is_healthy(self) -> bool:\n        \"\"\"\n        Check if the sandbox is healthy and responsive.\n\n        Returns:\n            True if sandbox is healthy, False otherwise\n        \"\"\"\n        if self._custom_health_check:\n            return self._custom_health_check(self)\n        try:\n            return self._health_service.ping(self.id)\n        except Exception:\n            return False\n\n    def check_ready(self, timeout: timedelta, polling_interval: timedelta) -> None:\n        \"\"\"\n        Wait for the sandbox to pass health checks with polling.\n\n        Args:\n            timeout: Maximum time to wait for health check to pass\n            polling_interval: Time between health check attempts\n\n        Raises:\n            SandboxReadyTimeoutException: if health check doesn't pass within timeout\n            SandboxException: if health check fails\n        \"\"\"\n        logger.info(\n            \"Waiting for sandbox %s to pass health check (timeout: %ss)\",\n            self.id,\n            timeout.total_seconds(),\n        )\n\n        deadline = time.time() + timeout.total_seconds()\n        attempt = 0\n        last_exception: Exception | None = None\n\n        while time.time() < deadline:\n            attempt += 1\n            logger.debug(\"Health check attempt #%s for sandbox %s\", attempt, self.id)\n            try:\n                if self.is_healthy():\n                    logger.info(\n                        \"Sandbox %s passed health check after %s attempts\",\n                        self.id,\n                        attempt,\n                    )\n                    return\n                last_exception = None\n            except Exception as e:\n                last_exception = e\n\n            time.sleep(polling_interval.total_seconds())\n\n        error_detail = (\n            f\"Last error: {last_exception}\"\n            if last_exception\n            else \"Health check returned false continuously\"\n        )\n        connection_detail = (\n            f\"ConnectionConfig(domain={self.connection_config.get_domain()}, \"\n            f\"use_server_proxy={self.connection_config.use_server_proxy})\"\n        )\n        if self.connection_config.use_server_proxy:\n            hint = (\n                \"Hint: server proxy mode is enabled. Check server-to-sandbox connectivity \"\n                \"and server API key/auth configuration.\"\n            )\n        else:\n            hint = (\n                \"Hint: direct sandbox endpoint access is enabled. If the SDK cannot directly \"\n                \"reach sandbox network/ports, set ConnectionConfigSync(use_server_proxy=True). \"\n                \"For Docker bridge deployments where server runs in a container, also configure \"\n                \"server [docker].host_ip to a host-reachable address.\"\n            )\n        final_message = (\n            f\"Sandbox health check timed out after {timeout.total_seconds()}s \"\n            f\"({attempt} attempts). {error_detail}. {connection_detail}. {hint}\"\n        )\n        logger.error(final_message)\n        raise SandboxReadyTimeoutException(final_message)\n\n    @classmethod\n    def create(\n        cls,\n        image: SandboxImageSpec | str,\n        *,\n        timeout: timedelta | None = timedelta(minutes=10),\n        ready_timeout: timedelta = timedelta(seconds=30),\n        env: dict[str, str] | None = None,\n        metadata: dict[str, str] | None = None,\n        resource: dict[str, str] | None = None,\n        network_policy: NetworkPolicy | None = None,\n        extensions: dict[str, str] | None = None,\n        entrypoint: list[str] | None = None,\n        volumes: list[Volume] | None = None,\n        connection_config: ConnectionConfigSync | None = None,\n        health_check: Callable[[\"SandboxSync\"], bool] | None = None,\n        health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n        skip_health_check: bool = False,\n    ) -> \"SandboxSync\":\n        \"\"\"\n        Create a new sandbox instance with the specified configuration (blocking).\n\n        Args:\n            image: Container image specification including image reference and optional auth\n            timeout: Maximum sandbox lifetime. Pass None to require explicit cleanup.\n            ready_timeout: Maximum time to wait for sandbox to become ready\n            env: Environment variables for the sandbox\n            metadata: Custom metadata for the sandbox\n            resource: Resource limits (CPU, memory, etc.)\n            network_policy: Optional outbound network policy (egress).\n            extensions: Opaque extension parameters passed through to the server as-is.\n                Prefer namespaced keys (e.g. ``storage.id``).\n            entrypoint: Command to run as entrypoint\n            volumes: Optional list of volumes to mount in the sandbox.\n            connection_config: Connection configuration\n            health_check: Custom sync health check function\n            health_check_polling_interval: Time between health check attempts\n            skip_health_check: If True, do NOT wait for sandbox readiness/health; returned instance may not be ready yet.\n\n        Returns:\n            Fully configured and ready SandboxSync instance\n\n        Raises:\n            SandboxException: if sandbox creation or initialization fails\n        \"\"\"\n        config = (connection_config or ConnectionConfigSync()).with_transport_if_missing()\n        entrypoint = entrypoint or [\"tail\", \"-f\", \"/dev/null\"]\n        env = env or {}\n        metadata = metadata or {}\n        resource = resource or {\"cpu\": \"1\", \"memory\": \"2Gi\"}\n        extensions = extensions or {}\n\n        if isinstance(image, str):\n            image = SandboxImageSpec(image=image)\n\n        timeout_log = \"manual-cleanup\" if timeout is None else f\"{timeout.total_seconds()}s\"\n        logger.info(\n            \"Creating sandbox with image: %s (timeout: %s)\",\n            image.image,\n            timeout_log,\n        )\n        factory = AdapterFactorySync(config)\n        sandbox_id: str | None = None\n        sandbox_service: SandboxesSync | None = None\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            response = sandbox_service.create_sandbox(\n                image,\n                entrypoint,\n                env,\n                metadata,\n                timeout,\n                resource,\n                network_policy,\n                extensions,\n                volumes,\n            )\n            sandbox_id = response.id\n            execd_endpoint = sandbox_service.get_sandbox_endpoint(\n                response.id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = sandbox_service.get_sandbox_endpoint(\n                response.id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=response.id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                sandbox.check_ready(ready_timeout, health_check_polling_interval)\n                logger.info(\"Sandbox %s is ready\", sandbox.id)\n            else:\n                logger.info(\n                    \"Sandbox %s created (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox.id,\n                )\n\n            return sandbox\n        except Exception as e:\n            if sandbox_id and sandbox_service:\n                try:\n                    logger.warning(\n                        \"Sandbox creation failed during initialization. Attempting to terminate zombie sandbox: %s\",\n                        sandbox_id,\n                    )\n                    sandbox_service.kill_sandbox(sandbox_id)\n                except Exception:\n                    pass\n            config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            raise SandboxInternalException(f\"Internal exception when creating sandbox: {e}\") from e\n\n    @classmethod\n    def connect(\n        cls,\n        sandbox_id: str,\n        connection_config: ConnectionConfigSync | None = None,\n        health_check: Callable[[\"SandboxSync\"], bool] | None = None,\n        connect_timeout: timedelta = timedelta(seconds=30),\n        health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n        skip_health_check: bool = False,\n    ) -> \"SandboxSync\":\n        \"\"\"\n        Connect to an existing sandbox instance by ID (blocking).\n\n        Args:\n            sandbox_id: ID of the existing sandbox\n            connection_config: Connection configuration\n            health_check: Custom sync health check function\n            connect_timeout: Max time to wait for sandbox readiness/health after connecting.\n            health_check_polling_interval: Polling interval used while waiting for readiness/health.\n            skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.\n\n        Returns:\n            Connected SandboxSync instance\n\n        Raises:\n            InvalidArgumentException: if required configuration is missing\n            SandboxException: if sandbox connection fails\n        \"\"\"\n        if not sandbox_id:\n            raise InvalidArgumentException(\"Sandbox ID must be specified\")\n        # Accept any string identifier.\n        sandbox_id = str(sandbox_id)\n\n        config = (connection_config or ConnectionConfigSync()).with_transport_if_missing()\n        logger.info(\"Connecting to sandbox: %s\", sandbox_id)\n        factory = AdapterFactorySync(config)\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            execd_endpoint = sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=sandbox_id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                sandbox.check_ready(connect_timeout, health_check_polling_interval)\n            else:\n                logger.info(\n                    \"Connected to sandbox %s (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox_id,\n                )\n\n            logger.info(\"Connected to sandbox %s\", sandbox_id)\n            return sandbox\n        except Exception as e:\n            config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            raise SandboxInternalException(f\"Failed to connect to sandbox: {e}\") from e\n\n    @classmethod\n    def resume(\n            cls,\n            sandbox_id: str,\n            connection_config: ConnectionConfigSync | None = None,\n            health_check: Callable[[\"SandboxSync\"], bool] | None = None,\n            resume_timeout: timedelta = timedelta(seconds=30),\n            health_check_polling_interval: timedelta = timedelta(milliseconds=200),\n            skip_health_check: bool = False,\n    ) -> \"SandboxSync\":\n        \"\"\"\n        Resume a paused sandbox by ID and return a new, usable SandboxSync instance.\n\n        This method performs the server-side resume operation, then re-resolves the execd endpoint\n        (which may change across pause/resume on some backends), rebuilds service adapters, and\n        optionally waits for readiness/health.\n\n        Args:\n            sandbox_id: ID of the paused sandbox to resume.\n            connection_config: Connection configuration (shared transport, headers, timeouts).\n            health_check: Optional custom sync health check function (falls back to ping).\n            resume_timeout: Max time to wait for sandbox readiness/health after resuming.\n            health_check_polling_interval: Polling interval used while waiting for readiness/health.\n            skip_health_check: If True, do NOT wait for readiness/health; returned instance may not be ready yet.\n        \"\"\"\n        if not sandbox_id:\n            raise InvalidArgumentException(\"Sandbox ID must be specified\")\n\n        # Accept any string identifier.\n        sandbox_id = str(sandbox_id)\n\n        config = (connection_config or ConnectionConfigSync()).with_transport_if_missing()\n\n        logger.info(\"Resuming sandbox: %s\", sandbox_id)\n        factory = AdapterFactorySync(config)\n\n        try:\n            sandbox_service = factory.create_sandbox_service()\n            sandbox_service.resume_sandbox(sandbox_id)\n\n            execd_endpoint = sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EXECD_PORT, config.use_server_proxy\n            )\n            egress_endpoint = sandbox_service.get_sandbox_endpoint(\n                sandbox_id, DEFAULT_EGRESS_PORT, config.use_server_proxy\n            )\n\n            sandbox = cls(\n                sandbox_id=sandbox_id,\n                sandbox_service=sandbox_service,\n                filesystem_service=factory.create_filesystem_service(execd_endpoint),\n                command_service=factory.create_command_service(execd_endpoint),\n                health_service=factory.create_health_service(execd_endpoint),\n                metrics_service=factory.create_metrics_service(execd_endpoint),\n                egress_service=factory.create_egress_service(egress_endpoint),\n                connection_config=config,\n                custom_health_check=health_check,\n            )\n\n            if not skip_health_check:\n                sandbox.check_ready(resume_timeout, health_check_polling_interval)\n            else:\n                logger.info(\n                    \"Resumed sandbox %s (skip_health_check=true, sandbox may not be ready yet)\",\n                    sandbox_id,\n                )\n\n            return sandbox\n        except Exception as e:\n            config.close_transport_if_owned()\n            if isinstance(e, SandboxException):\n                raise\n            raise SandboxInternalException(f\"Failed to resume sandbox: {e}\") from e\n\n\n    def __enter__(self) -> \"SandboxSync\":\n        \"\"\"Sync context manager entry.\"\"\"\n        return self\n\n    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:\n        \"\"\"Sync context manager exit.\"\"\"\n        self.close()\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous service interfaces (Protocols) for the sync SDK.\n\"\"\"\n\nfrom opensandbox.sync.services.command import CommandsSync\nfrom opensandbox.sync.services.egress import EgressSync\nfrom opensandbox.sync.services.filesystem import FilesystemSync\nfrom opensandbox.sync.services.health import HealthSync\nfrom opensandbox.sync.services.metrics import MetricsSync\nfrom opensandbox.sync.services.sandbox import SandboxesSync\n\n__all__ = [\n    \"CommandsSync\",\n    \"EgressSync\",\n    \"FilesystemSync\",\n    \"HealthSync\",\n    \"MetricsSync\",\n    \"SandboxesSync\",\n]\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/command.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous command service interface.\n\nDefines the contract for **blocking** command execution operations inside a sandbox.\nThis is the sync counterpart of :mod:`opensandbox.services.command`.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.execd import (\n    CommandLogs,\n    CommandStatus,\n    Execution,\n    RunCommandOpts,\n)\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\n\n\nclass CommandsSync(Protocol):\n    \"\"\"\n    Command execution service for sandbox environments (sync).\n\n    This service provides secure command execution capabilities within sandbox environments,\n    with support for SSE streaming output, timeout handling, and interruption.\n\n    Notes:\n        - All methods are **blocking** and executed in the current thread.\n        - Streaming output is delivered via SSE and accumulated into an ``Execution`` object.\n    \"\"\"\n\n    def run(\n        self,\n        command: str,\n        *,\n        opts: RunCommandOpts | None = None,\n        handlers: ExecutionHandlersSync | None = None,\n    ) -> Execution:\n        \"\"\"\n        Execute a shell command in the sandbox environment.\n\n        The command can be executed in streaming mode (SSE) based on request configuration\n        and optional handlers.\n\n        Args:\n            command: Shell command text to execute\n            opts: Command execution options (e.g. background, working_directory)\n            handlers: Optional handlers for streaming events\n\n        Returns:\n            An ``Execution`` object representing the command execution result/events.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def interrupt(self, execution_id: str) -> None:\n        \"\"\"\n        Interrupt and terminate a running command execution.\n\n        This typically sends a termination signal to the process associated with the given\n        execution ID.\n\n        Args:\n            execution_id: Unique identifier of the execution to interrupt.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def get_command_status(self, execution_id: str) -> CommandStatus:\n        \"\"\"\n        Get the current running status for a command.\n\n        Args:\n            execution_id: Unique identifier of the execution to query\n\n        Returns:\n            CommandStatus describing running state and exit code if available\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def get_background_command_logs(\n        self, execution_id: str, cursor: int | None = None\n    ) -> CommandLogs:\n        \"\"\"\n        Get background command logs (non-streamed).\n\n        Args:\n            execution_id: Unique identifier of the execution to query\n            cursor: Optional line cursor for incremental reads\n\n        Returns:\n            CommandLogs containing raw output and latest cursor\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/egress.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous egress service interface.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\n\n\nclass EgressSync(Protocol):\n    \"\"\"Blocking direct runtime egress policy service.\"\"\"\n\n    def get_policy(self) -> NetworkPolicy:\n        \"\"\"Retrieve the current egress policy from the sidecar.\"\"\"\n        ...\n\n    def patch_rules(self, rules: list[NetworkRule]) -> None:\n        \"\"\"Patch egress rules via the sidecar policy API with merge semantics.\n\n        Incoming rules take priority over existing rules with the same target.\n        Existing rules for other targets remain unchanged. Within one patch\n        payload, the first rule for a target wins. The current defaultAction is\n        preserved.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/filesystem.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous filesystem service interface.\n\nDefines the contract for **blocking** filesystem operations inside a sandbox.\nThis is the sync counterpart of :mod:`opensandbox.services.filesystem`.\n\"\"\"\n\nfrom collections.abc import Iterator\nfrom io import IOBase\nfrom typing import Protocol\n\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    EntryInfo,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\n\n\nclass FilesystemSync(Protocol):\n    \"\"\"\n    Filesystem operations service for sandbox environments (sync).\n\n    This service provides comprehensive file system management capabilities within sandbox\n    environments, including file operations, directory management, and metadata handling.\n\n    Notes:\n        - All methods are **blocking**.\n        - Paths may be absolute or relative to the sandbox working directory (server-defined).\n    \"\"\"\n\n    def read_file(\n        self,\n        path: str,\n        *,\n        encoding: str = \"utf-8\",\n        range_header: str | None = None,\n    ) -> str:\n        \"\"\"\n        Read the content of a file as a string with specified encoding.\n\n        Args:\n            path: The absolute or relative path to the file to read.\n            encoding: Character encoding for the file content (default: UTF-8).\n            range_header: HTTP byte range to read (e.g., \"bytes=0-1023\").\n\n        Returns:\n            The file content as a string.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def read_bytes(self, path: str, *, range_header: str | None = None) -> bytes:\n        \"\"\"\n        Read the content of a file as bytes.\n\n        Args:\n            path: The absolute or relative path to the file to read.\n            range_header: HTTP byte range to read (e.g., \"bytes=0-1023\").\n\n        Returns:\n            The file content as bytes.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def read_bytes_stream(\n        self, path: str, *, chunk_size: int = 64 * 1024, range_header: str | None = None\n    ) -> Iterator[bytes]:\n        \"\"\"\n        Stream file content as bytes chunks (blocking iterator).\n\n        Args:\n            path: File path to read.\n            chunk_size: Chunk size in bytes (default: 64KiB).\n            range_header: Optional HTTP range header.\n\n        Yields:\n            Byte chunks from the file.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def write_files(self, entries: list[WriteEntry]) -> None:\n        \"\"\"\n        Write content to files based on the provided write entries.\n\n        Args:\n            entries: List of WriteEntry objects specifying files to write and their content.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def write_file(\n        self,\n        path: str,\n        data: str | bytes | IOBase,\n        *,\n        encoding: str = \"utf-8\",\n        mode: int = 755,\n        owner: str | None = None,\n        group: str | None = None,\n    ) -> None:\n        \"\"\"\n        Write content to a single file (convenience method).\n\n        Args:\n            path: Destination file path.\n            data: Content to write (str/bytes/file-like).\n            encoding: Character encoding (when data is str).\n            mode: Unix file permissions (implementation-defined).\n            owner: Owner username.\n            group: Group name.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def create_directories(self, entries: list[WriteEntry]) -> None:\n        \"\"\"\n        Create directories based on the provided entries.\n\n        Args:\n            entries: List of WriteEntry objects specifying directories to create.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def delete_files(self, paths: list[str]) -> None:\n        \"\"\"\n        Delete the specified files.\n\n        Args:\n            paths: List of file paths to delete.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def delete_directories(self, paths: list[str]) -> None:\n        \"\"\"\n        Delete the specified directories.\n\n        Args:\n            paths: List of directory paths to delete.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def move_files(self, entries: list[MoveEntry]) -> None:\n        \"\"\"\n        Move files from source to destination paths.\n\n        Args:\n            entries: List of MoveEntry objects specifying source and destination paths.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def set_permissions(self, entries: list[SetPermissionEntry]) -> None:\n        \"\"\"\n        Set file system permissions for the specified entries.\n\n        Args:\n            entries: List of SetPermissionEntry objects specifying files and their new permissions.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def replace_contents(self, entries: list[ContentReplaceEntry]) -> None:\n        \"\"\"\n        Replace content in files based on search and replace patterns.\n\n        Args:\n            entries: List of ContentReplaceEntry objects specifying replacement operations.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def search(self, entry: SearchEntry) -> list[EntryInfo]:\n        \"\"\"\n        Search for files and directories based on the specified criteria.\n\n        Args:\n            entry: SearchEntry object containing search parameters and criteria.\n\n        Returns:\n            List of EntryInfo objects containing metadata for matching files/directories.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def get_file_info(self, paths: list[str]) -> dict[str, EntryInfo]:\n        \"\"\"\n        Retrieve file information for the specified paths.\n\n        Args:\n            paths: List of file/directory paths to get information for.\n\n        Returns:\n            Mapping where keys are paths and values are EntryInfo objects containing metadata.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/health.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous health service interface.\n\nDefines the contract for **blocking** health checks against a sandbox instance.\nThis is the sync counterpart of :mod:`opensandbox.services.health`.\n\"\"\"\n\nfrom typing import Protocol\n\n\nclass HealthSync(Protocol):\n    \"\"\"\n    Health check service for sandbox environments (sync).\n\n    This service provides lightweight checks to verify that a sandbox (and its execd service)\n    is reachable and responsive.\n    \"\"\"\n\n    def ping(self, sandbox_id: str) -> bool:\n        \"\"\"\n        Ping the sandbox execd service to verify liveness.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Returns:\n            True if the sandbox responds successfully, False otherwise.\n\n        Raises:\n            SandboxException: If the underlying request fails in a non-recoverable way.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/metrics.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous metrics service interface.\n\nDefines the contract for **blocking** metrics retrieval from a sandbox instance.\nThis is the sync counterpart of :mod:`opensandbox.services.metrics`.\n\"\"\"\n\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import SandboxMetrics\n\n\nclass MetricsSync(Protocol):\n    \"\"\"\n    Metrics retrieval service for sandbox environments (sync).\n\n    This service provides resource usage statistics (CPU, memory, etc.) for a running sandbox.\n    \"\"\"\n\n    def get_metrics(self, sandbox_id: str) -> SandboxMetrics:\n        \"\"\"\n        Retrieve current sandbox metrics for the given sandbox id.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Returns:\n            Current sandbox metrics including CPU/memory and other usage information.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/src/opensandbox/sync/services/sandbox.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nSynchronous sandbox service interface.\n\nDefines the contract for **blocking** sandbox lifecycle operations.\nThis is the sync counterpart of :mod:`opensandbox.services.sandbox`.\n\"\"\"\n\nfrom datetime import datetime, timedelta\nfrom typing import Protocol\n\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    PagedSandboxInfos,\n    SandboxCreateResponse,\n    SandboxEndpoint,\n    SandboxFilter,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxRenewResponse,\n    Volume,\n)\n\n\nclass SandboxesSync(Protocol):\n    \"\"\"\n    Core sandbox lifecycle management service (sync).\n\n    This service provides a clean abstraction over sandbox creation, management, and termination\n    operations, isolating business logic from API implementation details.\n    \"\"\"\n\n    def create_sandbox(\n        self,\n        spec: SandboxImageSpec,\n        entrypoint: list[str],\n        env: dict[str, str],\n        metadata: dict[str, str],\n        timeout: timedelta | None,\n        resource: dict[str, str],\n        network_policy: NetworkPolicy | None,\n        extensions: dict[str, str],\n        volumes: list[Volume] | None,\n    ) -> SandboxCreateResponse:\n        \"\"\"\n        Create a new sandbox with the specified configuration (blocking).\n\n        Args:\n            spec: Image specification for the sandbox.\n            entrypoint: Command to run as entrypoint.\n            env: Environment variables.\n            metadata: Custom metadata.\n            timeout: Sandbox lifetime / expiration duration. Pass None to require explicit cleanup.\n            resource: Resource limits.\n            network_policy: Optional outbound network policy (egress).\n            extensions: Opaque extension parameters passed through to the server as-is.\n                Prefer namespaced keys (e.g. ``storage.id``).\n            volumes: Optional list of volumes to mount in the sandbox.\n\n        Returns:\n            Sandbox create response.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def get_sandbox_info(self, sandbox_id: str) -> SandboxInfo:\n        \"\"\"\n        Retrieve information about an existing sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Returns:\n            Current sandbox information.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def list_sandboxes(self, filter: SandboxFilter) -> PagedSandboxInfos:\n        \"\"\"\n        List sandboxes with optional filtering.\n\n        Args:\n            filter: Filter criteria.\n\n        Returns:\n            Paged list of sandbox information matching the filter.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def get_sandbox_endpoint(\n        self, sandbox_id: str, port: int, use_server_proxy: bool = False\n    ) -> SandboxEndpoint:\n        \"\"\"\n        Get sandbox endpoint for an exposed port.\n\n        Args:\n            sandbox_id: Sandbox id.\n            port: Endpoint port number.\n            use_server_proxy: Whether to use server proxy for endpoint.\n\n        Returns:\n            Target sandbox endpoint.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a running sandbox, preserving its state.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a paused sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def renew_sandbox_expiration(\n        self, sandbox_id: str, new_expiration_time: datetime\n    ) -> SandboxRenewResponse:\n        \"\"\"\n        Renew the expiration time of a sandbox.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n            new_expiration_time: New expiration timestamp (timezone-aware recommended).\n\n        Returns:\n            Renew response including the new expiration time.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n\n    def kill_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Terminate a sandbox and release all associated resources.\n\n        Args:\n            sandbox_id: Unique identifier of the sandbox.\n\n        Raises:\n            SandboxException: If the operation fails.\n        \"\"\"\n        ...\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_adapters_eager_init.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nimport pytest\n\nfrom opensandbox.adapters.command_adapter import CommandsAdapter\nfrom opensandbox.adapters.filesystem_adapter import FilesystemAdapter\nfrom opensandbox.adapters.health_adapter import HealthAdapter\nfrom opensandbox.adapters.metrics_adapter import MetricsAdapter\nfrom opensandbox.adapters.sandboxes_adapter import SandboxesAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\n\ndef test_sandbox_service_adapter_eager_init() -> None:\n    cfg = ConnectionConfig(domain=\"localhost:8080\", api_key=\"x\")\n    adapter = SandboxesAdapter(cfg)\n    assert adapter is not None\n\n\n@pytest.mark.asyncio\nasync def test_execd_service_adapters_eager_init_and_urls() -> None:\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n\n    cmd = CommandsAdapter(cfg, endpoint)\n    fs = FilesystemAdapter(cfg, endpoint)\n    health = HealthAdapter(cfg, endpoint)\n    metrics = MetricsAdapter(cfg, endpoint)\n\n    assert cmd._get_execd_url(\"/ping\").endswith(\"/ping\")\n    assert fs._get_execd_url(\"/files/download\").endswith(\"/files/download\")\n\n    # Ensure openapi clients are available without lazy init\n    assert await cmd._get_client() is not None\n    assert await fs._get_client() is not None\n    assert await health._get_client() is not None\n    assert await metrics._get_client() is not None\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_command_service_adapter_streaming.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nimport json\n\nimport httpx\nimport pytest\n\nfrom opensandbox.adapters.command_adapter import CommandsAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException, SandboxApiException\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\n\nclass _SseTransport(httpx.AsyncBaseTransport):\n    def __init__(self) -> None:\n        self.last_request: httpx.Request | None = None\n\n    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:\n        self.last_request = request\n        body = request.content.decode(\"utf-8\") if isinstance(request.content, (bytes, bytearray)) else \"\"\n        payload = json.loads(body) if body else {}\n\n        if request.url.path == \"/command\" and payload.get(\"command\") == \"echo hi\":\n            sse = (\n                b'data: {\"type\":\"init\",\"text\":\"exec-1\",\"timestamp\":1}\\n\\n'\n                b'\\n'\n                b'data: {\"type\":\"stdout\",\"text\":\"hi\",\"timestamp\":2}\\n\\n'\n                b\"not-json\\n\\n\"\n                b'data: {\"type\":\"result\",\"results\":{\"text\":\"ok\"},\"timestamp\":3}\\n\\n'\n                b'data: {\"type\":\"execution_complete\",\"timestamp\":4,\"execution_time\":5}\\n\\n'\n            )\n            return httpx.Response(\n                200,\n                headers={\"Content-Type\": \"text/event-stream\"},\n                content=sse,\n                request=request,\n            )\n\n        return httpx.Response(500, content=b\"boom\", request=request)\n\n\n@pytest.mark.asyncio\nasync def test_run_command_streaming_happy_path_updates_execution() -> None:\n    transport = _SseTransport()\n    cfg = ConnectionConfig(protocol=\"http\", transport=transport)\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CommandsAdapter(cfg, endpoint)\n\n    execution = await adapter.run(\"echo hi\")\n    assert execution.id == \"exec-1\"\n    assert execution.logs.stdout[0].text == \"hi\"\n    assert execution.result[0].text == \"ok\"\n\n    assert transport.last_request is not None\n    assert transport.last_request.headers.get(\"accept\") == \"text/event-stream\"\n\n\n@pytest.mark.asyncio\nasync def test_run_command_rejects_blank_command() -> None:\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CommandsAdapter(cfg, endpoint)\n\n    with pytest.raises(InvalidArgumentException):\n        await adapter.run(\"   \")\n\n\n@pytest.mark.asyncio\nasync def test_run_command_non_200_raises_api_exception() -> None:\n    transport = _SseTransport()\n    cfg = ConnectionConfig(protocol=\"http\", transport=transport)\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CommandsAdapter(cfg, endpoint)\n\n    with pytest.raises(SandboxApiException):\n        await adapter.run(\"other\")\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_command_service_sse_client_config.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nfrom opensandbox.adapters.command_adapter import CommandsAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import SandboxEndpoint\n\n\ndef test_sse_client_has_event_stream_headers_and_no_read_timeout() -> None:\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = CommandsAdapter(cfg, endpoint)\n\n    sse_client = adapter._sse_client\n    assert sse_client is not None\n    assert sse_client.headers.get(\"Accept\") == \"text/event-stream\"\n    assert sse_client.timeout.read is None\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_connection_config.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nimport httpx\nimport pytest\n\nfrom opensandbox.config import ConnectionConfig\n\n\ndef test_protocol_validation() -> None:\n    ConnectionConfig(protocol=\"http\")\n    ConnectionConfig(protocol=\"https\")\n\n    with pytest.raises(ValueError):\n        ConnectionConfig(protocol=\"ftp\")  # type: ignore[arg-type]\n\n\ndef test_get_base_url_with_domain_and_protocol() -> None:\n    cfg = ConnectionConfig(domain=\"example.com:1234\", protocol=\"https\")\n    assert cfg.get_base_url() == \"https://example.com:1234/v1\"\n\n\ndef test_get_base_url_domain_can_include_scheme() -> None:\n    cfg = ConnectionConfig(domain=\"https://example.com:9999\", protocol=\"http\")\n    assert cfg.get_base_url() == \"https://example.com:9999/v1\"\n\n\n@pytest.mark.asyncio\nasync def test_close_transport_if_owned_default_transport() -> None:\n    cfg = ConnectionConfig().with_transport_if_missing()\n    # default transport should be closable and owned\n    await cfg.close_transport_if_owned()\n\n\n@pytest.mark.asyncio\nasync def test_close_transport_if_owned_does_not_close_user_transport() -> None:\n    class CustomTransport(httpx.AsyncBaseTransport):\n        def __init__(self) -> None:\n            self.closed = False\n\n        async def handle_async_request(self, request: httpx.Request) -> httpx.Response:  # pragma: no cover\n            raise RuntimeError(\"not used\")\n\n        async def aclose(self) -> None:\n            self.closed = True\n\n    t = CustomTransport()\n    cfg = ConnectionConfig(transport=t)\n    await cfg.close_transport_if_owned()\n    assert t.closed is False\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_connection_config_env_and_timeout.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom datetime import timedelta\n\nimport pytest\n\nfrom opensandbox.config import ConnectionConfig\n\n\ndef test_get_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None:\n    monkeypatch.setenv(\"OPEN_SANDBOX_API_KEY\", \"k1\")\n    cfg = ConnectionConfig(api_key=None)\n    assert cfg.get_api_key() == \"k1\"\n\n\ndef test_get_domain_from_env_and_default(monkeypatch: pytest.MonkeyPatch) -> None:\n    monkeypatch.delenv(\"OPEN_SANDBOX_DOMAIN\", raising=False)\n    cfg = ConnectionConfig(domain=None)\n    assert cfg.get_domain() == \"localhost:8080\"\n\n    monkeypatch.setenv(\"OPEN_SANDBOX_DOMAIN\", \"example.com:8081\")\n    cfg2 = ConnectionConfig(domain=None)\n    assert cfg2.get_domain() == \"example.com:8081\"\n\n\ndef test_timeout_must_be_positive() -> None:\n    ConnectionConfig(request_timeout=timedelta(seconds=1))\n    with pytest.raises(ValueError):\n        ConnectionConfig(request_timeout=timedelta(seconds=0))\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_converters_and_error_handling.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\n\nimport pytest\nfrom httpx import HTTPStatusError, Request, Response\n\nfrom opensandbox.adapters.converter.exception_converter import (\n    ExceptionConverter,\n    parse_sandbox_error,\n)\nfrom opensandbox.adapters.converter.execution_converter import (\n    ExecutionConverter,\n)\nfrom opensandbox.adapters.converter.filesystem_model_converter import (\n    FilesystemModelConverter,\n)\nfrom opensandbox.adapters.converter.metrics_model_converter import (\n    MetricsModelConverter,\n)\nfrom opensandbox.adapters.converter.response_handler import (\n    handle_api_error,\n    require_parsed,\n)\nfrom opensandbox.adapters.converter.sandbox_model_converter import (\n    SandboxModelConverter,\n)\nfrom opensandbox.api.lifecycle.errors import UnexpectedStatus\nfrom opensandbox.exceptions import (\n    InvalidArgumentException,\n    SandboxApiException,\n    SandboxInternalException,\n)\nfrom opensandbox.models.execd import RunCommandOpts\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule, SandboxImageSpec\n\n\ndef test_parse_sandbox_error_from_json_bytes() -> None:\n    err = parse_sandbox_error(b'{\"code\":\"X\",\"message\":\"m\"}')\n    assert err is not None\n    assert err.code == \"X\"\n    assert err.message == \"m\"\n\n\ndef test_parse_sandbox_error_from_plain_text_string() -> None:\n    err = parse_sandbox_error(\"not-json\")\n    assert err is not None\n    assert err.code == \"UNEXPECTED_RESPONSE\"\n    assert err.message == \"not-json\"\n\n\ndef test_parse_sandbox_error_from_invalid_utf8_bytes_fallback_message() -> None:\n    err = parse_sandbox_error(b\"\\xff\\xfe\")\n    assert err is not None\n    assert err.code == \"UNEXPECTED_RESPONSE\"\n    assert err.message is not None\n    assert \"\\ufffd\" in err.message\n\n\ndef test_handle_api_error_raises_with_parsed_message() -> None:\n    class Parsed:\n        message = \"bad request\"\n\n    class Resp:\n        status_code = 400\n        parsed = Parsed()\n        headers = {\"X-Request-ID\": \"req-123\"}\n\n    with pytest.raises(SandboxApiException) as ei:\n        handle_api_error(Resp(), \"Op\")\n    assert \"bad request\" in str(ei.value)\n    assert ei.value.request_id == \"req-123\"\n\n\ndef test_handle_api_error_noop_on_success() -> None:\n    class Resp:\n        status_code = 200\n        parsed = None\n\n    handle_api_error(Resp(), \"Op\")\n\n\ndef test_require_parsed_includes_request_id_on_invalid_payload() -> None:\n    class Resp:\n        status_code = 200\n        parsed = None\n        headers = {\"x-request-id\": \"req-456\"}\n\n    with pytest.raises(SandboxApiException) as ei:\n        require_parsed(Resp(), expected_type=str, operation_name=\"Op\")\n    assert ei.value.request_id == \"req-456\"\n\n\ndef test_exception_converter_maps_common_types() -> None:\n    se = ExceptionConverter.to_sandbox_exception(ValueError(\"x\"))\n    assert isinstance(se, InvalidArgumentException)\n\n    se2 = ExceptionConverter.to_sandbox_exception(OSError(\"x\"))\n    assert isinstance(se2, SandboxInternalException)\n\n\ndef test_exception_converter_maps_generated_unexpected_status_to_api_exception() -> (\n    None\n):\n    err = UnexpectedStatus(400, b'{\"code\":\"X\",\"message\":\"bad\"}')\n\n    converted = ExceptionConverter.to_sandbox_exception(err)\n\n    assert isinstance(converted, SandboxApiException)\n    assert converted.status_code == 400\n    assert converted.error is not None\n    assert converted.error.code == \"X\"\n\n\ndef test_exception_converter_maps_httpx_status_error_to_api_exception() -> None:\n    request = Request(\"GET\", \"https://example.test\")\n    response = Response(\n        502, request=request, content=b'{\"code\":\"UPSTREAM\",\"message\":\"gateway\"}'\n    )\n    err = HTTPStatusError(\"bad gateway\", request=request, response=response)\n\n    converted = ExceptionConverter.to_sandbox_exception(err)\n\n    assert isinstance(converted, SandboxApiException)\n    assert converted.status_code == 502\n    assert converted.error is not None\n    assert converted.error.code == \"UPSTREAM\"\n\n\ndef test_execution_converter_to_api_run_command_request() -> None:\n    from opensandbox.api.execd.types import UNSET\n\n    api = ExecutionConverter.to_api_run_command_request(\"echo hi\", RunCommandOpts())\n    d = api.to_dict()\n    assert d[\"command\"] == \"echo hi\"\n    assert \"cwd\" not in d\n\n    api2 = ExecutionConverter.to_api_run_command_request(\n        \"echo hi\",\n        RunCommandOpts(working_directory=\"/tmp\"),\n    )\n    d2 = api2.to_dict()\n    assert d2[\"cwd\"] == \"/tmp\"\n    # background defaults to False in domain opts; when False we omit it from the API request.\n    assert d2.get(\"background\", UNSET) is UNSET\n\n    from datetime import timedelta\n\n    api3 = ExecutionConverter.to_api_run_command_request(\n        \"sleep 10\",\n        RunCommandOpts(timeout=timedelta(seconds=60)),\n    )\n    d3 = api3.to_dict()\n    assert d3[\"command\"] == \"sleep 10\"\n    assert d3[\"timeout\"] == 60_000\n    # timeout omitted when not set (backward compat)\n    assert (\n        \"timeout\"\n        not in ExecutionConverter.to_api_run_command_request(\n            \"x\", RunCommandOpts()\n        ).to_dict()\n    )\n\n    api4 = ExecutionConverter.to_api_run_command_request(\n        \"id\",\n        RunCommandOpts(\n            uid=1000,\n            gid=1000,\n            envs={\"APP_ENV\": \"test\", \"LOG_LEVEL\": \"debug\"},\n        ),\n    )\n    d4 = api4.to_dict()\n    assert d4[\"uid\"] == 1000\n    assert d4[\"gid\"] == 1000\n    assert d4[\"envs\"] == {\"APP_ENV\": \"test\", \"LOG_LEVEL\": \"debug\"}\n    assert \"cwd\" not in d4\n\n\ndef test_run_command_opts_validates_gid_requires_uid() -> None:\n    with pytest.raises(ValueError, match=\"uid is required when gid is provided\"):\n        RunCommandOpts(gid=1000)\n\n\ndef test_filesystem_and_metrics_converters() -> None:\n    from datetime import datetime, timezone\n\n    from opensandbox.api.execd.models import FileInfo, Metrics\n\n    fi = FileInfo(\n        path=\"/a\",\n        mode=644,\n        owner=\"u\",\n        group=\"g\",\n        size=1,\n        modified_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n        created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n    )\n    entry = FilesystemModelConverter.to_entry_info(fi)\n    assert entry.path == \"/a\"\n\n    api_metrics = Metrics(\n        cpu_count=1.0,\n        cpu_used_pct=2.0,\n        mem_total_mib=3.0,\n        mem_used_mib=4.0,\n        timestamp=5,\n    )\n    m = MetricsModelConverter.to_sandbox_metrics(api_metrics)\n    assert m.cpu_used_percentage == 2.0\n\n\ndef test_sandbox_model_converter_to_api_create_request_and_renew_tz() -> None:\n    from datetime import timezone\n\n    spec = SandboxImageSpec(\"python:3.11\")\n    req = SandboxModelConverter.to_api_create_sandbox_request(\n        spec=spec,\n        entrypoint=[\"/bin/sh\"],\n        env={},\n        metadata={},\n        timeout=timedelta(seconds=3),\n        resource={\"cpu\": \"100m\"},\n        network_policy=NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        ),\n        extensions={},\n        volumes=None,\n    )\n    d = req.to_dict()\n    assert d[\"image\"][\"uri\"] == \"python:3.11\"\n    assert d[\"timeout\"] == 3\n    assert \"env\" not in d\n    assert \"metadata\" not in d\n    assert d[\"networkPolicy\"][\"defaultAction\"] == \"deny\"\n    assert d[\"networkPolicy\"][\"egress\"] == [{\"action\": \"allow\", \"target\": \"pypi.org\"}]\n\n    renew = SandboxModelConverter.to_api_renew_request(datetime(2025, 1, 1))\n    assert renew.expires_at.tzinfo is timezone.utc\n\n\ndef test_sandbox_model_converter_omits_timeout_for_manual_cleanup() -> None:\n    req = SandboxModelConverter.to_api_create_sandbox_request(\n        spec=SandboxImageSpec(\"python:3.11\"),\n        entrypoint=[\"/bin/sh\"],\n        env={},\n        metadata={},\n        timeout=None,\n        resource={\"cpu\": \"100m\"},\n        network_policy=None,\n        extensions={},\n        volumes=None,\n    )\n\n    dumped = req.to_dict()\n    assert \"timeout\" not in dumped\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_filesystem_search_error_handling.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom types import SimpleNamespace\n\nimport pytest\n\nfrom opensandbox.adapters.filesystem_adapter import FilesystemAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.exceptions import SandboxApiException\nfrom opensandbox.models.filesystem import SearchEntry\nfrom opensandbox.models.sandboxes import SandboxEndpoint\nfrom opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync\n\n\n@pytest.mark.asyncio\nasync def test_async_search_unexpected_response_without_headers_still_raises_api_exception(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    async def _fake_asyncio_detailed(**_: object) -> SimpleNamespace:\n        return SimpleNamespace(status_code=200, parsed=object())\n\n    from opensandbox.api.execd.api.filesystem import search_files\n\n    monkeypatch.setattr(search_files, \"asyncio_detailed\", _fake_asyncio_detailed)\n\n    cfg = ConnectionConfig(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = FilesystemAdapter(cfg, endpoint)\n    async def _fake_get_client() -> object:\n        return object()\n\n    monkeypatch.setattr(adapter, \"_get_client\", _fake_get_client)\n\n    with pytest.raises(SandboxApiException) as ei:\n        await adapter.search(SearchEntry(path=\"/tmp\", pattern=\"*.log\"))\n\n    assert \"unexpected response type\" in str(ei.value)\n    assert ei.value.request_id is None\n\n\ndef test_sync_search_unexpected_response_without_headers_still_raises_api_exception(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    def _fake_sync_detailed(**_: object) -> SimpleNamespace:\n        return SimpleNamespace(status_code=200, parsed=object())\n\n    from opensandbox.api.execd.api.filesystem import search_files\n\n    monkeypatch.setattr(search_files, \"sync_detailed\", _fake_sync_detailed)\n\n    cfg = ConnectionConfigSync(protocol=\"http\")\n    endpoint = SandboxEndpoint(endpoint=\"localhost:44772\", port=44772)\n    adapter = FilesystemAdapterSync(cfg, endpoint)\n\n    with pytest.raises(SandboxApiException) as ei:\n        adapter.search(SearchEntry(path=\"/tmp\", pattern=\"*.log\"))\n\n    assert \"unexpected response type\" in str(ei.value)\n    assert ei.value.request_id is None\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_models_stability.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timezone\n\nimport pytest\n\nfrom opensandbox.api.lifecycle.models.create_sandbox_response import (\n    CreateSandboxResponse as ApiCreateSandboxResponse,\n)\nfrom opensandbox.api.lifecycle.models.image_spec import ImageSpec as ApiImageSpec\nfrom opensandbox.api.lifecycle.models.sandbox import Sandbox as ApiSandbox\nfrom opensandbox.api.lifecycle.types import UNSET\nfrom opensandbox.models.execd import (\n    Execution,\n    ExecutionError,\n    ExecutionLogs,\n    ExecutionResult,\n    OutputMessage,\n)\nfrom opensandbox.models.filesystem import MoveEntry, WriteEntry\nfrom opensandbox.models.sandboxes import (\n    OSSFS,\n    PVC,\n    Host,\n    SandboxFilter,\n    SandboxImageAuth,\n    SandboxImageSpec,\n    SandboxInfo,\n    SandboxStatus,\n    Volume,\n)\n\n\ndef test_sandbox_image_spec_supports_positional_image() -> None:\n    spec = SandboxImageSpec(\"python:3.11\")\n    assert spec.image == \"python:3.11\"\n\n\ndef test_sandbox_image_spec_rejects_blank_image() -> None:\n    with pytest.raises(ValueError):\n        SandboxImageSpec(\"   \")\n\n\ndef test_api_image_spec_tolerates_null_auth() -> None:\n    spec = ApiImageSpec.from_dict({\"uri\": \"python:3.11\", \"auth\": None})\n    assert spec.uri == \"python:3.11\"\n    assert spec.auth is UNSET\n\n\ndef test_api_create_sandbox_response_tolerates_null_metadata() -> None:\n    response = ApiCreateSandboxResponse.from_dict(\n        {\n            \"id\": \"sandbox-1\",\n            \"status\": {\"state\": \"Running\", \"lastTransitionAt\": None},\n            \"createdAt\": \"2025-01-01T00:00:00Z\",\n            \"entrypoint\": [\"/bin/sh\"],\n            \"metadata\": None,\n            \"expiresAt\": None,\n        }\n    )\n    assert response.metadata is UNSET\n    assert response.expires_at is None\n    assert response.status.last_transition_at is UNSET\n\n\ndef test_api_sandbox_tolerates_null_metadata() -> None:\n    sandbox = ApiSandbox.from_dict(\n        {\n            \"id\": \"sandbox-1\",\n            \"image\": {\"uri\": \"python:3.11\", \"auth\": None},\n            \"status\": {\"state\": \"Running\", \"lastTransitionAt\": None},\n            \"entrypoint\": [\"/bin/sh\"],\n            \"createdAt\": \"2025-01-01T00:00:00Z\",\n            \"metadata\": None,\n            \"expiresAt\": None,\n        }\n    )\n    assert sandbox.metadata is UNSET\n    assert sandbox.expires_at is None\n    assert sandbox.status.last_transition_at is UNSET\n\n\ndef test_sandbox_image_auth_rejects_blank_username_and_password() -> None:\n    with pytest.raises(ValueError):\n        SandboxImageAuth(username=\" \", password=\"x\")\n    with pytest.raises(ValueError):\n        SandboxImageAuth(username=\"u\", password=\" \")\n\n\ndef test_sandbox_filter_validations() -> None:\n    SandboxFilter(page=0, page_size=1)\n    with pytest.raises(ValueError):\n        SandboxFilter(page=-1)\n    with pytest.raises(ValueError):\n        SandboxFilter(page_size=0)\n\n\ndef test_sandbox_status_and_info_alias_dump_is_stable() -> None:\n    status = SandboxStatus(\n        state=\"RUNNING\", last_transition_at=datetime(2025, 1, 1, tzinfo=timezone.utc)\n    )\n    info = SandboxInfo(\n        id=str(__import__(\"uuid\").uuid4()),\n        status=status,\n        entrypoint=[\"/bin/sh\"],\n        expires_at=datetime(2025, 1, 2, tzinfo=timezone.utc),\n        created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n        image=SandboxImageSpec(\"python:3.11\"),\n        metadata={\"k\": \"v\"},\n    )\n\n    dumped = info.model_dump(by_alias=True, mode=\"json\")\n    assert \"expires_at\" in dumped\n    assert \"created_at\" in dumped\n    assert dumped[\"status\"][\"last_transition_at\"].endswith((\"Z\", \"+00:00\"))\n\n\ndef test_sandbox_info_supports_manual_cleanup_expiration() -> None:\n    info = SandboxInfo(\n        id=str(__import__(\"uuid\").uuid4()),\n        status=SandboxStatus(state=\"RUNNING\"),\n        entrypoint=[\"/bin/sh\"],\n        expires_at=None,\n        created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n        image=SandboxImageSpec(\"python:3.11\"),\n    )\n\n    dumped = info.model_dump(by_alias=True, mode=\"json\")\n    assert dumped[\"expires_at\"] is None\n\n\ndef test_filesystem_models_aliases_and_validation() -> None:\n    m = MoveEntry(source=\"/a\", destination=\"/b\")\n    assert m.src == \"/a\"\n    assert m.dest == \"/b\"\n\n    with pytest.raises(ValueError):\n        WriteEntry(path=\"  \", data=\"x\")\n\n\n# ============================================================================\n# Volume Model Tests\n# ============================================================================\n\n\ndef test_host_backend_requires_absolute_path() -> None:\n    backend = Host(path=\"/data/shared\")\n    assert backend.path == \"/data/shared\"\n\n    with pytest.raises(ValueError, match=\"absolute path\"):\n        Host(path=\"relative/path\")\n\n\ndef test_pvc_backend_rejects_blank_claim_name() -> None:\n    backend = PVC(claimName=\"my-pvc\")\n    assert backend.claim_name == \"my-pvc\"\n\n    with pytest.raises(ValueError, match=\"blank\"):\n        PVC(claimName=\"   \")\n\n\ndef test_ossfs_backend_default_version_is_2_0() -> None:\n    backend = OSSFS(\n        bucket=\"bucket-test-3\",\n        endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n        accessKeyId=\"ak\",\n        accessKeySecret=\"sk\",\n    )\n    assert backend.version == \"2.0\"\n\n\ndef test_volume_with_host_backend() -> None:\n    vol = Volume(\n        name=\"data\",\n        host=Host(path=\"/data/shared\"),\n        mountPath=\"/mnt/data\",\n    )\n    assert vol.name == \"data\"\n    assert vol.host is not None\n    assert vol.host.path == \"/data/shared\"\n    assert vol.pvc is None\n    assert vol.mount_path == \"/mnt/data\"\n    assert vol.read_only is False  # default is read-write\n    assert vol.sub_path is None\n\n\ndef test_volume_with_pvc_backend() -> None:\n    vol = Volume(\n        name=\"models\",\n        pvc=PVC(claimName=\"shared-models\"),\n        mountPath=\"/mnt/models\",\n        readOnly=True,\n        subPath=\"v1\",\n    )\n    assert vol.name == \"models\"\n    assert vol.host is None\n    assert vol.pvc is not None\n    assert vol.pvc.claim_name == \"shared-models\"\n    assert vol.mount_path == \"/mnt/models\"\n    assert vol.read_only is True\n    assert vol.sub_path == \"v1\"\n\n\ndef test_volume_rejects_blank_name() -> None:\n    with pytest.raises(ValueError, match=\"blank\"):\n        Volume(\n            name=\"   \",\n            host=Host(path=\"/data\"),\n            mountPath=\"/mnt\",\n        )\n\n\ndef test_volume_requires_absolute_mount_path() -> None:\n    with pytest.raises(ValueError, match=\"absolute path\"):\n        Volume(\n            name=\"test\",\n            host=Host(path=\"/data\"),\n            mountPath=\"relative/path\",\n        )\n\n\ndef test_volume_serialization_uses_aliases() -> None:\n    vol = Volume(\n        name=\"test\",\n        pvc=PVC(claimName=\"my-pvc\"),\n        mountPath=\"/mnt/test\",\n        readOnly=True,\n        subPath=\"sub\",\n    )\n    dumped = vol.model_dump(by_alias=True, mode=\"json\")\n    assert \"mountPath\" in dumped\n    assert \"readOnly\" in dumped\n    assert \"subPath\" in dumped\n    assert dumped[\"pvc\"][\"claimName\"] == \"my-pvc\"\n    assert dumped[\"readOnly\"] is True\n\n\ndef test_volume_rejects_no_backend() -> None:\n    \"\"\"Volume must have exactly one backend specified.\"\"\"\n    with pytest.raises(ValueError, match=\"none was provided\"):\n        Volume(\n            name=\"test\",\n            mountPath=\"/mnt/test\",\n        )\n\n\ndef test_volume_rejects_multiple_backends() -> None:\n    \"\"\"Volume must have exactly one backend, not multiple.\"\"\"\n    with pytest.raises(ValueError, match=\"multiple were provided\"):\n        Volume(\n            name=\"test\",\n            host=Host(path=\"/data\"),\n            pvc=PVC(claimName=\"my-pvc\"),\n            mountPath=\"/mnt/test\",\n        )\n\n\n# ============================================================================\n# Execution __str__ and .text Tests\n# ============================================================================\n\n\ndef _make_output(text: str, *, is_error: bool = False) -> OutputMessage:\n    return OutputMessage(text=text, timestamp=0, is_error=is_error)\n\n\ndef _make_result(text: str) -> ExecutionResult:\n    return ExecutionResult(text=text, timestamp=0)\n\n\ndef test_execution_str_stdout_only() -> None:\n    ex = Execution(\n        logs=ExecutionLogs(\n            stdout=[_make_output(\"hello\"), _make_output(\"world\")],\n        ),\n    )\n    assert str(ex) == \"hello\\nworld\"\n\n\ndef test_execution_str_with_stderr() -> None:\n    ex = Execution(\n        logs=ExecutionLogs(\n            stdout=[_make_output(\"ok\")],\n            stderr=[_make_output(\"warn\", is_error=True)],\n        ),\n    )\n    assert str(ex) == \"ok\\n[stderr]\\nwarn\"\n\n\ndef test_execution_str_with_error() -> None:\n    ex = Execution(\n        error=ExecutionError(name=\"RuntimeError\", value=\"boom\", timestamp=0),\n    )\n    assert str(ex) == \"[error] RuntimeError: boom\"\n\n\ndef test_execution_str_empty() -> None:\n    ex = Execution()\n    assert str(ex) == \"\"\n\n\ndef test_execution_text_property() -> None:\n    ex = Execution(\n        logs=ExecutionLogs(\n            stdout=[_make_output(\"line1\"), _make_output(\"line2\")],\n            stderr=[_make_output(\"ignored\", is_error=True)],\n        ),\n    )\n    assert ex.text == \"line1\\nline2\"\n\n\ndef test_execution_text_includes_results() -> None:\n    \"\"\"code-interpreter stores return values in result, not stdout.\"\"\"\n    ex = Execution(\n        result=[_make_result(\"4\")],\n    )\n    assert ex.text == \"4\"\n    assert str(ex) == \"4\"\n\n\ndef test_execution_text_combines_stdout_and_results() -> None:\n    ex = Execution(\n        logs=ExecutionLogs(\n            stdout=[_make_output(\"3.11.14\")],\n        ),\n        result=[_make_result(\"4\")],\n    )\n    assert ex.text == \"3.11.14\\n4\"\n\n\ndef test_execution_text_strips_trailing_newlines() -> None:\n    \"\"\"code-interpreter streaming sends chunks with trailing newlines.\"\"\"\n    ex = Execution(\n        logs=ExecutionLogs(\n            stdout=[_make_output(\"1\\n\"), _make_output(\"2\\n\")],\n        ),\n    )\n    assert ex.text == \"1\\n2\"\n    assert str(ex) == \"1\\n2\"\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_business_logic.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom uuid import uuid4\n\nimport pytest\n\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.constants import DEFAULT_EGRESS_PORT, DEFAULT_EXECD_PORT\nfrom opensandbox.exceptions import SandboxReadyTimeoutException\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule, SandboxEndpoint\nfrom opensandbox.sandbox import Sandbox\n\n\nclass _SandboxServiceStub:\n    def __init__(self) -> None:\n        self.renew_calls: list[tuple[object, datetime]] = []\n        self.endpoint_calls: list[tuple[object, int, bool]] = []\n\n    async def renew_sandbox_expiration(self, sandbox_id, expires_at: datetime) -> None:\n        self.renew_calls.append((sandbox_id, expires_at))\n\n    async def get_sandbox_endpoint(self, sandbox_id, port: int, use_server_proxy: bool = False) -> SandboxEndpoint:\n        self.endpoint_calls.append((sandbox_id, port, use_server_proxy))\n        return SandboxEndpoint(endpoint=f\"sbx.internal:{port}\", headers={\"X-Egress\": \"1\"})\n\n\nclass _HealthServiceStub:\n    def __init__(self, *, should_raise: bool = False) -> None:\n        self.should_raise = should_raise\n        self.ping_calls: list[object] = []\n\n    async def ping(self, sandbox_id) -> bool:\n        self.ping_calls.append(sandbox_id)\n        if self.should_raise:\n            raise RuntimeError(\"boom\")\n        return True\n\n\nclass _Noop:\n    pass\n\n\nclass _EgressServiceStub:\n    def __init__(self) -> None:\n        self.patch_calls: list[list[NetworkRule]] = []\n\n    async def get_policy(self) -> NetworkPolicy:\n        return NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        )\n\n    async def patch_rules(self, rules: list[NetworkRule]) -> None:\n        self.patch_calls.append(rules)\n\n\ndef _make_sandbox(\n    *,\n    health_service,\n    sandbox_service,\n    custom_health_check=None,\n    connection_config: ConnectionConfig | None = None,\n) -> Sandbox:\n    return Sandbox(\n        sandbox_id=str(uuid4()),\n        sandbox_service=sandbox_service,\n        filesystem_service=_Noop(),\n        command_service=_Noop(),\n        health_service=health_service,\n        metrics_service=_Noop(),\n        egress_service=_EgressServiceStub(),\n        connection_config=connection_config or ConnectionConfig(),\n        custom_health_check=custom_health_check,\n    )\n\n\n@pytest.mark.asyncio\nasync def test_is_healthy_uses_ping_and_swallows_ping_errors() -> None:\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(should_raise=True),\n        sandbox_service=_SandboxServiceStub(),\n    )\n    assert await sbx.is_healthy() is False\n\n\n@pytest.mark.asyncio\nasync def test_check_ready_succeeds_after_retries_without_real_sleep(monkeypatch: pytest.MonkeyPatch) -> None:\n    # Avoid actual sleeping even if polling_interval > 0.\n    async def _no_sleep(_: float) -> None:\n        return None\n\n    monkeypatch.setattr(\"opensandbox.sandbox.asyncio.sleep\", _no_sleep)\n\n    calls = {\"n\": 0}\n\n    async def _custom_health(_: Sandbox) -> bool:\n        calls[\"n\"] += 1\n        return calls[\"n\"] >= 3\n\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(),\n        sandbox_service=_SandboxServiceStub(),\n        custom_health_check=_custom_health,\n    )\n\n    await sbx.check_ready(timeout=timedelta(seconds=1), polling_interval=timedelta(seconds=0.01))\n    assert calls[\"n\"] == 3\n\n\n@pytest.mark.asyncio\nasync def test_check_ready_timeout_raises() -> None:\n    async def _always_false(_: Sandbox) -> bool:\n        return False\n\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(),\n        sandbox_service=_SandboxServiceStub(),\n        custom_health_check=_always_false,\n    )\n\n    with pytest.raises(SandboxReadyTimeoutException):\n        await sbx.check_ready(timeout=timedelta(seconds=0.01), polling_interval=timedelta(seconds=0))\n\n\n@pytest.mark.asyncio\nasync def test_check_ready_timeout_message_includes_troubleshooting_hints() -> None:\n    async def _always_false(_: Sandbox) -> bool:\n        return False\n\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(),\n        sandbox_service=_SandboxServiceStub(),\n        custom_health_check=_always_false,\n        connection_config=ConnectionConfig(domain=\"10.0.0.1:8080\", use_server_proxy=False),\n    )\n\n    with pytest.raises(SandboxReadyTimeoutException) as exc_info:\n        await sbx.check_ready(timeout=timedelta(seconds=0.01), polling_interval=timedelta(seconds=0))\n\n    message = str(exc_info.value)\n    assert \"ConnectionConfig(domain=10.0.0.1:8080, use_server_proxy=False)\" in message\n    assert \"ConnectionConfig(use_server_proxy=True)\" in message\n\n\n@pytest.mark.asyncio\nasync def test_renew_passes_timezone_aware_utc_datetime() -> None:\n    svc = _SandboxServiceStub()\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(),\n        sandbox_service=svc,\n    )\n\n    before = datetime.now(timezone.utc)\n    await sbx.renew(timedelta(seconds=10))\n    after = datetime.now(timezone.utc)\n\n    assert len(svc.renew_calls) == 1\n    _, expires_at = svc.renew_calls[0]\n    assert expires_at.tzinfo is timezone.utc\n    assert before <= expires_at <= after + timedelta(seconds=12)\n\n\n@pytest.mark.asyncio\nasync def test_get_egress_policy_uses_injected_egress_service() -> None:\n    sbx = _make_sandbox(\n        health_service=_HealthServiceStub(),\n        sandbox_service=_SandboxServiceStub(),\n        connection_config=ConnectionConfig(use_server_proxy=True),\n    )\n\n    policy = await sbx.get_egress_policy()\n\n    assert policy.default_action == \"deny\"\n    assert policy.egress is not None\n    assert policy.egress[0].target == \"pypi.org\"\n\n\n@pytest.mark.asyncio\nasync def test_patch_egress_rules_uses_injected_egress_service(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    svc = _SandboxServiceStub()\n    egress_service = _EgressServiceStub()\n\n    sbx = Sandbox(\n        sandbox_id=str(uuid4()),\n        sandbox_service=svc,\n        filesystem_service=_Noop(),\n        command_service=_Noop(),\n        health_service=_HealthServiceStub(),\n        metrics_service=_Noop(),\n        egress_service=egress_service,\n        connection_config=ConnectionConfig(use_server_proxy=False),\n    )\n    rules = [NetworkRule(action=\"allow\", target=\"www.github.com\")]\n\n    await sbx.patch_egress_rules(rules)\n\n    assert svc.endpoint_calls == []\n    assert egress_service.patch_calls == [rules]\n\n\n@pytest.mark.asyncio\nasync def test_create_resolves_egress_endpoint_and_builds_service(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    egress_service = _EgressServiceStub()\n    factory_calls: list[SandboxEndpoint] = []\n\n    class _CreateResponse:\n        id = \"sbx-created\"\n\n    class _SandboxServiceCreateStub:\n        def __init__(self) -> None:\n            self.endpoint_calls: list[tuple[str, int, bool]] = []\n\n        async def create_sandbox(self, *_args, **_kwargs):\n            return _CreateResponse()\n\n        async def get_sandbox_endpoint(self, sandbox_id, port: int, use_server_proxy: bool = False) -> SandboxEndpoint:\n            self.endpoint_calls.append((sandbox_id, port, use_server_proxy))\n            return SandboxEndpoint(endpoint=f\"sbx.internal:{port}\", headers={\"X-Port\": str(port)})\n\n        async def kill_sandbox(self, _sandbox_id: str) -> None:\n            return None\n\n    class _FactoryStub:\n        def __init__(self, connection_config: ConnectionConfig) -> None:\n            self.connection_config = connection_config\n\n        def create_sandbox_service(self):\n            return sandbox_service\n\n        def create_filesystem_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_command_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_health_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_metrics_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_egress_service(self, endpoint: SandboxEndpoint) -> _EgressServiceStub:\n            factory_calls.append(endpoint)\n            return egress_service\n\n    sandbox_service = _SandboxServiceCreateStub()\n    monkeypatch.setattr(\"opensandbox.sandbox.AdapterFactory\", _FactoryStub)\n\n    async def _healthy(_sbx: Sandbox) -> bool:\n        return True\n\n    await Sandbox.create(\n        \"python:3.11\",\n        connection_config=ConnectionConfig(use_server_proxy=False),\n        health_check=_healthy,\n    )\n\n    assert sandbox_service.endpoint_calls == [\n        (\"sbx-created\", DEFAULT_EXECD_PORT, False),\n        (\"sbx-created\", DEFAULT_EGRESS_PORT, False),\n    ]\n    assert len(factory_calls) == 1\n    assert factory_calls == [\n        SandboxEndpoint(\n            endpoint=f\"sbx.internal:{DEFAULT_EGRESS_PORT}\",\n            headers={\"X-Port\": str(DEFAULT_EGRESS_PORT)},\n        )\n    ]\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_close_and_connect_validation.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nimport httpx\nimport pytest\n\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import InvalidArgumentException\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule\nfrom opensandbox.sandbox import Sandbox\n\n\nclass _NoopService:\n    pass\n\n\nclass _NoopEgressService:\n    async def get_policy(self) -> NetworkPolicy:  # pragma: no cover\n        return NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        )\n\n    async def patch_rules(self, rules: list[NetworkRule]) -> None:  # pragma: no cover\n        return None\n\n\n@pytest.mark.asyncio\nasync def test_sandbox_close_does_not_close_user_transport() -> None:\n    class CustomTransport(httpx.AsyncBaseTransport):\n        def __init__(self) -> None:\n            self.closed = False\n\n        async def handle_async_request(self, request: httpx.Request) -> httpx.Response:  # pragma: no cover\n            raise RuntimeError(\"not used\")\n\n        async def aclose(self) -> None:\n            self.closed = True\n\n    t = CustomTransport()\n    cfg = ConnectionConfig(transport=t)\n\n    sbx = Sandbox(\n        sandbox_id=str(__import__(\"uuid\").uuid4()),\n        sandbox_service=_NoopService(),\n        filesystem_service=_NoopService(),\n        command_service=_NoopService(),\n        health_service=_NoopService(),\n        metrics_service=_NoopService(),\n        egress_service=_NoopEgressService(),\n        connection_config=cfg,\n        custom_health_check=None,\n    )\n\n    await sbx.close()\n    assert t.closed is False\n\n\n@pytest.mark.asyncio\nasync def test_sandbox_connect_requires_id() -> None:\n    with pytest.raises(InvalidArgumentException):\n        await Sandbox.connect(sandbox_id=\"\", connection_config=ConnectionConfig())\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_manager_business_logic.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom uuid import uuid4\n\nimport httpx\nimport pytest\n\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.manager import SandboxManager\n\n\nclass _SandboxServiceStub:\n    def __init__(self) -> None:\n        self.renew_calls: list[tuple[object, datetime]] = []\n        self.pause_calls: list[object] = []\n\n    async def list_sandboxes(self, _filter):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    async def get_sandbox_info(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    async def kill_sandbox(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    async def renew_sandbox_expiration(self, sandbox_id, new_expiration_time: datetime) -> None:\n        self.renew_calls.append((sandbox_id, new_expiration_time))\n\n    async def pause_sandbox(self, sandbox_id) -> None:\n        self.pause_calls.append(sandbox_id)\n\n    async def resume_sandbox(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n\n@pytest.mark.asyncio\nasync def test_manager_renew_uses_utc_datetime() -> None:\n    svc = _SandboxServiceStub()\n    mgr = SandboxManager(svc, ConnectionConfig())\n\n    sid = str(uuid4())\n    await mgr.renew_sandbox(sid, timedelta(seconds=5))\n\n    assert len(svc.renew_calls) == 1\n    _, dt = svc.renew_calls[0]\n    assert dt.tzinfo is timezone.utc\n\n\n@pytest.mark.asyncio\nasync def test_manager_close_does_not_close_user_transport() -> None:\n    class CustomTransport(httpx.AsyncBaseTransport):\n        def __init__(self) -> None:\n            self.closed = False\n\n        async def handle_async_request(self, request: httpx.Request) -> httpx.Response:  # pragma: no cover\n            raise RuntimeError(\"not used\")\n\n        async def aclose(self) -> None:\n            self.closed = True\n\n    t = CustomTransport()\n    cfg = ConnectionConfig(transport=t)\n\n    mgr = SandboxManager(_SandboxServiceStub(), cfg)\n    await mgr.close()\n    assert t.closed is False\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_manager_sync_business_logic.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom uuid import uuid4\n\nimport httpx\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.sync.manager import SandboxManagerSync\n\n\nclass _SandboxServiceStub:\n    def __init__(self) -> None:\n        self.renew_calls: list[tuple[object, datetime]] = []\n\n    def list_sandboxes(self, _filter):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    def get_sandbox_info(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    def kill_sandbox(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    def renew_sandbox_expiration(self, sandbox_id, new_expiration_time: datetime) -> None:\n        self.renew_calls.append((sandbox_id, new_expiration_time))\n\n    def pause_sandbox(self, _sandbox_id) -> None:  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n    def resume_sandbox(self, _sandbox_id):  # pragma: no cover\n        raise RuntimeError(\"not used\")\n\n\ndef test_sync_manager_renew_uses_utc_datetime() -> None:\n    svc = _SandboxServiceStub()\n    mgr = SandboxManagerSync(svc, ConnectionConfigSync())\n\n    sid = str(uuid4())\n    mgr.renew_sandbox(sid, timedelta(seconds=5))\n\n    assert len(svc.renew_calls) == 1\n    _, dt = svc.renew_calls[0]\n    assert dt.tzinfo is timezone.utc\n\n\ndef test_sync_manager_close_does_not_close_user_transport() -> None:\n    class CustomTransport(httpx.BaseTransport):\n        def __init__(self) -> None:\n            self.closed = False\n\n        def handle_request(self, request: httpx.Request) -> httpx.Response:  # pragma: no cover\n            raise RuntimeError(\"not used\")\n\n        def close(self) -> None:\n            self.closed = True\n\n    t = CustomTransport()\n    cfg = ConnectionConfigSync(transport=t)\n\n    mgr = SandboxManagerSync(_SandboxServiceStub(), cfg)\n    mgr.close()\n    assert t.closed is False\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_service_adapter_lifecycle.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom uuid import uuid4\n\nimport pytest\n\nfrom opensandbox.adapters.sandboxes_adapter import SandboxesAdapter\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import SandboxApiException\nfrom opensandbox.models.sandboxes import (\n    NetworkPolicy,\n    NetworkRule,\n    SandboxFilter,\n    SandboxImageSpec,\n)\n\n\nclass _Resp:\n    def __init__(self, *, status_code: int, parsed) -> None:\n        self.status_code = status_code\n        self.parsed = parsed\n\n\ndef _api_create_sandbox_response(sandbox_id: str):\n    from opensandbox.api.lifecycle.models.create_sandbox_response import (\n        CreateSandboxResponse,\n    )\n    from opensandbox.api.lifecycle.models.sandbox_status import SandboxStatus\n\n    return CreateSandboxResponse(\n        id=sandbox_id,\n        status=SandboxStatus(state=\"Running\"),\n        expires_at=datetime(2025, 1, 2, tzinfo=timezone.utc),\n        created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n        entrypoint=[\"/bin/sh\"],\n    )\n\n\ndef _api_list_sandboxes_response():\n    from opensandbox.api.lifecycle.models.image_spec import ImageSpec\n    from opensandbox.api.lifecycle.models.list_sandboxes_response import (\n        ListSandboxesResponse,\n    )\n    from opensandbox.api.lifecycle.models.pagination_info import PaginationInfo\n    from opensandbox.api.lifecycle.models.sandbox import Sandbox\n    from opensandbox.api.lifecycle.models.sandbox_status import SandboxStatus\n\n    sbx = Sandbox(\n        id=str(uuid4()),\n        image=ImageSpec(uri=\"python:3.11\"),\n        status=SandboxStatus(state=\"Running\"),\n        entrypoint=[\"/bin/sh\"],\n        expires_at=datetime(2025, 1, 2, tzinfo=timezone.utc),\n        created_at=datetime(2025, 1, 1, tzinfo=timezone.utc),\n    )\n    return ListSandboxesResponse(\n        items=[sbx],\n        pagination=PaginationInfo(\n            page=0,\n            page_size=10,\n            total_items=1,\n            total_pages=1,\n            has_next_page=False,\n        ),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_create_sandbox_success(monkeypatch: pytest.MonkeyPatch) -> None:\n    called = {}\n\n    async def _fake_asyncio_detailed(*, client, body):\n        called[\"body\"] = body\n        return _Resp(status_code=200, parsed=_api_create_sandbox_response(str(uuid4())))\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    cfg = ConnectionConfig(domain=\"example.com:8080\", api_key=\"k\")\n    adapter = SandboxesAdapter(cfg)\n\n    out = await adapter.create_sandbox(\n        spec=SandboxImageSpec(\"python:3.11\"),\n        entrypoint=[\"/bin/sh\"],\n        env={},\n        metadata={},\n        timeout=timedelta(seconds=3),\n        resource={\"cpu\": \"100m\"},\n        network_policy=NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        ),\n        extensions={\"storage.id\": \"abc123\", \"debug\": \"true\"},\n        volumes=None,\n    )\n    assert isinstance(out.id, str)\n    assert \"image\" in called[\"body\"].to_dict()\n    assert called[\"body\"].to_dict()[\"extensions\"] == {\"storage.id\": \"abc123\", \"debug\": \"true\"}\n    network_policy = called[\"body\"].to_dict()[\"networkPolicy\"]\n    assert network_policy[\"defaultAction\"] == \"deny\"\n    assert network_policy[\"egress\"] == [{\"action\": \"allow\", \"target\": \"pypi.org\"}]\n\n\n@pytest.mark.asyncio\nasync def test_create_sandbox_manual_cleanup_omits_timeout(monkeypatch: pytest.MonkeyPatch) -> None:\n    called = {}\n\n    async def _fake_asyncio_detailed(*, client, body):\n        called[\"body\"] = body\n        return _Resp(status_code=200, parsed=_api_create_sandbox_response(str(uuid4())))\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = SandboxesAdapter(ConnectionConfig(domain=\"example.com:8080\", api_key=\"k\"))\n    await adapter.create_sandbox(\n        spec=SandboxImageSpec(\"python:3.11\"),\n        entrypoint=[\"/bin/sh\"],\n        env={},\n        metadata={},\n        timeout=None,\n        resource={\"cpu\": \"100m\"},\n        network_policy=None,\n        extensions={},\n        volumes=None,\n    )\n\n    assert \"timeout\" not in called[\"body\"].to_dict()\n\n\n@pytest.mark.asyncio\nasync def test_create_sandbox_empty_response_raises(monkeypatch: pytest.MonkeyPatch) -> None:\n    async def _fake_asyncio_detailed(*, client, body):\n        return _Resp(status_code=200, parsed=None)\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = SandboxesAdapter(ConnectionConfig())\n    with pytest.raises(SandboxApiException):\n        await adapter.create_sandbox(\n            spec=SandboxImageSpec(\"python:3.11\"),\n            entrypoint=[\"/bin/sh\"],\n            env={},\n            metadata={},\n            timeout=timedelta(seconds=1),\n            resource={\"cpu\": \"100m\"},\n            extensions={\"debug\": \"true\"},\n            network_policy=NetworkPolicy(),\n            volumes=None,\n        )\n\n\n@pytest.mark.asyncio\nasync def test_list_sandboxes_metadata_double_encoded(monkeypatch: pytest.MonkeyPatch) -> None:\n    from opensandbox.api.lifecycle.types import UNSET as API_UNSET\n\n    captured = {}\n\n    async def _fake_asyncio_detailed(*, client, state, metadata, page, page_size):\n        captured.update(\n            {\"state\": state, \"metadata\": metadata, \"page\": page, \"page_size\": page_size}\n        )\n        return _Resp(status_code=200, parsed=_api_list_sandboxes_response())\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.get_sandboxes.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = SandboxesAdapter(ConnectionConfig())\n    f = SandboxFilter(metadata={\"k k\": \"v/v\"})\n    await adapter.list_sandboxes(f)\n\n    assert captured[\"metadata\"] == \"k k=v/v\"\n    assert captured[\"state\"] is API_UNSET\n\n\n@pytest.mark.asyncio\nasync def test_pause_resume_kill_call_openapi(monkeypatch: pytest.MonkeyPatch) -> None:\n    sbx_id = str(uuid4())\n    calls: list[tuple[str, str]] = []\n\n    async def _ok_pause(*, client, sandbox_id):\n        calls.append((\"pause\", sandbox_id))\n        return _Resp(status_code=204, parsed=None)\n\n    async def _ok_resume(*, client, sandbox_id):\n        calls.append((\"resume\", sandbox_id))\n        return _Resp(status_code=204, parsed=None)\n\n    async def _ok_kill(*, client, sandbox_id):\n        calls.append((\"kill\", sandbox_id))\n        return _Resp(status_code=204, parsed=None)\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes_sandbox_id_pause.asyncio_detailed\",\n        _ok_pause,\n    )\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes_sandbox_id_resume.asyncio_detailed\",\n        _ok_resume,\n    )\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.delete_sandboxes_sandbox_id.asyncio_detailed\",\n        _ok_kill,\n    )\n\n    adapter = SandboxesAdapter(ConnectionConfig())\n    await adapter.pause_sandbox(sbx_id)\n    await adapter.resume_sandbox(sbx_id)\n    await adapter.kill_sandbox(sbx_id)\n\n    assert calls == [(\"pause\", sbx_id), (\"resume\", sbx_id), (\"kill\", sbx_id)]\n\n\n@pytest.mark.asyncio\nasync def test_renew_sandbox_expiration_sends_timezone_aware(monkeypatch: pytest.MonkeyPatch) -> None:\n    captured = {}\n\n    async def _fake_asyncio_detailed(*, client, sandbox_id, body):\n        from opensandbox.api.lifecycle.models.renew_sandbox_expiration_response import (\n            RenewSandboxExpirationResponse,\n        )\n\n        captured[\"expires_at\"] = body.expires_at\n        return _Resp(\n            status_code=200,\n            parsed=RenewSandboxExpirationResponse(expires_at=body.expires_at),\n        )\n\n    monkeypatch.setattr(\n        \"opensandbox.api.lifecycle.api.sandboxes.post_sandboxes_sandbox_id_renew_expiration.asyncio_detailed\",\n        _fake_asyncio_detailed,\n    )\n\n    adapter = SandboxesAdapter(ConnectionConfig())\n    await adapter.renew_sandbox_expiration(str(uuid4()), datetime(2025, 1, 1))  # naive\n\n    assert captured[\"expires_at\"].tzinfo is timezone.utc\n"
  },
  {
    "path": "sdks/sandbox/python/tests/test_sandbox_sync_business_logic.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nfrom __future__ import annotations\n\nfrom datetime import timedelta\nfrom uuid import uuid4\n\nimport pytest\n\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.constants import DEFAULT_EGRESS_PORT, DEFAULT_EXECD_PORT\nfrom opensandbox.exceptions import SandboxReadyTimeoutException\nfrom opensandbox.models.sandboxes import NetworkPolicy, NetworkRule, SandboxEndpoint\nfrom opensandbox.sync.sandbox import SandboxSync\n\n\nclass _Noop:\n    pass\n\n\nclass _SandboxServiceStub:\n    def __init__(self) -> None:\n        self.endpoint_calls: list[tuple[object, int, bool]] = []\n\n    def get_sandbox_endpoint(self, sandbox_id, port: int, use_server_proxy: bool = False) -> SandboxEndpoint:\n        self.endpoint_calls.append((sandbox_id, port, use_server_proxy))\n        return SandboxEndpoint(endpoint=f\"sync-egress:{port}\", headers={\"X-Egress\": \"1\"})\n\n\nclass _EgressServiceStub:\n    def __init__(self) -> None:\n        self.patch_calls: list[list[NetworkRule]] = []\n\n    def get_policy(self) -> NetworkPolicy:\n        return NetworkPolicy(\n            defaultAction=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        )\n\n    def patch_rules(self, rules: list[NetworkRule]) -> None:\n        self.patch_calls.append(rules)\n\n\ndef test_sync_check_ready_timeout_message_includes_troubleshooting_hints() -> None:\n    def _always_false(_: SandboxSync) -> bool:\n        return False\n\n    sbx = SandboxSync(\n        sandbox_id=str(uuid4()),\n        sandbox_service=_Noop(),\n        filesystem_service=_Noop(),\n        command_service=_Noop(),\n        health_service=_Noop(),\n        metrics_service=_Noop(),\n        egress_service=_EgressServiceStub(),\n        connection_config=ConnectionConfigSync(\n            domain=\"10.0.0.2:8080\",\n            use_server_proxy=False,\n        ),\n        custom_health_check=_always_false,\n    )\n\n    with pytest.raises(SandboxReadyTimeoutException) as exc_info:\n        sbx.check_ready(timeout=timedelta(seconds=0.01), polling_interval=timedelta(seconds=0))\n\n    message = str(exc_info.value)\n    assert \"ConnectionConfig(domain=10.0.0.2:8080, use_server_proxy=False)\" in message\n    assert \"ConnectionConfigSync(use_server_proxy=True)\" in message\n\n\ndef test_sync_get_egress_policy_uses_injected_egress_service() -> None:\n    sbx = SandboxSync(\n        sandbox_id=str(uuid4()),\n        sandbox_service=_SandboxServiceStub(),\n        filesystem_service=_Noop(),\n        command_service=_Noop(),\n        health_service=_Noop(),\n        metrics_service=_Noop(),\n        egress_service=_EgressServiceStub(),\n        connection_config=ConnectionConfigSync(use_server_proxy=True),\n    )\n\n    policy = sbx.get_egress_policy()\n\n    assert policy.default_action == \"deny\"\n    assert policy.egress is not None\n    assert policy.egress[0].target == \"pypi.org\"\n\n\ndef test_sync_patch_egress_rules_uses_injected_egress_service() -> None:\n    svc = _SandboxServiceStub()\n    egress_service = _EgressServiceStub()\n\n    sbx = SandboxSync(\n        sandbox_id=str(uuid4()),\n        sandbox_service=svc,\n        filesystem_service=_Noop(),\n        command_service=_Noop(),\n        health_service=_Noop(),\n        metrics_service=_Noop(),\n        egress_service=egress_service,\n        connection_config=ConnectionConfigSync(use_server_proxy=False),\n    )\n    rules = [NetworkRule(action=\"allow\", target=\"www.github.com\")]\n\n    sbx.patch_egress_rules(rules)\n\n    assert svc.endpoint_calls == []\n    assert egress_service.patch_calls == [rules]\n\n\ndef test_sync_create_resolves_egress_endpoint_and_builds_service(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    egress_service = _EgressServiceStub()\n    factory_calls: list[SandboxEndpoint] = []\n\n    class _CreateResponse:\n        id = \"sync-created\"\n\n    class _SandboxServiceCreateStub:\n        def __init__(self) -> None:\n            self.endpoint_calls: list[tuple[str, int, bool]] = []\n\n        def create_sandbox(self, *_args, **_kwargs):\n            return _CreateResponse()\n\n        def get_sandbox_endpoint(self, sandbox_id, port: int, use_server_proxy: bool = False) -> SandboxEndpoint:\n            self.endpoint_calls.append((sandbox_id, port, use_server_proxy))\n            return SandboxEndpoint(endpoint=f\"sync-egress:{port}\", headers={\"X-Port\": str(port)})\n\n        def kill_sandbox(self, _sandbox_id: str) -> None:\n            return None\n\n    class _FactoryStub:\n        def __init__(self, connection_config: ConnectionConfigSync) -> None:\n            self.connection_config = connection_config\n\n        def create_sandbox_service(self):\n            return sandbox_service\n\n        def create_filesystem_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_command_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_health_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_metrics_service(self, endpoint: SandboxEndpoint):\n            return _Noop()\n\n        def create_egress_service(self, endpoint: SandboxEndpoint) -> _EgressServiceStub:\n            factory_calls.append(endpoint)\n            return egress_service\n\n    sandbox_service = _SandboxServiceCreateStub()\n    monkeypatch.setattr(\"opensandbox.sync.sandbox.AdapterFactorySync\", _FactoryStub)\n\n    SandboxSync.create(\n        \"python:3.11\",\n        connection_config=ConnectionConfigSync(use_server_proxy=False),\n        health_check=lambda _sbx: True,\n    )\n\n    assert sandbox_service.endpoint_calls == [\n        (\"sync-created\", DEFAULT_EXECD_PORT, False),\n        (\"sync-created\", DEFAULT_EGRESS_PORT, False),\n    ]\n    assert len(factory_calls) == 1\n    assert factory_calls == [\n        SandboxEndpoint(\n            endpoint=f\"sync-egress:{DEFAULT_EGRESS_PORT}\",\n            headers={\"X-Port\": str(DEFAULT_EGRESS_PORT)},\n        )\n    ]\n"
  },
  {
    "path": "sdks/tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"lib\": [\"ES2022\", \"DOM\"],\n\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"removeComments\": false,\n\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"strictNullChecks\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noImplicitReturns\": true,\n    \"noFallthroughCasesInSwitch\": true,\n\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "server/.python-version",
    "content": "3.10\n"
  },
  {
    "path": "server/DEVELOPMENT.md",
    "content": "# Development Guide\n\nThis guide provides comprehensive information for developers working on OpenSandbox Server, including environment setup, architecture deep-dive, testing strategies, and contribution workflows.\n\n## 📋 Table of Contents\n\n- [Development Environment Setup](#development-environment-setup)\n- [Project Structure](#project-structure)\n- [Architecture Deep Dive](#architecture-deep-dive)\n- [Development Workflow](#development-workflow)\n- [Testing Guide](#testing-guide)\n- [Working with Docker Runtime](#working-with-docker-runtime)\n- [Working with Kubernetes Runtime](#working-with-kubernetes-runtime)\n- [Code Style and Standards](#code-style-and-standards)\n- [Debugging](#debugging)\n- [Performance Optimization](#performance-optimization)\n- [Contributing](#contributing)\n\n## Development Environment Setup\n\n### Prerequisites\n\n- **Python 3.10+**: Check version with `python --version`\n- **uv**: Install from [https://github.com/astral-sh/uv](https://github.com/astral-sh/uv)\n- **Docker**: For local development and testing\n- **Git**: Version control\n- **IDE**: VS Code, PyCharm, or Cursor (recommended for AI assistance)\n\n### Initial Setup\n\n1. **Clone and Navigate**\n   ```bash\n   git clone https://github.com/alibaba/OpenSandbox.git\n   cd OpenSandbox/server\n   ```\n\n2. **Install Dependencies**\n   ```bash\n   uv sync\n   ```\n\n3. **Verify Installation**\n   ```bash\n   uv run python -c \"import fastapi; print(fastapi.__version__)\"\n   ```\n\n4. **Configure Development Environment**\n   ```bash\n   cp example.config.toml ~/.sandbox.toml\n   ```\n\n   Edit `~/.sandbox.toml` for local development:\n   ```toml\n   [server]\n   host = \"0.0.0.0\"\n   port = 8080\n   log_level = \"DEBUG\"\n   api_key = \"your-secret-api-key-change-this\"\n\n   [runtime]\n   type = \"docker\"\n   execd_image = \"opensandbox/execd:v1.0.7\"\n\n   [docker]\n   network_mode = \"host\"\n   ```\n\n5. **Run Development Server**\n   ```bash\n   uv run python -m src.main\n   ```\n\n### IDE Configuration\n\n#### VS Code / Cursor\n\nCreate `.vscode/launch.json`:\n\n```json\n{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Python: FastAPI\",\n            \"type\": \"python\",\n            \"request\": \"launch\",\n            \"module\": \"src.main\",\n            \"justMyCode\": false,\n            \"env\": {\n                \"SANDBOX_CONFIG_PATH\": \"${workspaceFolder}/.sandbox.toml\"\n            }\n        }\n    ]\n}\n```\n\n#### PyCharm\n\n1. Open project in PyCharm\n2. Configure Python interpreter: **Settings → Project → Python Interpreter**\n3. Select the virtual environment created by `uv sync`\n4. Enable pytest: **Settings → Tools → Python Integrated Tools → Testing → pytest**\n\n## Project Structure\n\n```\nserver/\n├── src/                          # Source code\n│   ├── main.py                   # FastAPI application entry point\n│   ├── config.py                 # Configuration management\n│   ├── api/                      # API layer\n│   │   ├── lifecycle.py          # Sandbox lifecycle routes\n│   │   └── schema.py             # Pydantic models\n│   ├── middleware/               # Middleware components\n│   │   └── auth.py               # API Key authentication\n│   └── services/                 # Business logic layer\n│       ├── sandbox_service.py    # Abstract base class\n│       ├── docker.py             # Docker implementation\n│       └── factory.py            # Service factory\n├── tests/                        # Test suite\n├── scripts/                      # Utility scripts\n├── pyproject.toml                # Project metadata and dependencies\n└── example.config.toml           # Example configuration\n```\n\n## Architecture Deep Dive\n\n### Layered Architecture\n\nThe server follows a clean layered architecture:\n\n1. **HTTP Layer** (FastAPI routes) - Request validation and response serialization\n2. **Middleware Layer** - Authentication and cross-cutting concerns\n3. **Service Layer** - Business logic abstraction\n4. **Runtime Implementation Layer** - Docker/Kubernetes specific code\n\n### Request Flow\n\n#### Create Sandbox (Async)\n\n```\nClient → POST /sandboxes\n  ↓\nAuth Middleware validates API key\n  ↓\nlifecycle.create_sandbox() receives CreateSandboxRequest\n  ↓\nsandbox_service.create_sandbox_async(request)\n  ↓\nReturns 202 Accepted with Pending status immediately\n  ↓\nBackground thread provisions the sandbox\n```\n\n### Internal Systems\n\n#### Expiration Timer System\n\nTracks sandbox timeouts using in-memory data structures:\n- `_sandbox_expirations: Dict[str, datetime]` - Expiration times\n- `_expiration_timers: Dict[str, Timer]` - Active timer threads\n- `_expiration_lock: Lock` - Thread synchronization\n\n#### Async Provisioning System\n\nAvoids blocking API requests during slow operations by:\n1. Storing sandboxes in pending state\n2. Starting background provisioning thread\n3. Returning 202 Accepted immediately\n4. Transitioning to running state when ready\n\n## Development Workflow\n\n### Feature Development\n\n```bash\ngit checkout -b feature/my-feature\n# Implement feature\nuv run pytest\ngit commit -m \"feat: add my feature\"\ngit push origin feature/my-feature\n```\n\n### Bug Fixes\n\n```bash\ngit checkout -b fix/bug-description\n# Write failing test\n# Fix bug\nuv run pytest\ngit commit -m \"fix: resolve bug\"\n```\n\n## Testing Guide\n\n### Running Tests\n> **Note**: A local Docker daemon is required to run the full test suite, as integration tests interact with the Docker Engine.\n\n```bash\n# All tests\nuv run pytest\n\n# Specific file\nuv run pytest tests/test_docker_service.py\n\n# With coverage\nuv run pytest --cov=src --cov-report=html\n```\n\n### Writing Tests\n\nExample unit test:\n\n```python\n@patch(\"src.services.docker.docker\")\ndef test_create_sandbox_validates_entrypoint(mock_docker):\n    service = DockerSandboxService(config=test_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        entrypoint=[]  # Invalid\n    )\n    with pytest.raises(HTTPException):\n        service.create_sandbox(request)\n```\n\n## Working with Docker Runtime\n\n### Local Development\n\n```bash\n# Use local Docker\nexport DOCKER_HOST=\"unix:///var/run/docker.sock\"\nuv run python -m src.main\n\n# Use remote Docker\nexport DOCKER_HOST=\"ssh://user@remote-host\"\nuv run python -m src.main\n```\n\n### Network Modes\n\n**Host Mode (Default):**\n- Sandboxes share host network\n- Direct port access\n- Endpoint format: `http://{domain}/{sandbox_id}/{port}`\n\n**Bridge Mode:**\n- Isolated networks\n- HTTP proxy required\n- Endpoint format: `http://{server}/route/{sandbox_id}/{port}/path`\n\n### Egress sidecar (bridge + `networkPolicy`)\n\n- Config: set `[egress].image`; sidecar starts only when the request carries `networkPolicy`. Requires Docker `network_mode=\"bridge\"`.\n- Network & privileges: main container shares the sidecar netns (`network_mode=container:<sidecar>`); main container explicitly drops `NET_ADMIN`; sidecar keeps `NET_ADMIN` to manage iptables / DNS transparent redirect.\n- Ports: host port bindings live on the sidecar; main container labels record the mapped ports for upstream endpoint resolution.\n- Lifecycle: on create failure / delete / expiration / abnormal recovery, the sidecar is cleaned up; startup also removes orphaned sidecars.\n- Injection: `OPENSANDBOX_EGRESS_RULES` env passes the `networkPolicy` JSON; sidecar image is pulled/ensured before start.\n\n## Working with Kubernetes Runtime\n\n> **Status:** Planned / Configuration Ready\n\nArchitecture will include:\n- Pod management with execd init container\n- Service/Ingress for networking\n- CronJob or operator for expiration handling\n\n## Code Style and Standards\n\nFollow PEP 8 with Ruff enforcement:\n\n```bash\nuv run ruff check src tests\n```\n\n### Naming Conventions\n\n- Functions: `snake_case`\n- Classes: `PascalCase`\n- Constants: `UPPER_SNAKE_CASE`\n- Private: `_leading_underscore`\n\n### Type Hints\n\nAlways use type hints:\n\n```python\ndef get_sandbox(self, sandbox_id: str) -> Sandbox:\n    pass\n```\n\n## Debugging\n\n### Enable Debug Logging\n\n```toml\n[server]\nlog_level = \"DEBUG\"\n```\n\n### Interactive Debugging\n\nUse VS Code/Cursor breakpoints or:\n\n```python\nbreakpoint()  # Python 3.7+\n```\n\n### Docker Debugging\n\n```python\nimport logging\nlogging.getLogger(\"docker\").setLevel(logging.DEBUG)\n```\n\n## Performance Optimization\n\n### Profiling\n\n```bash\npython -m cProfile -o profile.stats -m src.main\n```\n\n### Optimization Tips\n\n1. **Async Operations**: Use async provisioning to avoid blocking\n2. **Connection Pooling**: Reuse Docker client connections\n3. **Caching**: Cache configuration and frequently accessed data\n4. **Resource Limits**: Set appropriate container resource limits\n5. **Monitoring**: Track container creation/deletion metrics\n\n## Contributing\n\n### Pull Request Process\n\n1. Fork the repository\n2. Create feature branch from `main`\n3. Write tests for new functionality\n4. Ensure all tests pass: `uv run pytest`\n5. Run linter: `uv run ruff check`\n6. Write clear commit messages\n7. Submit PR with description\n\n### Code Review Guidelines\n\n- Focus on readability and maintainability\n- Ensure test coverage for new code\n- Check for proper error handling\n- Verify documentation updates\n- Test Docker and potential Kubernetes compatibility\n\n### Commit Message Format\n\n```\n<type>: <description>\n\nTypes: feat, fix, docs, style, refactor, test, chore\n```\n\nExamples:\n- `feat: add Kubernetes runtime support`\n- `fix: resolve expiration timer memory leak`\n- `docs: update API documentation`\n\n---\n\nFor questions or support, please open an issue on the project repository.\n"
  },
  {
    "path": "server/Dockerfile",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nFROM python:3.10-slim AS builder\n\nENV PIP_DISABLE_PIP_VERSION_CHECK=1 \\\n    PYTHONDONTWRITEBYTECODE=1 \\\n    PYTHONUNBUFFERED=1 \\\n    UV_PROJECT_ENV=/app/.venv \\\n    UV_LINK_MODE=copy\n\nWORKDIR /app\n\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends curl ca-certificates \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN curl -LsSf https://astral.sh/uv/install.sh | sh\nENV PATH=\"/root/.local/bin:/root/.cargo/bin:${PATH}\"\n\nCOPY pyproject.toml uv.lock ./\nRUN uv sync --frozen --no-dev --no-install-project\n\nCOPY src ./src\nCOPY LICENSE README.md README_zh.md example.config.toml example.config.zh.toml \\\n     example.config.k8s.toml example.config.k8s.zh.toml example.batchsandbox-template.yaml ./\n\n# Install the project itself into the venv (deps already synced)\nRUN uv pip install --no-deps --editable .\n\nFROM python:3.10-slim AS runtime\n\nENV PIP_DISABLE_PIP_VERSION_CHECK=1 \\\n    PYTHONDONTWRITEBYTECODE=1 \\\n    PYTHONUNBUFFERED=1 \\\n    UV_PROJECT_ENV=/app/.venv \\\n    PATH=\"/app/.venv/bin:${PATH}\" \\\n    SANDBOX_CONFIG_PATH=/etc/opensandbox/config.toml\n\nWORKDIR /app\n\nCOPY --from=builder /app/.venv /app/.venv\nCOPY --from=builder /app/src /app/src\nCOPY --from=builder /app/example.config.k8s.toml /etc/opensandbox/config.toml\nCOPY --from=builder /app/example.config.k8s.zh.toml /etc/opensandbox/config.zh.toml\nCOPY --from=builder /app/example.batchsandbox-template.yaml /etc/opensandbox/example.batchsandbox-template.yaml\n\nEXPOSE 8080\n\nENTRYPOINT [\"opensandbox-server\"]\nCMD [\"--config\", \"/etc/opensandbox/config.toml\"]\n"
  },
  {
    "path": "server/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "server/README.md",
    "content": "# OpenSandbox Server\n\nEnglish | [中文](README_zh.md)\n\nA production-grade, FastAPI-based service for managing the lifecycle of containerized sandboxes. It acts as the control plane to create, run, monitor, and dispose isolated execution environments across container platforms.\n\n## Features\n\n### Core capabilities\n- **Lifecycle APIs**: Standardized REST interfaces for create, start, pause, resume, delete\n- **Pluggable runtimes**:\n  - **Docker**: Production-ready\n  - **Kubernetes**: Production-ready (see `kubernetes/` for deployment)\n- **Lifecycle cleanup modes**: Configurable TTL with renewal, or manual cleanup with explicit delete\n- **Access control**: API Key authentication (`OPEN-SANDBOX-API-KEY`); can be disabled for local/dev\n- **Networking modes**:\n  - Host: shared host network, performance first\n  - Bridge: isolated network with built-in HTTP routing\n- **Resource quotas**: CPU/memory limits with Kubernetes-style specs\n- **Observability**: Unified status with transition tracking\n- **Registry support**: Public and private images\n\n### Extended capabilities\n- **Async provisioning**: Background creation to reduce latency\n- **Timer restoration**: Expiration timers restored after restart\n- **Env/metadata injection**: Per-sandbox environment and metadata\n- **Port resolution**: Dynamic endpoint generation\n- **Structured errors**: Standard error codes and messages\n\nMetadata keys under the reserved prefix `opensandbox.io/` are system-managed\nand cannot be supplied by users.\n\n## Requirements\n\n- **Python**: 3.10 or higher\n- **Package Manager**: [uv](https://github.com/astral-sh/uv) (recommended) or pip\n- **Runtime Backend**:\n  - Docker Engine 20.10+ (for Docker runtime)\n  - Kubernetes 1.21.1+ (for Kubernetes runtime)\n- **Operating System**: Linux, macOS, or Windows with WSL2\n\n## Quick Start\n\n### Installation\n\n1. **Install from PyPI**:\n   > For source development or contributions, you can still clone the repo and run `uv sync` inside `server/`.\n   ```bash\n   uv pip install opensandbox-server\n   ```\n\n### Configuration\n\nThe server uses a TOML configuration file to select and configure the underlying runtime.\n\n**Init configuration from simple example**:\n```bash\n# run opensandbox-server -h for help\nopensandbox-server init-config ~/.sandbox.toml --example docker\n```\n\n**Create K8S configuration file**\n\nThe K8S version of the Sandbox Operator needs to be deployed in the cluster, refer to the Kubernetes directory.\n```bash\n# run opensandbox-server -h for help\nopensandbox-server init-config ~/.sandbox.toml --example k8s\n```\n\n**[optional] Edit configuration for your environment**\n\n- For quick e2e/demo (specify which one):\n  ```bash\n  opensandbox-server init-config ~/.sandbox.toml --example docker  # or docker-zh|k8s|k8s-zh\n  # add --force to overwrite existing file\n  ```\n- Render the full schema-driven skeleton (no defaults, just placeholders) by omitting --example:\n  ```bash\n  opensandbox-server init-config ~/.sandbox.toml\n  # add --force to overwrite existing file\n  ```\n\n**[optional] Edit `~/.sandbox.toml` for your environment**\n\nBefore you start the server, edit the configuration file to suit your environment. You could also generate a new empty configuration file by `opensandbox-server init-config ~/.sandbox.toml`.\n\n**Docker runtime + host networking**\n   ```toml\n   [server]\n   host = \"0.0.0.0\"\n   port = 8080\n   log_level = \"INFO\"\n   api_key = \"your-secret-api-key-change-this\"\n   max_sandbox_timeout_seconds = 86400  # Maximum TTL for requests that specify timeout\n\n   [runtime]\n   type = \"docker\"\n   execd_image = \"opensandbox/execd:v1.0.7\"\n\n   [docker]\n   network_mode = \"host\"  # Containers share host network; only one sandbox instance at a time\n   ```\n\n**Docker runtime + bridge networking**\n   ```toml\n   [server]\n   host = \"0.0.0.0\"\n   port = 8080\n   log_level = \"INFO\"\n   api_key = \"your-secret-api-key-change-this\"\n    max_sandbox_timeout_seconds = 86400  # Maximum TTL for requests that specify timeout\n\n   [runtime]\n   type = \"docker\"\n   execd_image = \"opensandbox/execd:v1.0.7\"\n\n   [docker]\n   network_mode = \"bridge\"  # Isolated container networking\n   ```\n\n**Docker Compose deployment (server runs in a container)**\n\nWhen `opensandbox-server` itself runs inside Docker Compose and manages sandboxes via\nmounted `/var/run/docker.sock`, configure a reachable host value for bridge-mode endpoint\nresolution:\n\n```toml\n[docker]\nnetwork_mode = \"bridge\"\nhost_ip = \"host.docker.internal\"  # or host LAN IP (for Linux: explicit host IP is recommended)\n```\n\nWhy this matters:\n- In bridge mode, sandbox containers get internal Docker IPs.\n- External callers usually cannot reach those internal IPs directly.\n- `host_ip` lets endpoint resolution return host-reachable addresses.\n\nFor SDK/API clients that cannot directly reach sandbox bridge addresses, request proxied\nendpoints through the server:\n\n```bash\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  \"http://localhost:8080/v1/sandboxes/<sandbox-id>/endpoints/44772?use_server_proxy=true\"\n```\n\nThe returned endpoint is rewritten to the server proxy route:\n- `<server-host>/sandboxes/<sandbox-id>/proxy/<port>`\n\nReference runtime compose file:\n- `server/docker-compose.example.yaml`\n\n**Sandbox TTL configuration**\n\n- `timeout` requests must be at least 60 seconds.\n- The maximum allowed TTL is controlled by `server.max_sandbox_timeout_seconds`.\n- Omit `timeout` or set it to `null` in the create request to use manual cleanup mode instead of automatic expiration.\n\n**Upgrade order for manual cleanup**\n\n- Existing TTL-only clients can continue to work without changes as long as they do not encounter manual-cleanup sandboxes.\n- Manual cleanup changes the lifecycle response contract: `expiresAt` may be `null`, and other nullable lifecycle fields may also be serialized explicitly as `null`.\n- In practice this can include fields such as `metadata`, `status.reason`, `status.message`, and `status.lastTransitionAt`, depending on the sandbox state and the server response model.\n- Before creating any manual-cleanup sandbox, upgrade every SDK/client that may call `create`, `get`, or `list` on the lifecycle API.\n- Recommended rollout order:\n  1. Upgrade SDKs/clients\n  2. Upgrade the server\n  3. Start creating sandboxes with `timeout` omitted or `null`\n- Do not introduce manual-cleanup sandboxes into a shared environment while old SDKs are still actively reading lifecycle responses.\n\n**Security hardening (applies to all Docker modes)**\n   ```toml\n   [docker]\n   # Drop dangerous capabilities and block privilege escalation by default\n   drop_capabilities = [\"AUDIT_WRITE\", \"MKNOD\", \"NET_ADMIN\", \"NET_RAW\", \"SYS_ADMIN\", \"SYS_MODULE\", \"SYS_PTRACE\", \"SYS_TIME\", \"SYS_TTY_CONFIG\"]\n   no_new_privileges = true\n   apparmor_profile = \"\"        # e.g. \"docker-default\" when AppArmor is available\n   # Limit fork bombs and optionally enforce seccomp / read-only rootfs\n   pids_limit = 512             # set to null to disable\n   seccomp_profile = \"\"        # path or profile name; empty uses Docker default\n   ```\n   Further reading on Docker container security: https://docs.docker.com/engine/security/\n\nFor common issues and solutions, see [Troubleshooting](TROUBLESHOOTING.md).\n\n**Secure container runtime (optional)**\n\nOpenSandbox supports secure container runtimes for enhanced isolation:\n\n```toml\n[secure_runtime]\ntype = \"gvisor\"              # Options: \"\", \"gvisor\", \"kata\", \"firecracker\"\ndocker_runtime = \"runsc\"      # Docker OCI runtime name (for gVisor, Kata)\n# k8s_runtime_class = \"gvisor\"  # Kubernetes RuntimeClass name (for K8s)\n```\n\n- `type=\"\"` (default): No secure runtime, uses runc\n- `type=\"gvisor\"`: Uses gVisor (runsc) for user-space kernel isolation\n- `type=\"kata\"`: Uses Kata Containers for VM-level isolation\n- `type=\"firecracker\"`: Uses Firecracker microVM (Kubernetes only)\n\n> **Detailed guide**: See [Secure Container Runtime Guide](../docs/secure-container.md) for complete installation instructions, system requirements, and troubleshooting.\n\n**Docker daemon setup** for gVisor:\n```json\n{\n  \"runtimes\": {\n    \"runsc\": {\n      \"path\": \"/usr/bin/runsc\"\n    }\n  }\n}\n```\n\n**Kubernetes setup**: Create RuntimeClass before using:\n```bash\nkubectl create -f - <<EOF\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc\nEOF\n```\n\n**Ingress exposure (direct | gateway)**\n   ```toml\n   [ingress]\n   mode = \"direct\"  # docker runtime only supports direct\n   # gateway.address = \"*.example.com\"         # host only (domain or IP[:port]); scheme is not allowed\n   # gateway.route.mode = \"wildcard\"            # wildcard | uri | header\n   ```\n   - `mode=direct`: default; required when `runtime.type=docker` (client ↔ sandbox direct reachability, no L7 gateway).\n   - `mode=gateway`: configure external ingress.\n     - `gateway.address`: wildcard domain required when `gateway.route.mode=wildcard`; otherwise must be domain, IP, or IP:port. Do not include scheme; clients decide http/https.\n     - `gateway.route.mode`: `wildcard` (host-based wildcard), `uri` (path-prefix), `header` (header-based routing).\n     - Response format examples:\n       - `wildcard`: `<sandbox-id>-<port>.example.com/path/to/request`\n       - `uri`: `10.0.0.1:8000/<sandbox-id>/<port>/path/to/request`\n       - `header`: `gateway.example.com` with header `OpenSandbox-Ingress-To: <sandbox-id>-<port>`\n\n**Kubernetes runtime**\n   ```toml\n   [runtime]\n   type = \"kubernetes\"\n   execd_image = \"opensandbox/execd:v1.0.7\"\n\n   [kubernetes]\n   kubeconfig_path = \"~/.kube/config\"\n   namespace = \"opensandbox\"\n   workload_provider = \"batchsandbox\"   # or \"agent-sandbox\"\n   informer_enabled = true              # Beta: enable watch-based cache\n   informer_resync_seconds = 300        # Beta: full list interval\n   informer_watch_timeout_seconds = 60  # Beta: watch restart interval\n   ```\n   - Informer settings are **beta** and enabled by default to reduce API calls; set `informer_enabled = false` to turn off.\n   - Resync and watch timeouts control how often the cache refreshes; tune for your cluster API limits.\n\n### Egress configuration\n\nThe **`[egress]`** block configures the **egress sidecar** image and enforcement mode. The server only starts this sidecar when a sandbox is created **with** a `networkPolicy` (outbound allow/deny rules). If the create request omits `networkPolicy`, no egress sidecar is added and outbound traffic is not restricted by this mechanism.\n\n#### Keys\n\n| Key | Type | Default | Required | Description |\n|-----|------|---------|----------|-------------|\n| `image` | string | — | **Yes** whenever `networkPolicy` is used in a create request | OCI image containing the egress binary. Pulled before the sidecar starts. |\n| `mode` | `dns` or `dns+nft` | `dns` | No | How the sidecar enforces policy. Written to the sidecar as `OPENSANDBOX_EGRESS_MODE` (see below). |\n\n#### `mode` values\n\n- **`dns`**: DNS-based enforcement via the in-sidecar DNS proxy. No nftables layer-2 rules from this path. **CIDR and static IP targets in the policy are not enforced** (use domain-style rules only if you rely on `dns` mode).\n- **`dns+nft`**: Same DNS path, plus nftables where available (see the [egress component README](../components/egress/README.md) for capabilities and fallbacks). **CIDR and static IP allow/deny rules are supported** via nftables when the table is applied successfully.\n\n#### Per-request `networkPolicy`\n\n- Rules are defined on **`CreateSandboxRequest.networkPolicy`** (default action and ordered egress rules: hostnames / patterns, and IP or CIDR entries when using **`dns+nft`**).\n- The serialized policy is passed into the sidecar as **`OPENSANDBOX_EGRESS_RULES`** (JSON).\n- An auth token may be attached for the egress HTTP API; see runtime behavior below.\n\n#### Docker runtime\n\n- **`egress.image` must be set** in config when clients send `networkPolicy`; otherwise the request is rejected.\n- Outbound policy requires **`docker.network_mode = \"bridge\"`**. Requests with `networkPolicy` are rejected for `network_mode=host` or for user-defined Docker networks that are incompatible with the sidecar attachment model.\n- The main sandbox container shares the sidecar’s network namespace, **drops `NET_ADMIN`**, and relies on the sidecar for policy; the sidecar **keeps `NET_ADMIN`**.\n- **IPv6** is disabled in the shared namespace so allow/deny behavior stays consistent.\n\n#### Kubernetes runtime\n\n- When `networkPolicy` is present, the workload pod includes an **egress** sidecar built from `egress.image`, in addition to the main sandbox container.\n- **`egress.image`** is required in the same way as for Docker.\n\n#### Operational notes\n\n- The sidecar image is pulled (or validated) before start; delete, expiry, and failure paths attempt to remove the sidecar.\n- For deeper behavior (DNS proxy, nftables, limits), refer to the **egress** component documentation under `components/egress/`.\n\n#### Example (`~/.sandbox.toml`)\n\n```toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[egress]\nimage = \"opensandbox/egress:v1.0.3\"\nmode = \"dns\"\n```\n\n#### Example create request with `networkPolicy`\n\n```json\n{\n  \"image\": {\"uri\": \"python:3.11-slim\"},\n  \"entrypoint\": [\"python\", \"-m\", \"http.server\", \"8000\"],\n  \"timeout\": 3600,\n  \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n  \"networkPolicy\": {\n    \"defaultAction\": \"deny\",\n    \"egress\": [\n      {\"action\": \"allow\", \"target\": \"pypi.org\"},\n      {\"action\": \"allow\", \"target\": \"*.python.org\"}\n    ]\n  }\n}\n```\n\n### Run the server\n\nStart the server using the installed CLI (reads `~/.sandbox.toml` by default):\n\n```bash\nopensandbox-server\n```\n\nThe server will start at `http://0.0.0.0:8080` (or your configured host/port).\n\n### Run the server (installed package)\n\nAfter installing the package (wheel or PyPI), you can use the CLI entrypoint:\n\n```bash\nopensandbox-server --config ~/.sandbox.toml\n```\n\n**Health check**\n\n```bash\ncurl http://localhost:8080/health\n```\n\nExpected response:\n```json\n{\"status\": \"healthy\"}\n```\n\n## API documentation\n\nOnce the server is running, interactive API documentation is available:\n\n- **Swagger UI**: [http://localhost:8080/docs](http://localhost:8080/docs)\n- **ReDoc**: [http://localhost:8080/redoc](http://localhost:8080/redoc)\n\nFurther reading on Docker container security: https://docs.docker.com/engine/security/\n\n### API authentication\n\nAuthentication is enforced only when `server.api_key` is set. If the value is empty or missing, the middleware skips API Key checks (intended for local/dev). For production, always set a non-empty `server.api_key` and send it via the `OPEN-SANDBOX-API-KEY` header.\n\nAll API endpoints (except `/health`, `/docs`, `/redoc`) require authentication via the `OPEN-SANDBOX-API-KEY` header when authentication is enabled:\n\n```bash\ncurl http://localhost:8080/v1/sandboxes\n```\n\n### Example usage\n\n**Create a Sandbox**\n\n```bash\ncurl -X POST \"http://localhost:8080/v1/sandboxes\" \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"image\": {\n      \"uri\": \"python:3.11-slim\"\n    },\n    \"entrypoint\": [\n      \"python\",\n      \"-m\",\n      \"http.server\",\n      \"8000\"\n    ],\n    \"timeout\": 3600,\n    \"resourceLimits\": {\n      \"cpu\": \"500m\",\n      \"memory\": \"512Mi\"\n    },\n    \"env\": {\n      \"PYTHONUNBUFFERED\": \"1\"\n    },\n    \"metadata\": {\n      \"team\": \"backend\",\n      \"project\": \"api-testing\"\n    }\n  }'\n```\n\nResponse:\n```json\n{\n  \"id\": \"a1b2c3d4-5678-90ab-cdef-1234567890ab\",\n  \"status\": {\n    \"state\": \"Pending\",\n    \"reason\": \"CONTAINER_STARTING\",\n    \"message\": \"Sandbox container is starting.\",\n    \"lastTransitionAt\": \"2024-01-15T10:30:00Z\"\n  },\n  \"metadata\": {\n    \"team\": \"backend\",\n    \"project\": \"api-testing\"\n  },\n  \"expiresAt\": \"2024-01-15T11:30:00Z\",\n  \"createdAt\": \"2024-01-15T10:30:00Z\",\n  \"entrypoint\": [\"python\", \"-m\", \"http.server\", \"8000\"]\n}\n```\n\n**Get Sandbox Details**\n\n```bash\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab\n```\n\n**Get Service Endpoint**\n\n```bash\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/endpoints/8000\n\n# execd (agent) endpoint\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/endpoints/44772\n```\n\nResponse:\n```json\n{\n  \"endpoint\": \"sandbox.example.com/a1b2c3d4-5678-90ab-cdef-1234567890ab/8000\"\n}\n```\n\n**Renew Expiration**\n\n```bash\ncurl -X POST \"http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/renew-expiration\" \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"expiresAt\": \"2024-01-15T12:30:00Z\"\n  }'\n```\n\n**Delete a Sandbox**\n\n```bash\ncurl -X DELETE \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab\n```\n\n## Architecture\n\n### Component responsibilities\n\n- **API Layer** (`src/api/`): HTTP request handling, validation, and response formatting\n- **Service Layer** (`src/services/`): Business logic for sandbox lifecycle operations\n- **Middleware** (`src/middleware/`): Cross-cutting concerns (authentication, logging)\n- **Configuration** (`src/config.py`): Centralized configuration management\n- **Runtime Implementations**: Platform-specific sandbox orchestration\n\n### Sandbox lifecycle states\n\n```\n       create()\n          │\n          ▼\n     ┌─────────┐\n     │ Pending │────────────────────┐\n     └────┬────┘                    │\n          │                         │\n          │ (provisioning)          │\n          ▼                         │\n     ┌─────────┐    pause()         │\n     │ Running │───────────────┐    │\n     └────┬────┘               │    │\n          │      resume()      │    │\n          │   ┌────────────────┘    │\n          │   │                     │\n          │   ▼                     │\n          │ ┌────────┐              │\n          ├─│ Paused │              │\n          │ └────────┘              │\n          │                         │\n          │ delete() or expire()    │\n          ▼                         │\n     ┌──────────┐                   │\n     │ Stopping │                   │\n     └────┬─────┘                   │\n          │                         │\n          ├────────────────┬────────┘\n          │                │\n          ▼                ▼\n     ┌────────────┐   ┌────────┐\n     │ Terminated │   │ Failed │\n     └────────────┘   └────────┘\n```\n\n## Configuration reference\n\n### Server configuration\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `server.host` | string | `\"0.0.0.0\"` | Interface to bind |\n| `server.port` | integer | `8080` | Port to listen on |\n| `server.log_level` | string | `\"INFO\"` | Python logging level |\n| `server.api_key` | string | `null` | API key for authentication |\n| `server.eip` | string | `null` | Bound public IP; when set, used as the host part when returning sandbox endpoints (Docker runtime) |\n\n### Runtime configuration\n\n| Key                    | Type   | Required | Description                                           |\n|------------------------|--------|----------|-------------------------------------------------------|\n| `runtime.type`         | string | Yes      | Runtime implementation (`\"docker\"` or `\"kubernetes\"`) |\n| `runtime.execd_image`  | string | Yes      | Container image with execd binary                     |\n\n### Egress configuration\n\n| Key | Type | Default | Required if using `networkPolicy` | Description |\n|-----|------|---------|-----------------------------------|-------------|\n| `egress.image` | string | — | Yes | Egress sidecar image (OCI reference). |\n| `egress.mode` | `dns` \\| `dns+nft` | `dns` | No | `OPENSANDBOX_EGRESS_MODE`. CIDR/IP rules need `dns+nft`; `dns` is domain-oriented only. |\n\n### Docker configuration\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `docker.network_mode` | string | `\"host\"` | Network mode (`\"host\"` or `\"bridge\"`) |\n\n### Agent-sandbox configuration\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `agent_sandbox.template_file` | string | `null` | Sandbox CR YAML template for agent-sandbox (used when `kubernetes.workload_provider = \"agent-sandbox\"`) |\n| `agent_sandbox.shutdown_policy` | string | `\"Delete\"` | Shutdown policy on expiry (`\"Delete\"` or `\"Retain\"`) |\n| `agent_sandbox.ingress_enabled` | boolean | `true` | Whether ingress routing is expected to be enabled |\n\n### Environment variables\n\n| Variable | Description |\n|----------|-------------|\n| `SANDBOX_CONFIG_PATH` | Override config file location |\n| `DOCKER_HOST` | Docker daemon URL (e.g., `unix:///var/run/docker.sock`) |\n| `PENDING_FAILURE_TTL` | TTL for failed pending sandboxes in seconds (default: 3600) |\n\n## Development\n\n### Code quality\n\n**Run linter**:\n```bash\nuv run ruff check\n```\n\n**Auto-fix issues**:\n```bash\nuv run ruff check --fix\n```\n\n**Format code**:\n```bash\nuv run ruff format\n```\n\n### Testing\n\n**Run all tests**:\n```bash\nuv run pytest\n```\n\n**Run with coverage**:\n```bash\nuv run pytest --cov=src --cov-report=html\n```\n\n**Run specific test**:\n```bash\nuv run pytest tests/test_docker_service.py::test_create_sandbox_requires_entrypoint\n```\n\n## License\n\nThis project is licensed under the terms specified in the LICENSE file in the repository root.\n\n## Contributing\n\nContributions are welcome. Suggested flow:\n\n1. Fork the repository\n2. Create a feature branch (`git checkout -b feature/amazing-feature`)\n3. Write tests for new functionality\n4. Ensure all tests pass (`uv run pytest`)\n5. Run linting (`uv run ruff check`)\n6. Commit with clear messages\n7. Push to your fork\n8. Open a Pull Request\n\n## Support\n\n- Documentation: See `DEVELOPMENT.md` for development guidance\n- Issues: Report defects via GitHub Issues\n- Discussions: Use GitHub Discussions for Q&A and ideas\n"
  },
  {
    "path": "server/README_zh.md",
    "content": "# OpenSandbox Server（沙箱服务端）\n\n中文 | [English](README.md)\n\n基于 FastAPI 的生产级容器化沙箱生命周期管理服务。作为控制平面，协调在不同容器编排环境中的隔离运行时的创建、执行、监控与销毁。\n\n## 功能特性\n\n### 核心能力\n- **生命周期管理**：标准化 REST API 覆盖创建、启动、暂停、恢复、删除\n- **可插拔运行时**：\n  - **Docker**：已支持生产部署\n  - **Kubernetes**：已支持生产部署\n- **自动过期**：可配置 TTL，支持续期\n- **访问控制**：API Key 认证（`OPEN-SANDBOX-API-KEY`），本地/开发可配置为空跳过\n- **网络模式**：\n  - Host：共享宿主网络，性能优先\n  - Bridge：隔离网络，内置 HTTP 代理路由\n- **资源配额**：CPU/内存限制，Kubernetes 风格规范\n- **状态可观测性**：统一状态与转换跟踪\n- **镜像仓库**：支持公共与私有镜像\n\n### 扩展能力\n- **异步供应**：后台创建，降低请求延迟\n- **定时恢复**：重启后自动恢复过期定时器\n- **环境与元数据注入**：按沙箱注入 env 与 metadata\n- **端口解析**：动态生成访问端点\n- **结构化错误**：标准错误码与消息，便于排障\n\n## 环境要求\n\n- **Python**：3.10 或更高版本\n- **包管理器**：[uv](https://github.com/astral-sh/uv)（推荐）或 pip\n- **运行时后端**：\n  - Docker Engine 20.10+（使用 Docker 运行时）\n  - Kubernetes 1.21.1+（使用 Kubernetes 运行时）\n- **操作系统**：Linux、macOS 或带 WSL2 的 Windows\n\n## 快速开始\n\n### 安装步骤\n\n1. **通过 PyPI 安装**（无需克隆仓库）：\n\n```bash\nuv pip install opensandbox-server\n```\n> 如需源码开发或贡献，可仍然克隆仓库并在 `server/` 下执行 `uv sync`。\n\n### 配置指南\n\n服务端使用 TOML 配置文件来选择和配置底层运行时。\n\n**从简单示例初始化配置**：\n```bash\n# 运行 opensandbox-server -h 查看帮助\nopensandbox-server init-config ~/.sandbox.toml --example docker-zh\n```\n\n**创建 K8S 配置文件**\n\n需要在集群中部署 K8S 版本的 Sandbox Operator，参考 Kubernetes 目录。\n```bash\n# 运行 opensandbox-server -h 查看帮助\nopensandbox-server init-config ~/.sandbox.toml --example k8s-zh\n```\n\n**[可选] 编辑配置以适配您的环境**\n\n- 用于快速 e2e/demo：\n  ```bash\n  opensandbox-server init-config ~/.sandbox.toml --example docker-zh  # 或 docker-zh|k8s|k8s-zh\n  # 已有文件需覆盖时加 --force\n  ```\n- 省略 `--example` 时生成“配置框架”（无默认值，只有占位符）：\n  ```bash\n  opensandbox-server init-config ~/.sandbox.toml\n  # 已有文件需覆盖时加 --force\n  ```\n\n**[可选] 编辑 `~/.sandbox.toml`** 适配您的环境\n\n在启动服务器前，编辑配置文件以适配您的环境。您也可以通过 `opensandbox-server init-config ~/.sandbox.toml` 生成一个新的完整配置模板。\n\n**Docker 运行时 + Host 网络模式**\n   ```toml\n   [server]\n   host = \"0.0.0.0\"\n   port = 8080\n   log_level = \"INFO\"\n   api_key = \"your-secret-api-key-change-this\"\n\n   [runtime]\n   type = \"docker\"\n   execd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n   [docker]\n   network_mode = \"host\"  # 容器共享宿主机网络，只能创建一个sandbox实例\n   ```\n\n**Docker 运行时 + Bridge 网络模式**\n   ```toml\n   [server]\n   host = \"0.0.0.0\"\n   port = 8080\n   log_level = \"INFO\"\n   api_key = \"your-secret-api-key-change-this\"\n\n   [runtime]\n   type = \"docker\"\n   execd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n   [docker]\n   network_mode = \"bridge\"  # 容器隔离网络\n   ```\n\n**Docker Compose 部署（server 本身运行在容器中）**\n\n当 `opensandbox-server` 运行在 Docker Compose 容器内，并通过挂载\n`/var/run/docker.sock` 管理沙箱时，需要为 bridge 模式端点解析配置一个可达的宿主地址：\n\n```toml\n[docker]\nnetwork_mode = \"bridge\"\nhost_ip = \"host.docker.internal\"  # 或宿主机 LAN IP（Linux 建议显式填写）\n```\n\n原因：\n- bridge 模式下沙箱容器会分配 Docker 内部 IP。\n- 外部调用方通常无法直接访问这些内部 IP。\n- `host_ip` 会让端点解析返回对调用方可达的宿主地址。\n\n对于无法直连 sandbox bridge 地址的 SDK/API 调用方，可通过 server 代理获取端点：\n\n```bash\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  \"http://localhost:8080/v1/sandboxes/<sandbox-id>/endpoints/44772?use_server_proxy=true\"\n```\n\n返回端点会被重写为 server 代理路径：\n- `<server-host>/sandboxes/<sandbox-id>/proxy/<port>`\n\n可参考 Compose 运行示例：\n- `server/docker-compose.example.yaml`\n\n**安全加固（适用于所有 Docker 模式）**\n   ```toml\n   [docker]\n   # 默认关闭危险能力、防止提权\n   drop_capabilities = [\"AUDIT_WRITE\", \"MKNOD\", \"NET_ADMIN\", \"NET_RAW\", \"SYS_ADMIN\", \"SYS_MODULE\", \"SYS_PTRACE\", \"SYS_TIME\", \"SYS_TTY_CONFIG\"]\n   no_new_privileges = true\n   apparmor_profile = \"\"        # 例如当 AppArmor 可用时使用 \"docker-default\"\n   # 限制进程数量\n   pids_limit = 512             # 设为 null 可关闭\n   seccomp_profile = \"\"        # 配置文件路径或名称；为空使用 Docker 默认\n   ```\n   更多 Docker 容器安全参考：https://docs.docker.com/engine/security/\n\n常见问题及解决方案请参阅 [故障排查](TROUBLESHOOTING_zh.md)。\n\n**安全容器运行时（可选）**\n\nOpenSandbox 支持安全容器运行时以增强隔离性：\n\n```toml\n[secure_runtime]\ntype = \"gvisor\"              # 选项: \"\", \"gvisor\", \"kata\", \"firecracker\"\ndocker_runtime = \"runsc\"      # Docker OCI 运行时名称（用于 gVisor、Kata）\n# k8s_runtime_class = \"gvisor\"  # Kubernetes RuntimeClass 名称（用于 K8s）\n```\n\n- `type=\"\"`（默认）：不使用安全运行时，使用 runc\n- `type=\"gvisor\"`：使用 gVisor (runsc) 实现用户态内核隔离\n- `type=\"kata\"`：使用 Kata Containers 实现 VM 级隔离\n- `type=\"firecracker\"`：使用 Firecracker 微虚拟机（仅 Kubernetes）\n\n> **详细指南**：参阅 [安全容器运行时指南](../docs/secure-container.md) 获取完整的安装说明、系统要求和故障排除。\n\n**Docker daemon 配置** gVisor 示例：\n```json\n{\n  \"runtimes\": {\n    \"runsc\": {\n      \"path\": \"/usr/bin/runsc\"\n    }\n  }\n}\n```\n\n**Kubernetes 配置**：使用前需先创建 RuntimeClass：\n```bash\nkubectl create -f - <<EOF\napiVersion: node.k8s.io/v1\nkind: RuntimeClass\nmetadata:\n  name: gvisor\nhandler: runsc\nEOF\n```\n\n**Ingress 暴露（direct | gateway）**\n```toml\n[ingress]\nmode = \"direct\"  # Docker 运行时仅支持 direct（直连，无 L7 网关）\n# gateway.address = \"*.example.com\"  # 仅主机（域名/IP 或 IP:port），不允许带 scheme\n# gateway.route.mode = \"wildcard\"            # wildcard | uri | header\n```\n- `mode=direct`：默认；当 `runtime.type=docker` 时必须使用（客户端与 sandbox 直连，不经过网关）。\n- `mode=gateway`：配置外部入口。\n  - `gateway.address`：当 `gateway.route.mode=wildcard` 时必须是泛域名；其他模式需为域名/IP 或 IP:port。不允许携带 scheme，客户端自行选择 http/https。\n  - `gateway.route.mode`：`wildcard`（域名泛匹配）、`uri`（基于路径前缀）、`header`（基于请求头路由）。\n  - 返回示例：\n    - `wildcard`：`<sandbox-id>-<port>.example.com/path/to/request`\n    - `uri`：`10.0.0.1:8000/<sandbox-id>/<port>/path/to/request`\n    - `header`：`gateway.example.com`，请求头 `OpenSandbox-Ingress-To: <sandbox-id>-<port>`\n\n**Kubernetes 运行时**\n   ```toml\n   [runtime]\n   type = \"kubernetes\"\n   execd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n   [kubernetes]\n   kubeconfig_path = \"~/.kube/config\"\n   namespace = \"opensandbox\"\n   workload_provider = \"batchsandbox\"        # 或 \"agent-sandbox\"\n   informer_enabled = true                   # Beta：启用 watch 缓存\n   informer_resync_seconds = 300             # Beta：全量刷新间隔\n   informer_watch_timeout_seconds = 60       # Beta：watch 超时重连间隔\n   ```\n   - Informer 配置为 **Beta**，默认开启以减少 API 压力；若需关闭设置 `informer_enabled = false`。\n   - resync / watch 超时用于控制缓存刷新频率，可根据集群 API 限流调优。\n\n### Egress 配置（`[egress]` 配置块）\n\n**`[egress]`** 用于配置 **egress 侧车** 的镜像与执行模式。仅当创建沙箱的请求中带有 **`networkPolicy`**（出站允许/拒绝规则）时，服务器才会注入该侧车；若请求未带 `networkPolicy`，不会添加 egress 侧车，也不会通过该机制限制出站流量。\n\n#### 配置项\n\n| 键 | 类型 | 默认值 | 何时必填 | 说明 |\n|----|------|--------|----------|------|\n| `image` | string | — | 任意一次创建请求携带 `networkPolicy` 时 **必填** | 包含 egress 可执行文件的容器镜像；侧车启动前会拉取或校验镜像。 |\n| `mode` | `dns` 或 `dns+nft` | `dns` | 否 | 侧车如何执行策略，写入环境变量 `OPENSANDBOX_EGRESS_MODE`（见下）。 |\n\n#### `mode` 取值\n\n- **`dns`**：通过侧车内 DNS 代理做基于域名的策略；不依赖本路径下的 nftables 二层规则。**策略中的 CIDR、静态 IP 类目标不会被强制执行**（若只用 `dns` 模式，请使用域名类规则）。\n- **`dns+nft`**：在 `dns` 的基础上启用 nftables（能力与回退行为见 [egress 组件说明](../components/egress/README.md)）。**支持 CIDR 与静态 IP 的放行/拒绝规则**（nftables 表成功下发时生效）。\n\n#### 请求体中的 `networkPolicy`\n\n- 规则在 **`CreateSandboxRequest.networkPolicy`** 中声明（默认动作与有序的 egress 规则：域名/通配符；在使用 **`dns+nft`** 时还可包含 IP 或 CIDR 条目）。\n- 序列化后的策略以 JSON 形式注入侧车环境变量 **`OPENSANDBOX_EGRESS_RULES`**。\n- 可能同时下发用于 egress HTTP API 的鉴权信息（与运行时行为一致）。\n\n#### Docker 运行时\n\n- 客户端传入 `networkPolicy` 时，配置中必须设置 **`egress.image`**，否则请求会被拒绝。\n- 出站策略要求 **`docker.network_mode = \"bridge\"`**；`network_mode=host` 或与侧车挂载模型不兼容的用户自定义网络下，携带 `networkPolicy` 的请求会被拒绝。\n- 主沙箱容器与侧车 **共享网络命名空间**，主容器 **drop `NET_ADMIN`**，由侧车保留 **`NET_ADMIN`** 完成策略相关操作。\n- 共享 netns 内会 **禁用 IPv6**，以保证放行/拒绝行为一致。\n\n#### Kubernetes 运行时\n\n- 当请求带有 `networkPolicy` 时，工作负载 Pod 中除主容器外，还会增加基于 **`egress.image`** 的 **egress** 侧车。\n- **`egress.image`** 的必填规则与 Docker 相同。\n\n#### 运维说明\n\n- 侧车镜像在启动前拉取或校验；删除、过期、失败等路径会尽量清理侧车。\n- DNS 代理、nftables、能力边界等详见仓库内 **`components/egress/`** 文档。\n\n#### 配置示例（`~/.sandbox.toml`）\n\n```toml\n[runtime]\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[egress]\nimage = \"opensandbox/egress:v1.0.3\"\nmode = \"dns\"\n```\n\n#### 带 `networkPolicy` 的创建请求示例\n\n```json\n{\n  \"image\": {\"uri\": \"python:3.11-slim\"},\n  \"entrypoint\": [\"python\", \"-m\", \"http.server\", \"8000\"],\n  \"timeout\": 3600,\n  \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n  \"networkPolicy\": {\n    \"defaultAction\": \"deny\",\n    \"egress\": [\n      {\"action\": \"allow\", \"target\": \"pypi.org\"},\n      {\"action\": \"allow\", \"target\": \"*.python.org\"}\n    ]\n  }\n}\n```\n\n### 启动服务\n\n使用安装后的 CLI 启动（默认读取 `~/.sandbox.toml`）：\n\n```bash\nopensandbox-server\n```\n\n服务将在 `http://0.0.0.0:8080`（或您配置的主机/端口）启动。\n\n### 启动服务（安装包方式）\n\n安装为 Python 包后，可直接使用 CLI 启动：\n\n```bash\nopensandbox-server --config ~/.sandbox.toml\n```\n\n**健康检查**\n\n```bash\ncurl http://localhost:8080/health\n```\n\n预期响应：\n```json\n{\"status\": \"healthy\"}\n```\n\n## API 文档\n\n服务启动后，可访问交互式 API 文档：\n\n- **Swagger UI**：[http://localhost:8080/docs](http://localhost:8080/docs)\n- **ReDoc**：[http://localhost:8080/redoc](http://localhost:8080/redoc)\n\n### API 认证\n\n仅当 `server.api_key` 设置为非空值时才启用鉴权；当该值为空或缺省时，中间件会跳过 API Key 校验（适合本地/开发调试）。生产环境请务必设置非空的 `server.api_key`，并通过 `OPEN-SANDBOX-API-KEY` 请求头发送。\n\n当鉴权开启时，除 `/health`、`/docs`、`/redoc` 外的 API 端点均需要通过 `OPEN-SANDBOX-API-KEY` 请求头进行认证：\n\n```bash\ncurl http://localhost:8080/v1/sandboxes\n```\n\n### 使用示例\n\n**创建沙箱**\n\n```bash\ncurl -X POST \"http://localhost:8080/v1/sandboxes\" \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"image\": {\n      \"uri\": \"python:3.11-slim\"\n    },\n    \"entrypoint\": [\n      \"python\",\n      \"-m\",\n      \"http.server\",\n      \"8000\"\n    ],\n    \"timeout\": 3600,\n    \"resourceLimits\": {\n      \"cpu\": \"500m\",\n      \"memory\": \"512Mi\"\n    },\n    \"env\": {\n      \"PYTHONUNBUFFERED\": \"1\"\n    },\n    \"metadata\": {\n      \"team\": \"backend\",\n      \"project\": \"api-testing\"\n    }\n  }'\n```\n\n响应：\n```json\n{\n  \"id\": \"a1b2c3d4-5678-90ab-cdef-1234567890ab\",\n  \"status\": {\n    \"state\": \"Pending\",\n    \"reason\": \"CONTAINER_STARTING\",\n    \"message\": \"Sandbox container is starting.\",\n    \"lastTransitionAt\": \"2024-01-15T10:30:00Z\"\n  },\n  \"metadata\": {\n    \"team\": \"backend\",\n    \"project\": \"api-testing\"\n  },\n  \"expiresAt\": \"2024-01-15T11:30:00Z\",\n  \"createdAt\": \"2024-01-15T10:30:00Z\",\n  \"entrypoint\": [\"python\", \"-m\", \"http.server\", \"8000\"]\n}\n```\n\n**获取沙箱详情**\n\n```bash\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab\n```\n\n**获取服务端点**\n\n```bash\n# 获取自定义服务端点\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/endpoints/8000\n\n# 获取OpenSandbox守护进程（execd）端点\ncurl -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/endpoints/44772\n```\n\n响应：\n```json\n{\n  \"endpoint\": \"sandbox.example.com/a1b2c3d4-5678-90ab-cdef-1234567890ab/8000\"\n}\n```\n\n**续期沙箱**\n\n```bash\ncurl -X POST \"http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab/renew-expiration\" \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"expiresAt\": \"2024-01-15T12:30:00Z\"\n  }'\n```\n\n**删除沙箱**\n\n```bash\ncurl -X DELETE \\\n  -H \"OPEN-SANDBOX-API-KEY: your-secret-api-key\" \\\n  http://localhost:8080/v1/sandboxes/a1b2c3d4-5678-90ab-cdef-1234567890ab\n```\n\n## 系统架构\n\n### 组件职责\n\n- **API 层**（`src/api/`）：HTTP 请求处理、验证和响应格式化\n- **服务层**（`src/services/`）：沙箱生命周期操作的业务逻辑\n- **中间件**（`src/middleware/`）：横切关注点（认证、日志）\n- **配置**（`src/config.py`）：集中式配置管理\n- **运行时实现**：平台特定的沙箱编排\n\n### 沙箱生命周期状态\n\n```\n       create()\n          │\n          ▼\n     ┌─────────┐\n     │ Pending │────────────────────┐\n     └────┬────┘                    │\n          │                         │\n          │ (provisioning)          │\n          ▼                         │\n     ┌─────────┐    pause()         │\n     │ Running │───────────────┐    │\n     └────┬────┘               │    │\n          │      resume()      │    │\n          │   ┌────────────────┘    │\n          │   │                     │\n          │   ▼                     │\n          │ ┌────────┐              │\n          ├─│ Paused │              │\n          │ └────────┘              │\n          │                         │\n          │ delete() or expire()    │\n          ▼                         │\n     ┌──────────┐                   │\n     │ Stopping │                   │\n     └────┬─────┘                   │\n          │                         │\n          ├────────────────┬────────┘\n          │                │\n          ▼                ▼\n     ┌────────────┐   ┌────────┐\n     │ Terminated │   │ Failed │\n     └────────────┘   └────────┘\n```\n\n## 配置参考\n\n### 服务器配置\n\n| 键 | 类型 | 默认值 | 描述 |\n|----|------|--------|------|\n| `server.host` | string | `\"0.0.0.0\"` | 绑定的网络接口 |\n| `server.port` | integer | `8080` | 监听端口 |\n| `server.log_level` | string | `\"INFO\"` | Python 日志级别 |\n| `server.api_key` | string | `null` | API 认证密钥 |\n| `server.eip` | string | `null` | 绑定的公网 IP；配置后，返回 sandbox endpoint 时作为地址的 host 部分（Docker 运行时） |\n\n### 运行时配置\n\n| 键                      | 类型     | 必需 | 描述                                 |\n|------------------------|--------|----|------------------------------------|\n| `runtime.type`         | string | 是  | 运行时实现（`\"docker\"` 或 `\"kubernetes\"`） |\n| `runtime.execd_image`  | string | 是  | 包含 execd 二进制文件的容器镜像                |\n\n### Egress 配置\n\n| 键 | 类型 | 默认值 | 使用 `networkPolicy` 时是否必填 | 说明 |\n|----|------|--------|--------------------------------|------|\n| `egress.image` | string | — | 是 | Egress 侧车镜像（OCI 引用）。 |\n| `egress.mode` | `dns` \\| `dns+nft` | `dns` | 否 | `OPENSANDBOX_EGRESS_MODE`。CIDR/IP 类规则需 `dns+nft`；`dns` 仅面向域名类策略。 |\n\n### Docker 配置\n\n| 键 | 类型 | 默认值 | 描述 |\n|----|------|--------|------|\n| `docker.network_mode` | string | `\"host\"` | 网络模式（`\"host\"` 或 `\"bridge\"`）|\n\n### Agent-sandbox 配置\n\n| 键 | 类型 | 默认值 | 描述 |\n|----|------|--------|------|\n| `agent_sandbox.template_file` | string | `null` | agent-sandbox 的 Sandbox CR YAML 模板路径（仅在 `kubernetes.workload_provider = \"agent-sandbox\"` 时使用） |\n| `agent_sandbox.shutdown_policy` | string | `\"Delete\"` | 过期时的关停策略（`\"Delete\"` 或 `\"Retain\"`） |\n| `agent_sandbox.ingress_enabled` | boolean | `true` | 是否启用 ingress 路由 |\n\n### 环境变量\n\n| 变量 | 描述 |\n|------|------|\n| `SANDBOX_CONFIG_PATH` | 覆盖配置文件位置 |\n| `DOCKER_HOST` | Docker 守护进程 URL（例如 `unix:///var/run/docker.sock`）|\n| `PENDING_FAILURE_TTL` | 失败的待处理沙箱的 TTL（秒，默认：3600）|\n\n## 开发\n\n### 代码质量\n\n**运行代码检查**：\n```bash\nuv run ruff check\n```\n\n**自动修复问题**：\n```bash\nuv run ruff check --fix\n```\n\n**格式化代码**：\n```bash\nuv run ruff format\n```\n\n### 测试\n\n**运行所有测试**：\n```bash\nuv run pytest\n```\n\n**带覆盖率运行**：\n```bash\nuv run pytest --cov=src --cov-report=html\n```\n\n**运行特定测试**：\n```bash\nuv run pytest tests/test_docker_service.py::test_create_sandbox_requires_entrypoint\n```\n\n## 许可证\n\n本项目遵循仓库根目录下的 LICENSE 文件条款。\n\n## 贡献\n\n欢迎提交改进，建议遵循以下流程：\n\n1. Fork 仓库\n2. 创建特性分支（`git checkout -b feature/amazing-feature`）\n3. 为新功能编写测试\n4. 确保所有测试通过（`uv run pytest`）\n5. 运行代码检查（`uv run ruff check`）\n6. 使用清晰的消息提交\n7. 推送到您的 fork\n8. 打开 Pull Request\n\n## 支持\n\n- 文档：参阅 `DEVELOPMENT.md` 获取开发指南\n- 问题报告：通过 GitHub Issues 报告缺陷\n- 讨论：在 GitHub Discussions 进行答疑与交流\n"
  },
  {
    "path": "server/TROUBLESHOOTING.md",
    "content": "# Troubleshooting\n\nEnglish | [中文](TROUBLESHOOTING_zh.md)\n\n## `exec /opt/opensandbox/bootstrap.sh: operation not permitted`\n\nIf sandbox logs show:\n\n```text\nexec /opt/opensandbox/bootstrap.sh: operation not permitted\n```\n\ncheck the following first:\n\n1. Verify the script exists and is executable inside the sandbox container:\n   ```bash\n   docker exec -it <sandbox-container> ls -l /opt/opensandbox/bootstrap.sh\n   ```\n2. Verify runtime security/mount constraints are not blocking execution (for example strict\n   confinement or `noexec` mount behavior in host/container runtime setup).\n3. If you are running Docker from Snap-based environments (for example Ubuntu Core), prefer\n   Docker CE package deployments for production OpenSandbox workloads, because strict runtime\n   confinement may block this bootstrap execution path in some setups.\n4. Re-run with the latest server and execd images to ensure you include the latest runtime fixes.\n\nIf this still reproduces, collect:\n- `docker info`\n- `docker logs opensandbox-server`\n- `docker logs <sandbox-container>`\n- your `config.toml` (mask secrets)\n\n## Sandbox health check timed out (e.g. on Alibaba Cloud ECS)\n\nIf the client reports:\n\n```text\nopensandbox.exceptions.sandbox.SandboxReadyTimeoutException: Sandbox health check timed out after 30.0s (2 attempts). Health check returned false continuously\n```\n\nwhen the server runs on a cloud VM (e.g. [Alibaba Cloud ECS](https://github.com/alibaba/OpenSandbox/issues/297)), the client is likely trying to reach the sandbox at an address it cannot access. The server may be returning a bind address such as `127.0.0.1` or an internal LAN IP in the endpoint URL, so the health check from the client side fails.\n\n**Solution:** Set the bound public IP so that the server returns a reachable address in the sandbox endpoint API. In your config (e.g. `~/.sandbox.toml`), under `[server]`, set `eip` to the VM’s public IP (or the hostname that clients use to reach the server):\n\n```toml\n[server]\nhost = \"0.0.0.0\"\nport = 8080\neip = \"47.x.x.x\"   # Your ECS public IP, or the hostname clients use to reach this server\n```\n\nAfter restarting the server, the get-endpoint API will use `eip` as the host part of the returned URL, so the client can reach the sandbox for the health check. This applies to the Docker runtime; the server skips resolving `host` when `eip` is set.\n"
  },
  {
    "path": "server/TROUBLESHOOTING_zh.md",
    "content": "# 故障排查\n\n[English](TROUBLESHOOTING.md) | 中文\n\n## `exec /opt/opensandbox/bootstrap.sh: operation not permitted`\n\n如果沙箱日志出现：\n\n```text\nexec /opt/opensandbox/bootstrap.sh: operation not permitted\n```\n\n建议先检查：\n\n1. 确认脚本在沙箱容器内存在且可执行：\n   ```bash\n   docker exec -it <sandbox-container> ls -l /opt/opensandbox/bootstrap.sh\n   ```\n2. 检查运行时安全策略和挂载约束是否阻止执行（例如严格沙箱约束或 `noexec` 挂载行为）。\n3. 如果使用 Snap 版本 Docker（如 Ubuntu Core 场景），生产环境建议优先使用 Docker CE 安装方式，因为部分严格约束环境会影响该 bootstrap 执行路径。\n4. 升级并复现：使用最新 server / execd 镜像确认是否已包含修复。\n\n如果仍可复现，建议附带以下信息提 issue：\n- `docker info`\n- `docker logs opensandbox-server`\n- `docker logs <sandbox-container>`\n- `config.toml`（注意脱敏）\n\n## 沙箱健康检查超时（如阿里云 ECS）\n\n若服务端部署在云主机（例如 [阿里云 ECS](https://github.com/alibaba/OpenSandbox/issues/297)），客户端创建沙箱时出现：\n\n```text\nopensandbox.exceptions.sandbox.SandboxReadyTimeoutException: Sandbox health check timed out after 30.0s (2 attempts). Health check returned false continuously\n```\n\n通常是因为服务端返回的 endpoint 地址（如 `127.0.0.1` 或内网 IP）对客户端不可达，客户端无法完成健康检查。\n\n**解决办法：** 配置绑定的公网 IP，让服务端在返回 sandbox endpoint 时使用客户端可访问的地址。在配置文件（如 `~/.sandbox.toml`）的 `[server]` 下设置 `eip` 为云主机的公网 IP（或客户端访问该服务时使用的主机名）：\n\n```toml\n[server]\nhost = \"0.0.0.0\"\nport = 8080\neip = \"47.x.x.x\"   # 你的 ECS 公网 IP，或客户端用来访问本机的主机名\n```\n\n重启服务后，获取 endpoint 的 API 会使用 `eip` 作为返回地址的 host 部分，客户端即可连通沙箱并通过健康检查。该行为针对 Docker 运行时；配置了 `eip` 后，服务端将不再根据 `host` 解析地址。\n"
  },
  {
    "path": "server/build.sh",
    "content": "#!/bin/bash\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -ex\n\nTAG=${TAG:-latest}\n\ndocker buildx rm server-builder || true\n\ndocker buildx create --use --name server-builder\n\ndocker buildx inspect --bootstrap\n\ndocker buildx ls\n\nLATEST_TAGS=()\nif [[ \"${TAG}\" == v* ]]; then\n  LATEST_TAGS+=(-t opensandbox/server:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/server:latest)\nfi\n\ndocker buildx build \\\n  -t opensandbox/server:${TAG} \\\n  -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/server:${TAG} \\\n  \"${LATEST_TAGS[@]}\" \\\n  --platform linux/amd64,linux/arm64 \\\n  --push \\\n  ."
  },
  {
    "path": "server/docker-compose.example.yaml",
    "content": "configs:\n  opensandbox-config:\n    content: |\n      [server]\n      host = \"0.0.0.0\"\n      port = 8090\n      log_level = \"INFO\"\n\n      [runtime]\n      type = \"docker\"\n      # execd_image = \"opensandbox/execd:v1.0.7\"\n      execd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n      [egress]\n      image = \"opensandbox/egress:v1.0.3\"\n      # image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3\"\n\n      [docker]\n      network_mode = \"bridge\"\n      # When server runs in a container, set host_ip to the host's IP or hostname so bridge-mode endpoints are reachable (e.g. host.docker.internal or the host LAN IP).\n      # It's required when server deployed with docker container under host.\n      host_ip = \"host.docker.internal\"\n      drop_capabilities = [\"AUDIT_WRITE\", \"MKNOD\", \"NET_ADMIN\", \"NET_RAW\", \"SYS_ADMIN\", \"SYS_MODULE\", \"SYS_PTRACE\", \"SYS_TIME\", \"SYS_TTY_CONFIG\"]\n      no_new_privileges = true\n      # TODO: For production environments, it is recommended to set this to '4096' or higher to avoid\n      # \"can't start new thread\" errors when multiple sandboxes are running concurrently.\n      # See: https://github.com/alibaba/OpenSandbox/issues/447\n      pids_limit = 4096\n\n      [ingress]\n      mode = \"direct\"\n\nversion: '3.8'\n\nservices:\n  opensandbox-server:\n    image: opensandbox/server:latest\n    container_name: opensandbox-server\n    networks:\n      - opensandbox-net\n    ports:\n      - \"8090:8090\"\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n    configs:\n      - source: opensandbox-config\n        target: /etc/opensandbox/config.toml\n    environment:\n      - SANDBOX_CONFIG_PATH=/etc/opensandbox/config.toml\n\n  sdk-client:\n    image: python:3.11-slim\n    container_name: sdk-client\n    networks:\n      - opensandbox-net\n    command: >\n      sh -c \"pip install opensandbox && tail -f /dev/null\"\n    environment:\n      - OPENSANDBOX_SERVER_URL=http://opensandbox-server:8090\n\nnetworks:\n  opensandbox-net:\n    driver: bridge"
  },
  {
    "path": "server/example.batchsandbox-template.yaml",
    "content": "# Example BatchSandbox CR template for OpenSandbox Kubernetes runtime\n# This is a complete BatchSandbox CR template that will be merged with runtime values\n#\n# Usage in config.toml:\n#   [kubernetes]\n#   batchsandbox_template_file = \"/path/to/this/file.yaml\"\n\n# Metadata template (will be merged with runtime-generated metadata)\nmetadata:\n# Spec template\nspec:\n  replicas: 1\n  # Pod template specification\n  template:\n    spec:\n      restartPolicy: Never\n      tolerations:\n        - operator: \"Exists\"\n"
  },
  {
    "path": "server/example.config.k8s.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Example Kubernetes Runtime Configuration for OpenSandbox Server\n#\n# This configuration file demonstrates how to configure the OpenSandbox server\n# to use Kubernetes as the sandbox runtime.\n#\n# Usage:\n#   1. Copy this file to ~/.sandbox.toml (or set SANDBOX_CONFIG_PATH environment variable)\n#   2. Update the configuration values according to your environment\n#   3. Start the server: uvicorn src.main:app --host 0.0.0.0 --port 8080\n\n[server]\nhost = \"0.0.0.0\"\nport = 8080\nlog_level = \"INFO\"\n# api_key = \"your-secret-api-key\"  # Optional: Uncomment to enable API key authentication\n\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[storage]\n# Volume and storage configuration\n# -----------------------------------------------------------------\n# Allowlist of host path prefixes permitted for bind mounts.\n# If empty, all host paths are allowed (not recommended for production).\n# Example: allowed_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\nallowed_host_paths = []\n\n[kubernetes]\n# Path to kubeconfig file. Leave as null to use in-cluster configuration\n# Replace with your path\nkubeconfig_path = \"~/.kube/config\"\n\n# Namespace for sandbox workloads\nnamespace = \"opensandbox\"\n\n# [Beta] Enable informer-backed cache to reduce API calls.\n# Set to false to disable the watch-based cache.\ninformer_enabled = true\ninformer_resync_seconds = 300\ninformer_watch_timeout_seconds = 60\n\n# Workload provider type: available providers are registered in the provider factory\n# If not specified, uses the first registered provider (typically \"batchsandbox\")\nworkload_provider = \"batchsandbox\"\n\n# Path to the BatchSandbox template file\n# Replace with your path\nbatchsandbox_template_file = \"~/batchsandbox-template.yaml\"\n\n[ingress]\n# Ingress exposure mode: direct (default) or gateway\nmode = \"direct\"\n\n[egress]\n# Egress configuration\n# -----------------------------------------------------------------\nimage = \"opensandbox/egress:v1.0.3\"\n# Enforcement: \"dns\" (DNS proxy only) or \"dns+nft\" (nftables + DNS).\nmode = \"dns\"\n"
  },
  {
    "path": "server/example.config.k8s.zh.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Example Kubernetes Runtime Configuration for OpenSandbox Server\n#\n# This configuration file demonstrates how to configure the OpenSandbox server\n# to use Kubernetes as the sandbox runtime.\n#\n# Usage:\n#   1. Copy this file to ~/.sandbox.toml (or set SANDBOX_CONFIG_PATH environment variable)\n#   2. Update the configuration values according to your environment\n#   3. Start the server: uvicorn src.main:app --host 0.0.0.0 --port 8080\n\n[server]\nhost = \"0.0.0.0\"\nport = 8080\nlog_level = \"INFO\"\n# api_key = \"your-secret-api-key\"  # Optional: Uncomment to enable API key authentication\n\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n[storage]\n# 卷存储配置\n# -----------------------------------------------------------------\n# 允许进行 bind mount 的宿主机路径前缀白名单。\n# 仅匹配这些前缀的路径才能被挂载到沙箱中。\n# 如果为空，则允许所有路径（不建议在生产环境使用）。\n# 示例：allowed_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\nallowed_host_paths = []\n\n[kubernetes]\n# Path to kubeconfig file. Leave as null to use in-cluster configuration\n# Replace with your path\nkubeconfig_path = \"~/.kube/config\"\n\n# Namespace for sandbox workloads\nnamespace = \"opensandbox\"\n\n# [Beta] 启用 informer 缓存以减少 API 调用。\n# 如需关闭 watch 缓存，将该项设为 false。\ninformer_enabled = true\ninformer_resync_seconds = 300\ninformer_watch_timeout_seconds = 60\n\n# Workload provider type: available providers are registered in the provider factory\n# If not specified, uses the first registered provider (typically \"batchsandbox\")\nworkload_provider = \"batchsandbox\"\n\n# Path to the BatchSandbox template file\n# Replace with your path\nbatchsandbox_template_file = \"~/batchsandbox-template.yaml\"\n\n[ingress]\n# Ingress exposure mode: direct (default) or gateway\nmode = \"direct\"\n\n[egress]\n# Egress configuration\n# -----------------------------------------------------------------\nimage = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3\"\n# Enforcement: \"dns\" (DNS proxy only) or \"dns+nft\" (nftables + DNS).\nmode = \"dns\"\n"
  },
  {
    "path": "server/example.config.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Example OpenSandbox configuration.\n# Copy this file to ~/.sandbox.toml or set SANDBOX_CONFIG_PATH to point at it.\n# Each top-level block mirrors the sections supported by src/config.py.\n\n[server]\n# Lifecycle API host/port and logging settings\n# -----------------------------------------------------------------\nhost = \"127.0.0.1\"\nport = 8080\nlog_level = \"INFO\"\n# api_key = \"your-secret-api-key\"  # Optional: Uncomment to enable API key authentication\n# eip = \"1.2.3.4\"  # Optional: External IP/hostname for endpoint URLs when returning sandbox endpoints\n# Maximum TTL for sandboxes that specify timeout. Comment out this line to disable the upper bound.\nmax_sandbox_timeout_seconds = 86400\n\n[runtime]\n# Runtime selection (docker | kubernetes)\n# -----------------------------------------------------------------\ntype = \"docker\"\nexecd_image = \"opensandbox/execd:v1.0.7\"\n\n[egress]\n# Egress configuration\n# -----------------------------------------------------------------\nimage = \"opensandbox/egress:v1.0.3\"\n# Enforcement: \"dns\" (DNS proxy only) or \"dns+nft\" (nftables + DNS).\nmode = \"dns\"\n\n[storage]\n# Volume and storage configuration\n# -----------------------------------------------------------------\n# Allowlist of host path prefixes permitted for bind mounts.\n# If empty, all host paths are allowed (not recommended for production).\n# Example: allowed_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\nallowed_host_paths = []\n\n[docker]\n# Docker-specific knobs\n# -----------------------------------------------------------------\n# Use bridge for network isolation\nnetwork_mode = \"bridge\"\n# Docker API timeout (seconds). If unset, default 180\n# api_timeout = 300\n# When server runs in a container, host IP/hostname for bridge-mode endpoints\n# host_ip = \"10.57.1.91\"\n# Drop dangerous capabilities and block privilege escalation\ndrop_capabilities = [\"AUDIT_WRITE\", \"MKNOD\", \"NET_ADMIN\", \"NET_RAW\", \"SYS_ADMIN\", \"SYS_MODULE\", \"SYS_PTRACE\", \"SYS_TIME\", \"SYS_TTY_CONFIG\"]\nno_new_privileges = true\n# Optional: set an AppArmor profile name (e.g., \"docker-default\") when AppArmor is enabled\napparmor_profile = \"\"\n# Limit process count to reduce host impact from fork bombs; set to null to disable\n# TODO: For production environments, it is recommended to set this to '4096' or higher to avoid\n# \"can't start new thread\" errors when multiple sandboxes are running concurrently.\n# See: https://github.com/alibaba/OpenSandbox/issues/447\npids_limit = 4096\n# Seccomp profile: empty string uses Docker default; set to an absolute path for a custom profile\nseccomp_profile = \"\"\n\n[ingress]\n# Ingress exposure mode: direct (default) or gateway\nmode = \"direct\"\n"
  },
  {
    "path": "server/example.config.zh.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Example OpenSandbox configuration.\n# Copy this file to ~/.sandbox.toml or set SANDBOX_CONFIG_PATH to point at it.\n# Each top-level block mirrors the sections supported by src/config.py.\n\n[server]\n# Lifecycle API host/port and logging settings\n# -----------------------------------------------------------------\nhost = \"127.0.0.1\"\nport = 8080\nlog_level = \"INFO\"\n# api_key = \"your-secret-api-key\"  # Optional: Uncomment to enable API key authentication\n\n[runtime]\n# Runtime selection (docker | kubernetes)\n# -----------------------------------------------------------------\ntype = \"docker\"\nexecd_image = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7\"\n\n[egress]\n# Egress configuration\n# -----------------------------------------------------------------\nimage = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3\"\n# Enforcement: \"dns\" (DNS proxy only) or \"dns+nft\" (nftables + DNS).\nmode = \"dns\"\n\n[storage]\n# 卷存储配置\n# -----------------------------------------------------------------\n# 允许进行 bind mount 的宿主机路径前缀白名单。\n# 仅匹配这些前缀的路径才能被挂载到沙箱中。\n# 如果为空，则允许所有路径（不建议在生产环境使用）。\n# 示例：allowed_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\nallowed_host_paths = []\n\n[docker]\n# Docker-specific knobs\n# -----------------------------------------------------------------\n# Supported values for network_mode: \"host\", \"bridge\"\nnetwork_mode = \"bridge\"\n# Drop dangerous capabilities and block privilege escalation\ndrop_capabilities = [\"AUDIT_WRITE\", \"MKNOD\", \"NET_ADMIN\", \"NET_RAW\", \"SYS_ADMIN\", \"SYS_MODULE\", \"SYS_PTRACE\", \"SYS_TIME\", \"SYS_TTY_CONFIG\"]\nno_new_privileges = true\n# Optional: set an AppArmor profile name (e.g., \"docker-default\") when AppArmor is enabled\napparmor_profile = \"\"\n# Limit process count to reduce host impact from fork bombs; set to null to disable\n# TODO: 生产环境建议设置为 4096 或更高，避免多沙箱并发时出现 \"can't start new thread\" 错误\n# See: https://github.com/alibaba/OpenSandbox/issues/447\npids_limit = 4096\n# Seccomp profile: empty string uses Docker default; set to an absolute path for a custom profile\nseccomp_profile = \"\"\n\n[ingress]\n# Ingress exposure mode: direct (default) or gateway\nmode = \"direct\"\n"
  },
  {
    "path": "server/pyproject.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[build-system]\nrequires = [\"hatchling\", \"hatch-vcs\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"opensandbox-server\"\ndynamic = [\"version\"]\ndescription = \"FastAPI control plane for OpenSandbox that manages sandbox lifecycle on Docker (ready) and Kubernetes (planned) runtimes.\"\nreadme = \"README.md\"\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"pangjiping.pjp@alibaba-inc.com\" }\n]\nlicense = { text = \"Apache-2.0\" }\nrequires-python = \">=3.10\"\nkeywords = [\"sandbox\", \"server\", \"control-plane\", \"fastapi\", \"opensandbox\"]\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: Apache Software License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Software Development :: Libraries\",\n    \"Typing :: Typed\",\n]\ndependencies = [\n    \"docker\",\n    \"fastapi\",\n    \"httpx[socks]\",\n    \"kubernetes\",\n    \"pydantic\",\n    \"pydantic-settings\",\n    \"pyyaml\",\n    \"tomli; python_version < \\\"3.11\\\"\",\n    \"uvicorn\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/alibaba/OpenSandbox\"\nRepository = \"https://github.com/alibaba/OpenSandbox\"\nIssues = \"https://github.com/alibaba/OpenSandbox/issues\"\n\n[project.scripts]\nopensandbox-server = \"src.cli:main\"\n\n[tool.hatch.version]\nsource = \"vcs\"\n\n[tool.hatch.version.raw-options]\n# This package is in a subdirectory; explicitly point setuptools-scm at the git root.\nroot = \"..\"\ntag_regex = \"^server/v(?P<version>\\\\d+\\\\.\\\\d+\\\\.\\\\d+(?:[\\\\.\\\\w\\\\+\\\\-]*)?)$\"\ngit_describe_command = 'git describe --dirty --tags --long --match \"server/v*\"'\nfallback_version = \"0.1.0.dev0\"\n\n[tool.hatch.build]\ninclude = [\n    \"LICENSE\",\n    \"example.config.toml\",\n    \"example.config.zh.toml\",\n    \"example.config.k8s.toml\",\n    \"example.config.k8s.zh.toml\",\n    \"example.batchsandbox-template.yaml\",\n    \"src/**/py.typed\",\n    \"src\",\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.hatch.build.targets.wheel.force-include]\n\"example.config.toml\" = \"example.config.toml\"\n\"example.config.zh.toml\" = \"example.config.zh.toml\"\n\"example.config.k8s.toml\" = \"example.config.k8s.toml\"\n\"example.config.k8s.zh.toml\" = \"example.config.k8s.zh.toml\"\n\"example.batchsandbox-template.yaml\" = \"example.batchsandbox-template.yaml\"\n\n[dependency-groups]\ndev = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"pytest-cov>=4.0.0\",\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.0\",\n]\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 100\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint]\nselect = [\"E4\", \"E7\", \"E9\", \"F\"]\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\npythonVersion = \"3.10\"\npythonPlatform = \"All\"\n\ninclude = [\"src\", \"tests\"]\n\nexclude = [\n    \"**/node_modules\",\n    \"**/__pycache__\",\n]\n\nvenvPath = \".\"\nvenv = \".venv\"\n\nreportMissingImports = true\nreportMissingTypeStubs = false\n"
  },
  {
    "path": "server/src/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\ndef hello() -> str:\n    return \"Hello from sandbox-server!\"\n"
  },
  {
    "path": "server/src/api/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n"
  },
  {
    "path": "server/src/api/lifecycle.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAPI routes for OpenSandbox Lifecycle API.\n\nThis module defines FastAPI routes that map to the OpenAPI specification endpoints.\nAll business logic is delegated to the service layer that backs each operation.\n\"\"\"\n\nfrom typing import List, Optional\n\nimport httpx\nfrom fastapi import APIRouter, Header, Query, Request, status\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.responses import Response, StreamingResponse\n\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    CreateSandboxResponse,\n    Endpoint,\n    ErrorResponse,\n    ListSandboxesRequest,\n    ListSandboxesResponse,\n    PaginationRequest,\n    RenewSandboxExpirationRequest,\n    RenewSandboxExpirationResponse,\n    Sandbox,\n    SandboxFilter,\n)\nfrom src.services.factory import create_sandbox_service\n\n# RFC 2616 Section 13.5.1\nHOP_BY_HOP_HEADERS = {\n    \"connection\",\n    \"keep-alive\",\n    \"proxy-authenticate\",\n    \"proxy-authorization\",\n    \"te\",\n    \"trailer\",\n    \"transfer-encoding\",\n    \"upgrade\",\n}\n\n# Headers that shouldn't be forwarded to untrusted/internal backends\nSENSITIVE_HEADERS = {\n    \"authorization\",\n    \"cookie\",\n}\n\n# Initialize router\nrouter = APIRouter(tags=[\"Sandboxes\"])\n\n# Initialize service based on configuration from config.toml (defaults to docker)\nsandbox_service = create_sandbox_service()\n\n\n# ============================================================================\n# Sandbox CRUD Operations\n# ============================================================================\n\n@router.post(\n    \"/sandboxes\",\n    response_model=CreateSandboxResponse,\n    status_code=status.HTTP_202_ACCEPTED,\n    responses={\n        202: {\"description\": \"Sandbox creation accepted for asynchronous provisioning\"},\n        400: {\"model\": ErrorResponse, \"description\": \"The request was invalid or malformed\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        409: {\"model\": ErrorResponse, \"description\": \"The operation conflicts with the current state\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def create_sandbox(\n    request: CreateSandboxRequest,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> CreateSandboxResponse:\n    \"\"\"\n    Create a sandbox from a container image.\n\n    Creates a new sandbox from a container image with optional resource limits,\n    environment variables, and metadata. Sandboxes are provisioned directly from\n    the specified image without requiring a pre-created template.\n\n    Args:\n        request: Sandbox creation request\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        CreateSandboxResponse: Accepted sandbox creation request\n\n    Raises:\n        HTTPException: If sandbox creation scheduling fails\n    \"\"\"\n\n    return await sandbox_service.create_sandbox(request)\n\n\n# Search endpoint\n@router.get(\n    \"/sandboxes\",\n    response_model=ListSandboxesResponse,\n    responses={\n        200: {\"description\": \"Paginated collection of sandboxes\"},\n        400: {\"model\": ErrorResponse, \"description\": \"The request was invalid or malformed\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def list_sandboxes(\n    state: Optional[List[str]] = Query(None, description=\"Filter by lifecycle state. Pass multiple times for OR logic.\"),\n    metadata: Optional[str] = Query(None, description=\"Arbitrary metadata key-value pairs for filtering (URL encoded).\"),\n    page: int = Query(1, ge=1, description=\"Page number for pagination\"),\n    page_size: int = Query(20, ge=1, le=200, alias=\"pageSize\", description=\"Number of items per page\"),\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> ListSandboxesResponse:\n    \"\"\"\n    List sandboxes with optional filtering and pagination.\n\n    List all sandboxes with optional filtering and pagination using query parameters.\n    All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n\n    Args:\n        state: Filter by lifecycle state.\n        metadata: Arbitrary metadata key-value pairs for filtering.\n        page: Page number for pagination.\n        page_size: Number of items per page.\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        ListSandboxesResponse: Paginated list of sandboxes\n    \"\"\"\n    # Parse metadata query string into dictionary\n    metadata_dict = {}\n    if metadata:\n        from urllib.parse import parse_qsl\n        try:\n            # Parse query string format: key=value&key2=value2\n            # strict_parsing=True rejects malformed segments like \"a=1&broken\"\n            parsed = parse_qsl(metadata, keep_blank_values=True, strict_parsing=True)\n            metadata_dict = dict(parsed)\n        except Exception as e:\n            from fastapi import HTTPException\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\"code\": \"INVALID_METADATA_FORMAT\", \"message\": f\"Invalid metadata format: {str(e)}\"}\n            )\n\n    # Construct request object\n    request = ListSandboxesRequest(\n        filter=SandboxFilter(state=state, metadata=metadata_dict if metadata_dict else None),\n        pagination=PaginationRequest(page=page, pageSize=page_size)\n    )\n\n    import logging\n    logger = logging.getLogger(__name__)\n    logger.info(\"ListSandboxes: %s\", request.filter)\n\n    # Delegate to the service layer for filtering and pagination\n    return sandbox_service.list_sandboxes(request)\n\n\n@router.get(\n    \"/sandboxes/{sandbox_id}\",\n    response_model=Sandbox,\n    responses={\n        200: {\"description\": \"Sandbox current state and metadata\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def get_sandbox(\n    sandbox_id: str,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> Sandbox:\n    \"\"\"\n    Fetch a sandbox by id.\n\n    Returns the complete sandbox information including image specification,\n    status, metadata, and timestamps.\n\n    Args:\n        sandbox_id: Unique sandbox identifier\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        Sandbox: Complete sandbox information\n\n    Raises:\n        HTTPException: If sandbox not found or access denied\n    \"\"\"\n    # Delegate to the service layer for sandbox lookup\n    return sandbox_service.get_sandbox(sandbox_id)\n\n\n@router.delete(\n    \"/sandboxes/{sandbox_id}\",\n    status_code=status.HTTP_204_NO_CONTENT,\n    responses={\n        204: {\"description\": \"Sandbox successfully deleted\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        409: {\"model\": ErrorResponse, \"description\": \"The operation conflicts with the current state\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def delete_sandbox(\n    sandbox_id: str,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> Response:\n    \"\"\"\n    Delete a sandbox.\n\n    Terminates sandbox execution. The sandbox will transition through Stopping state to Terminated.\n\n    Args:\n        sandbox_id: Unique sandbox identifier\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        Response: 204 No Content\n\n    Raises:\n        HTTPException: If sandbox not found or deletion fails\n    \"\"\"\n    # Delegate to the service layer for deletion\n    sandbox_service.delete_sandbox(sandbox_id)\n    return Response(status_code=status.HTTP_204_NO_CONTENT)\n\n\n# ============================================================================\n# Sandbox Lifecycle Operations\n# ============================================================================\n\n@router.post(\n    \"/sandboxes/{sandbox_id}/pause\",\n    status_code=status.HTTP_202_ACCEPTED,\n    responses={\n        202: {\"description\": \"Pause operation accepted\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        409: {\"model\": ErrorResponse, \"description\": \"The operation conflicts with the current state\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def pause_sandbox(\n    sandbox_id: str,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> Response:\n    \"\"\"\n    Pause execution while retaining state.\n\n    Pauses a running sandbox while preserving its state.\n    Poll GET /sandboxes/{sandboxId} to track state transition to Paused.\n\n    Args:\n        sandbox_id: Unique sandbox identifier\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        Response: 202 Accepted\n\n    Raises:\n        HTTPException: If sandbox not found or cannot be paused\n    \"\"\"\n    # Delegate to the service layer for pause orchestration\n    sandbox_service.pause_sandbox(sandbox_id)\n    return Response(status_code=status.HTTP_202_ACCEPTED)\n\n\n@router.post(\n    \"/sandboxes/{sandbox_id}/resume\",\n    status_code=status.HTTP_202_ACCEPTED,\n    responses={\n        202: {\"description\": \"Resume operation accepted\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        409: {\"model\": ErrorResponse, \"description\": \"The operation conflicts with the current state\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def resume_sandbox(\n    sandbox_id: str,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> Response:\n    \"\"\"\n    Resume a paused sandbox.\n\n    Resumes execution of a paused sandbox.\n    Poll GET /sandboxes/{sandboxId} to track state transition to Running.\n\n    Args:\n        sandbox_id: Unique sandbox identifier\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        Response: 202 Accepted\n\n    Raises:\n        HTTPException: If sandbox not found or cannot be resumed\n    \"\"\"\n    # Delegate to the service layer for resume orchestration\n    sandbox_service.resume_sandbox(sandbox_id)\n    return Response(status_code=status.HTTP_202_ACCEPTED)\n\n\n@router.post(\n    \"/sandboxes/{sandbox_id}/renew-expiration\",\n    response_model=RenewSandboxExpirationResponse,\n    response_model_exclude_none=True,\n    responses={\n        200: {\"description\": \"Sandbox expiration updated successfully\"},\n        400: {\"model\": ErrorResponse, \"description\": \"The request was invalid or malformed\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        409: {\"model\": ErrorResponse, \"description\": \"The operation conflicts with the current state\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def renew_sandbox_expiration(\n    sandbox_id: str,\n    request: RenewSandboxExpirationRequest,\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> RenewSandboxExpirationResponse:\n    \"\"\"\n    Renew sandbox expiration.\n\n    Renews the absolute expiration time of a sandbox.\n    The new expiration time must be in the future and after the current expiresAt time.\n\n    Args:\n        sandbox_id: Unique sandbox identifier\n        request: Renewal request with new expiration time\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        RenewSandboxExpirationResponse: Updated expiration time\n\n    Raises:\n        HTTPException: If sandbox not found or renewal fails\n    \"\"\"\n    # Delegate to the service layer for expiration updates\n    return sandbox_service.renew_expiration(sandbox_id, request)\n\n\n# ============================================================================\n# Sandbox Endpoints\n# ============================================================================\n\n@router.get(\n    \"/sandboxes/{sandbox_id}/endpoints/{port}\",\n    response_model=Endpoint,\n    response_model_exclude_none=True,\n    responses={\n        200: {\"description\": \"Endpoint retrieved successfully\"},\n        401: {\"model\": ErrorResponse, \"description\": \"Authentication credentials are missing or invalid\"},\n        403: {\"model\": ErrorResponse, \"description\": \"The authenticated user lacks permission for this operation\"},\n        404: {\"model\": ErrorResponse, \"description\": \"The requested resource does not exist\"},\n        500: {\"model\": ErrorResponse, \"description\": \"An unexpected server error occurred\"},\n    },\n)\nasync def get_sandbox_endpoint(\n    request: Request,\n    sandbox_id: str,\n    port: int,\n    use_server_proxy: bool = Query(False, description=\"Whether to return a server-proxied URL\"),\n    x_request_id: Optional[str] = Header(None, alias=\"X-Request-ID\", description=\"Unique request identifier for tracing\"),\n) -> Endpoint:\n    \"\"\"\n    Get sandbox access endpoint.\n\n    Returns the public access endpoint URL for accessing a service running on a specific port\n    within the sandbox. The service must be listening on the specified port inside the sandbox\n    for the endpoint to be available.\n\n    Args:\n        request: FastAPI request object\n        sandbox_id: Unique sandbox identifier\n        port: Port number where the service is listening inside the sandbox (1-65535)\n        use_server_proxy: Whether to return a server-proxied URL\n        x_request_id: Unique request identifier for tracing (optional; server generates if omitted).\n\n    Returns:\n        Endpoint: Public endpoint URL\n\n    Raises:\n        HTTPException: If sandbox not found or endpoint not available\n    \"\"\"\n    # Delegate to the service layer for endpoint resolution\n    endpoint = sandbox_service.get_endpoint(sandbox_id, port)\n\n    if use_server_proxy:\n        # Construct proxy URL\n        base_url = str(request.base_url).rstrip(\"/\")\n        base_url = base_url.replace(\"https://\", \"\").replace(\"http://\", \"\")\n        endpoint.endpoint = f\"{base_url}/sandboxes/{sandbox_id}/proxy/{port}\"\n\n    return endpoint\n\n\n@router.api_route(\n    \"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}\",\n    methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\"],\n)\nasync def proxy_sandbox_endpoint_request(request: Request, sandbox_id: str, port: int, full_path: str):\n    \"\"\"\n    Receives all incoming requests, determines the target sandbox from path parameter,\n    and asynchronously proxies the request to it.\n    \"\"\"\n\n    endpoint = sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)\n\n    target_host = endpoint.endpoint\n    query_string = request.url.query\n\n    client: httpx.AsyncClient = request.app.state.http_client\n\n    try:\n        upgrade_header = request.headers.get(\"Upgrade\", \"\")\n        if upgrade_header.lower() == \"websocket\":\n            raise HTTPException(status_code=400, detail=\"Websocket upgrade is not supported yet\")\n\n        # Filter headers\n        hop_by_hop = set(HOP_BY_HOP_HEADERS)\n        connection_header = request.headers.get(\"connection\")\n        if connection_header:\n            hop_by_hop.update(\n                header.strip().lower()\n                for header in connection_header.split(\",\")\n                if header.strip()\n            )\n        headers = {}\n        for key, value in request.headers.items():\n            key_lower = key.lower()\n            if (\n                key_lower != \"host\"\n                and key_lower not in hop_by_hop\n                and key_lower not in SENSITIVE_HEADERS\n            ):\n                headers[key] = value\n\n        req = client.build_request(\n            method=request.method,\n            url=f\"http://{target_host}/{full_path}\",\n            params=query_string if query_string else None,\n            headers=headers,\n            content=request.stream() if request.method in (\"POST\", \"PUT\", \"PATCH\", \"DELETE\") else None,\n        )\n\n        resp = await client.send(req, stream=True)\n\n        hop_by_hop = set(HOP_BY_HOP_HEADERS)\n        connection_header = resp.headers.get(\"connection\")\n        if connection_header:\n            hop_by_hop.update(\n                header.strip().lower()\n                for header in connection_header.split(\",\")\n                if header.strip()\n            )\n        response_headers = {\n            key: value\n            for key, value in resp.headers.items()\n            if key.lower() not in hop_by_hop\n        }\n\n        return StreamingResponse(\n            content=resp.aiter_bytes(),\n            status_code=resp.status_code,\n            headers=response_headers,\n        )\n    except httpx.ConnectError as e:\n        raise HTTPException(\n            status_code=502,\n            detail=f\"Could not connect to the backend sandbox {endpoint}: {e}\",\n        )\n    except HTTPException:\n        # Preserve explicit HTTP exceptions raised above (e.g. websocket upgrade not supported).\n        raise\n    except Exception as e:\n        raise HTTPException(\n            status_code=500, detail=f\"An internal error occurred in the proxy: {e}\"\n        )\n"
  },
  {
    "path": "server/src/api/schema.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nPydantic schemas for OpenSandbox Lifecycle API.\n\nThis module defines data models based on the OpenAPI specification\nfor request/response validation and serialization.\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Dict, List, Literal, Optional\n\nfrom pydantic import BaseModel, Field, RootModel, model_validator\n\n\n# ============================================================================\n# Image Specification\n# ============================================================================\n\nclass ImageAuth(BaseModel):\n    \"\"\"\n    Registry authentication credentials for private container registries.\n    \"\"\"\n    username: str = Field(..., description=\"Registry username or service account\")\n    password: str = Field(..., description=\"Registry password or authentication token\")\n\n\nclass ImageSpec(BaseModel):\n    \"\"\"\n    Container image specification for sandbox provisioning.\n\n    Supports public registry images and private registry images with authentication.\n    \"\"\"\n    uri: str = Field(\n        ...,\n        description=\"Container image URI in standard format (e.g., 'python:3.11', 'gcr.io/my-project/app:v1.0')\",\n    )\n    auth: Optional[ImageAuth] = Field(\n        None,\n        description=\"Registry authentication credentials (required for private registries)\",\n    )\n\n\n# ============================================================================\n# Resource Limits\n# ============================================================================\n\nclass ResourceLimits(RootModel[Dict[str, str]]):\n    \"\"\"\n    Runtime resource constraints as key-value pairs.\n\n    Similar to Kubernetes resource specifications, allows flexible definition\n    of resource limits. Common resource types include cpu, memory, and gpu.\n    \"\"\"\n    root: Dict[str, str] = Field(\n        default_factory=dict,\n        example={\"cpu\": \"500m\", \"memory\": \"512Mi\", \"gpu\": \"1\"},\n    )\n\n\nclass NetworkRule(BaseModel):\n    \"\"\"\n    Egress rule: allow/deny a specific domain or wildcard.\n    \"\"\"\n\n    action: str = Field(..., description=\"Whether to allow or deny matching targets (allow | deny).\")\n    target: str = Field(\n        ...,\n        description=\"FQDN or wildcard domain (e.g., 'example.com', '*.example.com').\",\n        min_length=1,\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass NetworkPolicy(BaseModel):\n    \"\"\"\n    Egress network policy matching the sidecar /policy payload.\n    \"\"\"\n\n    default_action: Optional[str] = Field(\n        default=None,\n        alias=\"defaultAction\",\n        description=\"Default action when no egress rule matches (allow | deny). If omitted, sidecar defaults to deny.\",\n    )\n    egress: list[NetworkRule] = Field(\n        default_factory=list,\n        description=\"Ordered egress rules. Empty/omitted yields allow-all at startup.\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\n# ============================================================================\n# Volume Definitions\n# ============================================================================\n\n\nclass Host(BaseModel):\n    \"\"\"\n    Host path bind mount backend.\n\n    Maps a directory on the host filesystem into the container.\n    Only available when the runtime supports host mounts.\n\n    Security note: Host paths are restricted by server-side allowlist.\n    Users must specify paths under permitted prefixes.\n    \"\"\"\n\n    path: str = Field(\n        ...,\n        description=\"Absolute path on the host filesystem to mount.\",\n        pattern=r\"^(/|[A-Za-z]:[\\\\/])\",\n    )\n\n\nclass PVC(BaseModel):\n    \"\"\"\n    Platform-managed named volume backend.\n\n    A runtime-neutral abstraction for referencing a pre-existing, platform-managed\n    named volume. The semantics are identical across runtimes: claim an existing\n    volume by name, mount it into the container, and leave volume lifecycle\n    management to the user.\n\n    - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.\n    - Docker: maps to a Docker named volume (created via ``docker volume create``).\n    \"\"\"\n\n    claim_name: str = Field(\n        ...,\n        alias=\"claimName\",\n        description=(\n            \"Name of the volume on the target platform. \"\n            \"In Kubernetes this is the PVC name; in Docker this is the named volume name.\"\n        ),\n        pattern=r\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\",\n        max_length=253,\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass OSSFS(BaseModel):\n    \"\"\"\n    Alibaba Cloud OSS mount backend via ossfs.\n\n    The runtime mounts a host-side OSS path under ``storage.ossfs_mount_root``\n    and then bind-mounts the resolved path into the sandbox container. Prefix\n    selection is expressed via ``Volume.subPath``.\n    In Docker runtime, OSSFS backend requires the server host to be Linux with FUSE support.\n    \"\"\"\n\n    bucket: str = Field(\n        ...,\n        description=\"OSS bucket name.\",\n        min_length=3,\n        max_length=63,\n    )\n    endpoint: str = Field(\n        ...,\n        description=\"OSS endpoint, e.g. 'oss-cn-hangzhou.aliyuncs.com'.\",\n        min_length=1,\n    )\n    version: Literal[\"1.0\", \"2.0\"] = Field(\n        \"2.0\",\n        description=\"ossfs major version used by runtime mount integration.\",\n    )\n    options: Optional[List[str]] = Field(\n        None,\n        description=(\n            \"Additional ossfs mount options. Runtime encodes options by version: \"\n            \"1.0 => 'ossfs ... -o <option>', 2.0 => 'ossfs2 config line --<option>'. \"\n            \"Provide raw option payloads without leading '-'.\"\n        ),\n    )\n    access_key_id: Optional[str] = Field(\n        None,\n        alias=\"accessKeyId\",\n        description=\"OSS access key ID for inline credentials mode.\",\n        min_length=1,\n    )\n    access_key_secret: Optional[str] = Field(\n        None,\n        alias=\"accessKeySecret\",\n        description=\"OSS access key secret for inline credentials mode.\",\n        min_length=1,\n    )\n    class Config:\n        populate_by_name = True\n\n    @model_validator(mode=\"after\")\n    def validate_inline_credentials(self) -> \"OSSFS\":\n        \"\"\"Ensure inline credentials are provided for current OSSFS mode.\"\"\"\n        if not self.access_key_id or not self.access_key_secret:\n            raise ValueError(\n                \"OSSFS inline credentials are required: accessKeyId and accessKeySecret.\"\n            )\n        return self\n\n\nclass Volume(BaseModel):\n    \"\"\"\n    Storage mount definition for a sandbox.\n\n    Each volume entry contains:\n    - A unique name identifier\n    - Exactly one backend struct (host, pvc, etc.) with backend-specific fields\n    - Common mount settings (mountPath, readOnly, subPath)\n    \"\"\"\n\n    name: str = Field(\n        ...,\n        description=\"Unique identifier for the volume within the sandbox.\",\n        pattern=r\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\",\n        max_length=63,\n    )\n    host: Optional[Host] = Field(\n        None,\n        description=\"Host path bind mount backend.\",\n    )\n    pvc: Optional[PVC] = Field(\n        None,\n        description=\"Platform-managed named volume backend (PVC in Kubernetes, named volume in Docker).\",\n    )\n    ossfs: Optional[OSSFS] = Field(\n        None,\n        description=\"OSSFS mount backend.\",\n    )\n    mount_path: str = Field(\n        ...,\n        alias=\"mountPath\",\n        description=\"Absolute path inside the container where the volume is mounted.\",\n        pattern=r\"^/.*\",\n    )\n    read_only: bool = Field(\n        False,\n        alias=\"readOnly\",\n        description=\"If true, the volume is mounted as read-only. Defaults to false (read-write).\",\n    )\n    sub_path: Optional[str] = Field(\n        None,\n        alias=\"subPath\",\n        description=\"Optional subdirectory under the backend path to mount.\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n    @model_validator(mode=\"after\")\n    def validate_exactly_one_backend(self) -> \"Volume\":\n        \"\"\"Ensure exactly one backend type is specified.\"\"\"\n        backends = [self.host, self.pvc, self.ossfs]\n        specified = [b for b in backends if b is not None]\n        if len(specified) == 0:\n            raise ValueError(\"Exactly one backend (host, pvc, ossfs) must be specified, but none was provided.\")\n        if len(specified) > 1:\n            raise ValueError(\"Exactly one backend (host, pvc, ossfs) must be specified, but multiple were provided.\")\n        return self\n\n\n# ============================================================================\n# Sandbox Status\n# ============================================================================\n\nclass SandboxStatus(BaseModel):\n    \"\"\"\n    Detailed status information with lifecycle state and transition details.\n    \"\"\"\n    state: str = Field(\n        ...,\n        description=\"Current lifecycle state (Pending, Running, Pausing, Paused, Stopping, Terminated, Failed)\",\n    )\n    reason: Optional[str] = Field(\n        None,\n        description=\"Short machine-readable reason code for the current state\",\n    )\n    message: Optional[str] = Field(\n        None,\n        description=\"Human-readable message describing the current state or reason for state transition\",\n    )\n    last_transition_at: Optional[datetime] = Field(\n        None,\n        alias=\"lastTransitionAt\",\n        description=\"Timestamp of the last state transition\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\n# ============================================================================\n# Sandbox Models\n# ============================================================================\n\nclass CreateSandboxRequest(BaseModel):\n    \"\"\"\n    Request to create a new sandbox from a container image.\n    \"\"\"\n    image: ImageSpec = Field(..., description=\"Container image specification for the sandbox\")\n    timeout: Optional[int] = Field(\n        None,\n        ge=60,\n        description=(\n            \"Sandbox timeout in seconds (minimum 60). \"\n            \"The maximum is controlled by server.max_sandbox_timeout_seconds. \"\n            \"When omitted or null, the sandbox will not auto-terminate and must be deleted explicitly. \"\n            \"Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject \"\n            \"null timeout when the workload provider does not support non-expiring sandboxes.\"\n        ),\n    )\n    resource_limits: ResourceLimits = Field(\n        ...,\n        alias=\"resourceLimits\",\n        description=\"Runtime resource constraints for the sandbox instance\",\n    )\n    env: Optional[Dict[str, Optional[str]]] = Field(\n        None,\n        description=\"Environment variables to inject into the sandbox runtime\",\n    )\n    metadata: Optional[Dict[str, str]] = Field(\n        None,\n        description=\"Custom key-value metadata for management, filtering, and tagging\",\n    )\n    entrypoint: List[str] = Field(\n        ...,\n        min_length=1,\n        description=\"The command to execute as the sandbox's entry process\",\n        example=[\"python\", \"/app/main.py\"],\n    )\n    network_policy: Optional[NetworkPolicy] = Field(\n        None,\n        alias=\"networkPolicy\",\n        description=(\n            \"Optional outbound network policy. Shape matches the egress sidecar /policy endpoint. \"\n            \"Empty/omitted means allow-all until updated.\"\n        ),\n    )\n    volumes: Optional[List[Volume]] = Field(\n        None,\n        description=(\n            \"Storage mounts for the sandbox. Each volume entry specifies a named backend-specific \"\n            \"storage source and common mount settings. Exactly one backend type must be specified per volume entry.\"\n        ),\n    )\n    extensions: Optional[Dict[str, str]] = Field(\n        None,\n        description=\"Opaque container for provider-specific or transient parameters not covered by the core API\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass CreateSandboxResponse(BaseModel):\n    \"\"\"\n    Response from creating a new sandbox.\n\n    Contains essential information without image and updatedAt.\n    \"\"\"\n    id: str = Field(..., description=\"Unique sandbox identifier\")\n    status: SandboxStatus = Field(..., description=\"Current lifecycle status and detailed state information\")\n    metadata: Optional[Dict[str, str]] = Field(None, description=\"Custom metadata from creation request\")\n    expires_at: Optional[datetime] = Field(\n        None,\n        alias=\"expiresAt\",\n        description=\"Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.\",\n    )\n    created_at: datetime = Field(..., alias=\"createdAt\", description=\"Sandbox creation timestamp\")\n    entrypoint: List[str] = Field(..., description=\"Entry process specification from creation request\")\n\n    class Config:\n        populate_by_name = True\n\n\nclass Sandbox(BaseModel):\n    \"\"\"\n    Runtime execution environment provisioned from a container image.\n\n    This is the complete representation of the sandbox resource.\n    \"\"\"\n    id: str = Field(..., description=\"Unique sandbox identifier\")\n    image: ImageSpec = Field(..., description=\"Container image specification used to provision this sandbox\")\n    status: SandboxStatus = Field(..., description=\"Current lifecycle status and detailed state information\")\n    metadata: Optional[Dict[str, str]] = Field(None, description=\"Custom metadata from creation request\")\n    entrypoint: List[str] = Field(..., description=\"The command to execute as the sandbox's entry process\")\n    expires_at: Optional[datetime] = Field(\n        None,\n        alias=\"expiresAt\",\n        description=\"Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.\",\n    )\n    created_at: datetime = Field(..., alias=\"createdAt\", description=\"Sandbox creation timestamp\")\n\n    class Config:\n        populate_by_name = True\n\n\n# ============================================================================\n# List Sandboxes\n# ============================================================================\n\nclass SandboxFilter(BaseModel):\n    \"\"\"\n    Filtering criteria for listing sandboxes.\n    \"\"\"\n    state: Optional[List[str]] = Field(\n        None,\n        min_length=1,\n        description=\"Filter by lifecycle state (status.state) - supports OR logic\",\n    )\n    metadata: Optional[Dict[str, str]] = Field(\n        None,\n        description=\"Filter by metadata key-value pairs (AND logic)\",\n    )\n\n\nclass PaginationRequest(BaseModel):\n    \"\"\"\n    Pagination parameters for list requests.\n    \"\"\"\n    page: int = Field(1, ge=1, description=\"Page number\")\n    page_size: int = Field(\n        20,\n        ge=1,\n        le=200,\n        alias=\"pageSize\",\n        description=\"Number of items per page\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass ListSandboxesRequest(BaseModel):\n    \"\"\"\n    Request body for complex listing queries.\n    \"\"\"\n    filter: SandboxFilter = Field(\n        default_factory=SandboxFilter,\n        description=\"Filtering criteria (all conditions combined with AND logic)\",\n    )\n    pagination: Optional[PaginationRequest] = Field(None, description=\"Pagination parameters\")\n\n\nclass PaginationInfo(BaseModel):\n    \"\"\"\n    Pagination metadata for list responses.\n    \"\"\"\n    page: int = Field(..., ge=1, description=\"Current page number\")\n    page_size: int = Field(..., ge=1, alias=\"pageSize\", description=\"Number of items per page\")\n    total_items: int = Field(..., ge=0, alias=\"totalItems\", description=\"Total number of items matching the filter\")\n    total_pages: int = Field(..., ge=0, alias=\"totalPages\", description=\"Total number of pages\")\n    has_next_page: bool = Field(..., alias=\"hasNextPage\", description=\"Whether there are more pages after the current one\")\n\n    class Config:\n        populate_by_name = True\n\n\nclass ListSandboxesResponse(BaseModel):\n    \"\"\"\n    Paginated collection of sandboxes.\n    \"\"\"\n    items: List[Sandbox] = Field(..., description=\"List of sandboxes\")\n    pagination: PaginationInfo = Field(..., description=\"Pagination metadata\")\n\n\n# ============================================================================\n# Renew Expiration\n# ============================================================================\n\nclass RenewSandboxExpirationRequest(BaseModel):\n    \"\"\"\n    Request to renew sandbox expiration time.\n    \"\"\"\n    expires_at: datetime = Field(\n        ...,\n        alias=\"expiresAt\",\n        description=\"New absolute expiration time in UTC (RFC 3339 format). Must be in the future.\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass RenewSandboxExpirationResponse(BaseModel):\n    \"\"\"\n    Response for renewing sandbox expiration.\n    \"\"\"\n    expires_at: datetime = Field(\n        ...,\n        alias=\"expiresAt\",\n        description=\"The new absolute expiration time in UTC (RFC 3339 format)\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\n# ============================================================================\n# Endpoint\n# ============================================================================\n\nclass Endpoint(BaseModel):\n    \"\"\"\n    Endpoint for accessing a service running in the sandbox.\n    \"\"\"\n    endpoint: str = Field(\n        ...,\n        description=\"Public endpoint string (host[:port]/path) exposed for the sandbox service\",\n    )\n    headers: Optional[dict[str, str]] = Field(\n        default=None,\n        description=\"Optional headers required when accessing the endpoint (e.g., for header-based routing).\",\n    )\n\n\n# ============================================================================\n# Error Response\n# ============================================================================\n\nclass ErrorResponse(BaseModel):\n    \"\"\"\n    Standard error response for all non-2xx HTTP responses.\n\n    HTTP status code indicates the error category; code and message provide details.\n    \"\"\"\n    code: str = Field(\n        ...,\n        description=\"Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR)\",\n    )\n    message: str = Field(\n        ...,\n        description=\"Human-readable error message describing what went wrong and how to fix it\",\n    )\n"
  },
  {
    "path": "server/src/cli.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom __future__ import annotations\n\nimport argparse\nimport os\nimport shutil\nfrom pathlib import Path\n\nimport uvicorn\n\nfrom src.config import (\n    AgentSandboxRuntimeConfig,\n    CONFIG_ENV_VAR,\n    DEFAULT_CONFIG_PATH,\n    DockerConfig,\n    EgressConfig,\n    IngressConfig,\n    KubernetesRuntimeConfig,\n    RuntimeConfig,\n    ServerConfig,\n    StorageConfig,\n)\n\nEXAMPLE_FILE_MAP = {\n    \"docker\": \"example.config.toml\",\n    \"docker-zh\": \"example.config.zh.toml\",\n    \"k8s\": \"example.config.k8s.toml\",\n    \"k8s-zh\": \"example.config.k8s.zh.toml\",\n}\n\n\ndef _build_parser() -> argparse.ArgumentParser:\n    parser = argparse.ArgumentParser(\n        description=\"Run the OpenSandbox server.\",\n        formatter_class=argparse.RawTextHelpFormatter,\n    )\n    parser.add_argument(\n        \"--config\",\n        help=\"Path to the server config TOML file (overrides SANDBOX_CONFIG_PATH).\",\n    )\n    parser.add_argument(\n        \"--reload\",\n        action=\"store_true\",\n        help=\"Enable auto-reload (development only).\",\n    )\n\n    subparsers = parser.add_subparsers(dest=\"command\")\n\n    init_parser = subparsers.add_parser(\n        \"init-config\",\n        help=\"Generate a config file from packaged examples or the schema skeleton.\",\n    )\n    init_parser.add_argument(\n        \"path\",\n        nargs=\"?\",\n        default=str(DEFAULT_CONFIG_PATH),\n        help=\"Destination path for the config file (default: ~/.sandbox.toml).\",\n    )\n    init_parser.add_argument(\n        \"--example\",\n        choices=sorted(EXAMPLE_FILE_MAP),\n        help=(\n            \"Packaged example to copy (docker, docker-zh, k8s, k8s-zh). \"\n            \"Omit to render the full skeleton with placeholders.\"\n        ),\n    )\n    init_parser.add_argument(\n        \"--force\",\n        action=\"store_true\",\n        help=\"Overwrite existing file when generating config.\",\n    )\n\n    parser.epilog = (\n        \"Subcommands:\\n\"\n        \"  init-config [path] [--example {docker,docker-zh,k8s,k8s-zh}] [--force]\\n\"\n        \"    Generate a config file. Without --example it renders the full skeleton (placeholders only).\\n\"\n        \"    --example    Copy a packaged example config.\\n\"\n        \"    --force      Overwrite destination if it exists.\\n\"\n    )\n    return parser\n\n\ndef copy_example_config(\n    destination: str | Path | None = None, *, force: bool = False, kind: str = \"default\"\n) -> Path:\n    \"\"\"Copy a packaged example config template to the target path.\"\"\"\n    if kind not in EXAMPLE_FILE_MAP:\n        supported = \", \".join(EXAMPLE_FILE_MAP)\n        raise ValueError(f\"Unsupported example kind '{kind}'. Choices: {supported}\")\n\n    filename = EXAMPLE_FILE_MAP[kind]\n    src_path = Path(__file__).resolve().parent.parent / filename\n    if not src_path.exists():\n        raise FileNotFoundError(f\"Missing example config template at {src_path}\")\n\n    dest_path = Path(destination or DEFAULT_CONFIG_PATH).expanduser()\n    dest_path.parent.mkdir(parents=True, exist_ok=True)\n    if dest_path.exists() and not force:\n        raise FileExistsError(f\"Config file already exists at {dest_path}. Use --force to overwrite.\")\n\n    shutil.copyfile(src_path, dest_path)\n    return dest_path\n\n\ndef render_full_config(destination: str | Path | None = None, *, force: bool = False) -> Path:\n    \"\"\"\n    Render the most complete config skeleton from config models with comments.\n\n    No defaults are prefilled; everything is emitted as placeholders so users\n    must explicitly set values. Field comments come from pydantic Field\n    descriptions to stay in sync with the schema.\n    \"\"\"\n\n    def _placeholder_for_field(field) -> str:\n        \"\"\"Return a placeholder TOML value that is intentionally empty.\"\"\"\n        ann = field.annotation\n        if ann is not None:\n            origin = getattr(ann, \"__origin__\", None)\n            if ann is list or origin is list:\n                return \"[]\"\n        return '\"\"'  # string placeholder for scalars/bool/int; user must replace\n\n    def _render_section(\n        section: str,\n        model,\n        *,\n        placeholders: dict[str, str] | None = None,\n        extra_comments: list[str] | None = None,\n    ) -> str:\n        lines: list[str] = []\n        if extra_comments:\n            lines.extend([f\"# {c}\" for c in extra_comments])\n        lines.append(f\"[{section}]\")\n\n        placeholders = placeholders or {}\n\n        for field_name, field in model.model_fields.items():\n            key = field.alias or field_name\n            value = placeholders.get(key, _placeholder_for_field(field))\n            if field.description:\n                lines.append(f\"# {field.description}\")\n            lines.append(f\"{key} = {value}\")\n            lines.append(\"\")\n\n        if lines and lines[-1] == \"\":\n            lines.pop()\n        return \"\\n\".join(lines)\n\n    dest_path = Path(destination or DEFAULT_CONFIG_PATH).expanduser()\n    dest_path.parent.mkdir(parents=True, exist_ok=True)\n    if dest_path.exists() and not force:\n        raise FileExistsError(f\"Config file already exists at {dest_path}. Use --force to overwrite.\")\n\n    sections = [\n        \"# Generated from OpenSandbox config schema. Remove sections you do not use.\",\n        _render_section(\"server\", ServerConfig),\n        _render_section(\"runtime\", RuntimeConfig),\n        _render_section(\"docker\", DockerConfig),\n        _render_section(\n            \"egress\",\n            EgressConfig,\n            extra_comments=[\"Used when networkPolicy is provided. Requires docker.network_mode = \\\"bridge\\\".\"],\n        ),\n        _render_section(\n            \"kubernetes\",\n            KubernetesRuntimeConfig,\n            extra_comments=[\"Only used when runtime.type = \\\"kubernetes\\\"\"],\n        ),\n        _render_section(\n            \"agent_sandbox\",\n            AgentSandboxRuntimeConfig,\n            extra_comments=[\"Requires kubernetes.workload_provider = \\\"agent-sandbox\\\"\"],\n        ),\n        _render_section(\"ingress\", IngressConfig),\n        _render_section(\"storage\", StorageConfig),\n    ]\n\n    content = \"\\n\\n\".join(sections) + \"\\n\"\n    dest_path.write_text(content, encoding=\"utf-8\")\n    return dest_path\n\n\ndef main() -> None:\n    parser = _build_parser()\n    args = parser.parse_args()\n\n    if args.command == \"init-config\":\n        try:\n            if args.example:\n                dest = copy_example_config(args.path, force=args.force, kind=args.example)\n                print(f\"Wrote example config ({args.example}) to {dest}\\n\")\n            else:\n                dest = render_full_config(args.path, force=args.force)\n                print(f\"Wrote full config skeleton to {dest}\\n\")\n        except Exception as exc:  # noqa: BLE001\n            print(f\"Failed to write config template: {exc}\\n\")\n            raise SystemExit(1)\n        return\n\n    if args.config:\n        os.environ[CONFIG_ENV_VAR] = args.config\n\n    from src import main as server_main  # local import after env is set\n\n    uvicorn.run(\n        \"src.main:app\",\n        host=server_main.app_config.server.host,\n        port=server_main.app_config.server.port,\n        reload=args.reload,\n        log_config=server_main._log_config,\n    )\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "server/src/config.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nApplication configuration management for sandbox server.\n\nLoads configuration from a TOML file (default: ~/.sandbox.toml) and exposes\nhelpers to access the parsed settings throughout the application.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport ipaddress\nimport logging\nimport os\nimport re\nfrom pathlib import Path\nfrom typing import Any, Dict, Literal, Optional\n\nfrom pydantic import BaseModel, Field, ValidationError, model_validator\n\ntry:  # Python 3.11+\n    import tomllib  # type: ignore[attr-defined]\nexcept ModuleNotFoundError:  # Python 3.10 fallback\n    import tomli as tomllib  # type: ignore[import]\n\nlogger = logging.getLogger(__name__)\n\nCONFIG_ENV_VAR = \"SANDBOX_CONFIG_PATH\"\nDEFAULT_CONFIG_PATH = Path.home() / \".sandbox.toml\"\n\n_DOMAIN_RE = re.compile(r\"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?:\\.[A-Za-z0-9-]{1,63})+$\")\n_WILDCARD_DOMAIN_RE = re.compile(r\"^\\*\\.(?!-)[A-Za-z0-9-]{1,63}(?:\\.[A-Za-z0-9-]{1,63})+$\")\n_IPV4_WITH_PORT_RE = re.compile(r\"^(?P<ip>(?:\\d{1,3}\\.){3}\\d{1,3})(?::(?P<port>\\d{1,5}))?$\")\n\nINGRESS_MODE_DIRECT = \"direct\"\nINGRESS_MODE_GATEWAY = \"gateway\"\nGATEWAY_ROUTE_MODE_WILDCARD = \"wildcard\"\nGATEWAY_ROUTE_MODE_HEADER = \"header\"\nGATEWAY_ROUTE_MODE_URI = \"uri\"\n\nEGRESS_MODE_DNS = \"dns\"\nEGRESS_MODE_DNS_NFT = \"dns+nft\"\n\n\ndef _is_valid_ip(host: str) -> bool:\n    try:\n        ipaddress.ip_address(host)\n        return True\n    except ValueError:\n        return False\n\n\ndef _is_valid_ip_or_ip_port(address: str) -> bool:\n    match = _IPV4_WITH_PORT_RE.match(address)\n    if not match:\n        return False\n    ip_str = match.group(\"ip\")\n    if not _is_valid_ip(ip_str):\n        return False\n    port_str = match.group(\"port\")\n    if port_str is None:\n        return True\n    try:\n        port = int(port_str)\n    except ValueError:\n        return False\n    return 1 <= port <= 65535\n\n\ndef _is_valid_domain(host: str) -> bool:\n    return bool(_DOMAIN_RE.match(host))\n\n\ndef _is_wildcard_domain(host: str) -> bool:\n    return bool(_WILDCARD_DOMAIN_RE.match(host))\n\n\nclass GatewayRouteModeConfig(BaseModel):\n    \"\"\"Routing strategy for gateway ingress exposure.\"\"\"\n\n    mode: Literal[\n        GATEWAY_ROUTE_MODE_WILDCARD,\n        GATEWAY_ROUTE_MODE_HEADER,\n        GATEWAY_ROUTE_MODE_URI,\n    ] = Field(\n        ...,\n        description=\"Routing mode used by the gateway (wildcard, header, uri).\",\n    )\n\n    class Config:\n        populate_by_name = True\n\n\nclass GatewayConfig(BaseModel):\n    \"\"\"Gateway mode configuration for ingress exposure.\"\"\"\n\n    address: str = Field(\n        ...,\n        description=\"Gateway host used to expose sandboxes (domain or IP, may include :port; scheme is not allowed).\",\n        min_length=1,\n    )\n    route: GatewayRouteModeConfig = Field(\n        ...,\n        description=\"Routing mode configuration used by the gateway.\",\n    )\n\n\nclass IngressConfig(BaseModel):\n    \"\"\"Configuration for exposing sandbox ingress.\"\"\"\n\n    mode: Literal[INGRESS_MODE_DIRECT, INGRESS_MODE_GATEWAY] = Field(\n        default=INGRESS_MODE_DIRECT,\n        description=\"Ingress exposure mode (direct or gateway).\",\n    )\n    gateway: Optional[GatewayConfig] = Field(\n        default=None,\n        description=\"Gateway configuration required when mode = 'gateway'.\",\n    )\n\n    @model_validator(mode=\"after\")\n    def validate_ingress_mode(self) -> \"IngressConfig\":\n        if self.mode == INGRESS_MODE_GATEWAY and self.gateway is None:\n            raise ValueError(\"gateway block must be provided when ingress.mode = 'gateway'.\")\n        if self.mode == INGRESS_MODE_DIRECT and self.gateway is not None:\n            raise ValueError(\"gateway block must be omitted unless ingress.mode = 'gateway'.\")\n\n        if self.mode == INGRESS_MODE_GATEWAY and self.gateway:\n            route_mode = self.gateway.route.mode\n            address_raw = self.gateway.address\n            hostport = address_raw\n            if \"://\" in address_raw:\n                raise ValueError(\"ingress.gateway.address must not include a scheme; clients choose http/https.\")\n\n            if route_mode == GATEWAY_ROUTE_MODE_WILDCARD:\n                if not _is_wildcard_domain(hostport):\n                    raise ValueError(\n                        \"ingress.gateway.address must be a wildcard domain (e.g., *.example.com) \"\n                        \"when gateway.route.mode is wildcard.\"\n                    )\n            else:\n                if \"*\" in hostport:\n                    raise ValueError(\n                        \"ingress.gateway.address must not contain wildcard when gateway.route.mode is not wildcard.\"\n                    )\n                if not (_is_valid_domain(hostport) or _is_valid_ip_or_ip_port(hostport)):\n                    raise ValueError(\n                        \"ingress.gateway.address must be a valid domain, IP, or IP:port when gateway.route.mode is not wildcard.\"\n                    )\n        return self\n\n\nclass ServerConfig(BaseModel):\n    \"\"\"FastAPI server configuration.\"\"\"\n\n    host: str = Field(\n        default=\"0.0.0.0\",\n        description=\"Interface bound by the lifecycle API server.\",\n        min_length=1,\n    )\n    port: int = Field(\n        default=8080,\n        ge=1,\n        le=65535,\n        description=\"Port exposed by the lifecycle API server.\",\n    )\n    log_level: str = Field(\n        default=\"INFO\",\n        description=\"Python logging level for the server process.\",\n        min_length=3,\n    )\n    api_key: Optional[str] = Field(\n        default=None,\n        description=\"Global API key for authenticating incoming lifecycle API calls.\",\n    )\n    eip: Optional[str] = Field(\n        default=None,\n        description=\"Bound public IP. When set, used as the host part when returning sandbox endpoints.\",\n    )\n    max_sandbox_timeout_seconds: Optional[int] = Field(\n        default=None,\n        ge=60,\n        description=(\n            \"Maximum allowed sandbox TTL in seconds for requests that specify timeout. \"\n            \"Omit from config to disable the server-side upper bound.\"\n        ),\n    )\n\n\nclass KubernetesRuntimeConfig(BaseModel):\n    \"\"\"Kubernetes-specific runtime configuration.\"\"\"\n\n    kubeconfig_path: Optional[str] = Field(\n        default=None,\n        description=\"Absolute path to the kubeconfig file used for API authentication.\",\n    )\n    informer_enabled: bool = Field(\n        default=True,\n        description=(\n            \"[Beta] Enable informer-backed cache for workload reads. \"\n            \"Keeps a watch to reduce API pressure; set false to disable.\"\n        ),\n    )\n    informer_resync_seconds: int = Field(\n        default=300,\n        ge=1,\n        description=(\n            \"[Beta] Full resync interval for informer cache (seconds). \"\n            \"Shorter intervals refresh the cache more eagerly.\"\n        ),\n    )\n    informer_watch_timeout_seconds: int = Field(\n        default=60,\n        ge=1,\n        description=(\n            \"[Beta] Watch timeout (seconds) before restarting the informer stream.\"\n        ),\n    )\n    read_qps: float = Field(\n        default=0.0,\n        ge=0,\n        description=(\n            \"Maximum read requests per second to the Kubernetes API (get/list). \"\n            \"0 means unlimited (no rate limiting).\"\n        ),\n    )\n    read_burst: int = Field(\n        default=0,\n        ge=0,\n        description=(\n            \"Burst size for the read rate limiter. \"\n            \"0 means use read_qps as burst (minimum 1).\"\n        ),\n    )\n    write_qps: float = Field(\n        default=0.0,\n        ge=0,\n        description=(\n            \"Maximum write requests per second to the Kubernetes API (create/delete/patch). \"\n            \"0 means unlimited (no rate limiting).\"\n        ),\n    )\n    write_burst: int = Field(\n        default=0,\n        ge=0,\n        description=(\n            \"Burst size for the write rate limiter. \"\n            \"0 means use write_qps as burst (minimum 1).\"\n        ),\n    )\n    namespace: Optional[str] = Field(\n        default=None,\n        description=\"Namespace used for sandbox workloads.\",\n    )\n    service_account: Optional[str] = Field(\n        default=None,\n        description=\"Service account bound to sandbox workloads.\",\n    )\n    workload_provider: Optional[str] = Field(\n        default=None,\n        description=\"Workload provider type. If not specified, uses the first registered provider.\",\n    )\n    batchsandbox_template_file: Optional[str] = Field(\n        default=None,\n        description=\"Path to BatchSandbox CR YAML template file. Used when workload_provider is 'batchsandbox'.\",\n    )\n    sandbox_create_timeout_seconds: int = Field(\n        default=60,\n        ge=1,\n        description=\"Timeout in seconds to wait for a sandbox to become ready (IP assigned) after creation.\",\n    )\n    sandbox_create_poll_interval_seconds: float = Field(\n        default=1.0,\n        gt=0,\n        description=\"Polling interval in seconds when waiting for a sandbox to become ready after creation.\",\n    )\n    execd_init_resources: Optional[\"ExecdInitResources\"] = Field(\n        default=None,\n        description=(\n            \"Resource requests/limits for the execd init container. \"\n            \"If unset, no resource constraints are applied.\"\n        ),\n    )\n\n\nclass ExecdInitResources(BaseModel):\n    \"\"\"Resource requests and limits for the execd init container.\"\"\"\n\n    limits: Optional[Dict[str, str]] = Field(\n        default=None,\n        description='Resource limits, e.g. {cpu = \"100m\", memory = \"128Mi\"}.',\n    )\n    requests: Optional[Dict[str, str]] = Field(\n        default=None,\n        description='Resource requests, e.g. {cpu = \"50m\", memory = \"64Mi\"}.',\n    )\n\n\nclass AgentSandboxRuntimeConfig(BaseModel):\n    \"\"\"Agent-sandbox runtime configuration.\"\"\"\n\n    template_file: Optional[str] = Field(\n        default=None,\n        description=\"Path to Sandbox CR YAML template file for agent-sandbox.\",\n    )\n    shutdown_policy: Literal[\"Delete\", \"Retain\"] = Field(\n        default=\"Delete\",\n        description=\"Shutdown policy applied when a sandbox expires (Delete or Retain).\",\n    )\n    ingress_enabled: bool = Field(\n        default=True,\n        description=\"Whether ingress routing to agent-sandbox pods is expected to be enabled.\",\n    )\n\n\nclass StorageConfig(BaseModel):\n    \"\"\"Volume and storage configuration for sandbox mounts.\"\"\"\n\n    allowed_host_paths: list[str] = Field(\n        default_factory=list,\n        description=(\n            \"Allowlist of host path prefixes permitted for host bind mounts. \"\n            \"If empty, all host paths are allowed (not recommended for production). \"\n            \"Each entry must be an absolute path (e.g., '/data/opensandbox').\"\n        ),\n    )\n    ossfs_mount_root: str = Field(\n        default=\"/mnt/ossfs\",\n        description=(\n            \"Host-side root directory where OSSFS mounts are resolved. \"\n            \"Resolved OSSFS host paths are built as \"\n            \"'ossfs_mount_root/<bucket>/<volume.subPath?>'.\"\n        ),\n    )\n\n\nclass EgressConfig(BaseModel):\n    \"\"\"Egress sidecar configuration.\"\"\"\n\n    image: Optional[str] = Field(\n        default=None,\n        description=\"Container image for the egress sidecar (used when network policy is requested).\",\n        min_length=1,\n    )\n    mode: Literal[\n        EGRESS_MODE_DNS,\n        EGRESS_MODE_DNS_NFT,\n    ] = Field(\n        default=EGRESS_MODE_DNS,\n        description=\"Egress enforcement passed to the sidecar as OPENSANDBOX_EGRESS_MODE (dns or dns+nft).\",\n    )\n\n\nclass RuntimeConfig(BaseModel):\n    \"\"\"Runtime selection (docker, kubernetes, etc.).\"\"\"\n\n    type: Literal[\"docker\", \"kubernetes\"] = Field(\n        ...,\n        description=\"Active sandbox runtime implementation.\",\n    )\n    execd_image: str = Field(\n        ...,\n        description=\"Container image that contains the execd binary for sandbox initialization.\",\n        min_length=1,\n    )\n\n\nclass SecureRuntimeConfig(BaseModel):\n    \"\"\"Secure container runtime configuration (gVisor, Kata, Firecracker).\"\"\"\n\n    type: Literal[\"\", \"gvisor\", \"kata\", \"firecracker\"] = Field(\n        default=\"\",\n        description=(\n            \"Secure runtime type. Empty means no secure runtime. \"\n            \"gVisor uses runsc OCI runtime. \"\n            \"Kata uses kata-runtime (OCI) or kata-qemu (RuntimeClass). \"\n            \"Firecracker uses kata-fc (RuntimeClass, Kubernetes only).\"\n        ),\n    )\n    docker_runtime: Optional[str] = Field(\n        default=None,\n        description=(\n            \"OCI runtime name for Docker (e.g., 'runsc' for gVisor, 'kata-runtime' for Kata). \"\n            \"When specified, the Docker daemon will use this runtime instead of runc.\"\n        ),\n    )\n    k8s_runtime_class: Optional[str] = Field(\n        default=None,\n        description=(\n            \"Kubernetes RuntimeClass name for secure containers. \"\n            \"Common values: 'gvisor', 'kata-qemu', 'kata-fc'. \"\n            \"When specified, pods will have runtimeClassName set to this value.\"\n        ),\n    )\n\n    @model_validator(mode=\"after\")\n    def validate_secure_runtime(self) -> \"SecureRuntimeConfig\":\n        if self.type == \"\":\n            # No secure runtime configured\n            if self.docker_runtime is not None or self.k8s_runtime_class is not None:\n                raise ValueError(\n                    \"docker_runtime and k8s_runtime_class must be omitted when secure_runtime.type is empty.\"\n                )\n            return self\n\n        if self.type == \"firecracker\":\n            # Firecracker is Kubernetes-only\n            if self.k8s_runtime_class is None:\n                raise ValueError(\n                    \"secure_runtime.k8s_runtime_class is required when secure_runtime.type is 'firecracker'.\"\n                )\n            # Optional: also allow docker_runtime for consistency, but Firecracker won't use it\n\n        # For gVisor and Kata, at least one runtime must be specified\n        if self.type in (\"gvisor\", \"kata\"):\n            if self.docker_runtime is None and self.k8s_runtime_class is None:\n                raise ValueError(\n                    f\"At least one of secure_runtime.docker_runtime or secure_runtime.k8s_runtime_class \"\n                    f\"must be specified when secure_runtime.type is '{self.type}'.\"\n                )\n\n        return self\n\n\nclass DockerConfig(BaseModel):\n    \"\"\"Docker runtime specific settings.\"\"\"\n\n    network_mode: str = Field(\n        default=\"host\",\n        description=\"Docker network mode for sandbox containers (host, bridge, or a custom user-defined network name).\",\n    )\n    api_timeout: Optional[int] = Field(\n        default=None,\n        ge=1,\n        description=\"Docker API timeout in seconds. If unset, default is 180.\",\n    )\n    host_ip: Optional[str] = Field(\n        default=None,\n        description=(\n            \"Docker host IP or hostname for bridge-mode endpoint URLs when the server runs in a container.\"\n        ),\n    )\n    drop_capabilities: list[str] = Field(\n        default_factory=lambda: [\n            \"AUDIT_WRITE\",\n            \"MKNOD\",\n            \"NET_ADMIN\",\n            \"NET_RAW\",\n            \"SYS_ADMIN\",\n            \"SYS_MODULE\",\n            \"SYS_PTRACE\",\n            \"SYS_TIME\",\n            \"SYS_TTY_CONFIG\",\n        ],\n        description=(\n            \"Linux capabilities to drop from sandbox containers. Defaults to a conservative set to reduce host impact.\"\n        ),\n    )\n    apparmor_profile: Optional[str] = Field(\n        default=None,\n        description=(\n            \"Optional AppArmor profile name applied to sandbox containers. Leave unset to let Docker choose the default.\"\n        ),\n    )\n    no_new_privileges: bool = Field(\n        default=True,\n        description=\"Enable the kernel no_new_privileges flag to block privilege escalation inside the container.\",\n    )\n    seccomp_profile: Optional[str] = Field(\n        default=None,\n        description=(\n            \"Optional seccomp profile name or path applied to sandbox containers. Leave unset to use Docker's default profile.\"\n        ),\n    )\n    pids_limit: Optional[int] = Field(\n        default=4096,\n        ge=1,\n        description=\"Maximum number of processes allowed per sandbox container. Set to null to disable the limit.\",\n    )\n\n\nclass AppConfig(BaseModel):\n    \"\"\"Root application configuration model.\"\"\"\n\n    server: ServerConfig = Field(default_factory=ServerConfig)\n    runtime: RuntimeConfig = Field(..., description=\"Sandbox runtime configuration.\")\n    kubernetes: Optional[KubernetesRuntimeConfig] = None\n    agent_sandbox: Optional[\"AgentSandboxRuntimeConfig\"] = None\n    ingress: Optional[IngressConfig] = None\n    docker: DockerConfig = Field(default_factory=DockerConfig)\n    storage: StorageConfig = Field(default_factory=StorageConfig)\n    egress: Optional[EgressConfig] = None\n    secure_runtime: Optional[SecureRuntimeConfig] = Field(\n        default=None,\n        description=\"Secure container runtime configuration (gVisor, Kata, Firecracker).\",\n    )\n\n    @model_validator(mode=\"after\")\n    def validate_runtime_blocks(self) -> \"AppConfig\":\n        if self.runtime.type == \"docker\":\n            if self.kubernetes is not None:\n                raise ValueError(\"Kubernetes block must be omitted when runtime.type = 'docker'.\")\n            if self.agent_sandbox is not None:\n                raise ValueError(\"agent_sandbox block must be omitted when runtime.type = 'docker'.\")\n            if self.ingress is not None and self.ingress.mode != INGRESS_MODE_DIRECT:\n                raise ValueError(\"ingress.mode must be 'direct' when runtime.type = 'docker'.\")\n            if self.secure_runtime is not None and self.secure_runtime.type == \"firecracker\":\n                raise ValueError( \"secure_runtime.type 'firecracker' is only compatible with runtime.type='kubernetes'.\")\n        elif self.runtime.type == \"kubernetes\":\n            if self.kubernetes is None:\n                self.kubernetes = KubernetesRuntimeConfig()\n            provider_type = (self.kubernetes.workload_provider or \"\").lower()\n            if provider_type == \"agent-sandbox\":\n                if self.agent_sandbox is None:\n                    self.agent_sandbox = AgentSandboxRuntimeConfig()\n            elif self.agent_sandbox is not None:\n                raise ValueError(\n                    \"agent_sandbox block requires kubernetes.workload_provider = 'agent-sandbox'.\"\n                )\n        else:\n            raise ValueError(f\"Unsupported runtime type '{self.runtime.type}'.\")\n        return self\n\n\n_config: AppConfig | None = None\n_config_path: Path | None = None\n\n\ndef _resolve_config_path(path: str | Path | None = None) -> Path:\n    \"\"\"Resolve configuration file path from explicit value, env var, or default.\"\"\"\n    if path:\n        return Path(path).expanduser()\n    env_path = os.environ.get(CONFIG_ENV_VAR)\n    if env_path:\n        return Path(env_path).expanduser()\n    return DEFAULT_CONFIG_PATH\n\n\ndef _load_toml_data(path: Path) -> dict[str, Any]:\n    \"\"\"Load TOML content from file, returning empty dict if file is missing.\"\"\"\n    if not path.exists():\n        logger.info(\"Config file %s not found. Using default configuration.\", path)\n        return {}\n\n    try:\n        with path.open(\"rb\") as fh:\n            data = tomllib.load(fh)\n            logger.info(\"Loaded configuration from %s\", path)\n            return data\n    except Exception as exc:  # noqa: BLE001\n        logger.error(\"Failed to read config file %s: %s\", path, exc)\n        raise\n\n\ndef load_config(path: str | Path | None = None) -> AppConfig:\n    \"\"\"\n    Load configuration from TOML file and store it globally.\n\n    Args:\n        path: Optional explicit config path. Falls back to SANDBOX_CONFIG_PATH env,\n              then ~/.sandbox.toml when not provided.\n\n    Returns:\n        AppConfig: Parsed application configuration.\n\n    Raises:\n        ValidationError: If the TOML contents do not match AppConfig schema.\n        Exception: For any IO or parsing errors.\n    \"\"\"\n    global _config, _config_path\n\n    resolved_path = _resolve_config_path(path)\n    raw_data = _load_toml_data(resolved_path)\n\n    try:\n        _config = AppConfig(**raw_data)\n    except ValidationError as exc:\n        logger.error(\"Invalid configuration in %s: %s\", resolved_path, exc)\n        raise\n\n    _config_path = resolved_path\n    return _config\n\n\ndef get_config() -> AppConfig:\n    \"\"\"\n    Retrieve the currently loaded configuration, loading defaults if necessary.\n\n    Returns:\n        AppConfig: Currently active configuration.\n    \"\"\"\n    global _config\n    if _config is None:\n        _config = load_config()\n    return _config\n\n\ndef get_config_path() -> Path:\n    \"\"\"Return the resolved configuration path.\"\"\"\n    global _config_path\n    if _config_path is None:\n        _config_path = _resolve_config_path()\n    return _config_path\n\n\n__all__ = [\n    \"AppConfig\",\n    \"ServerConfig\",\n    \"RuntimeConfig\",\n    \"IngressConfig\",\n    \"GatewayConfig\",\n    \"GatewayRouteModeConfig\",\n    \"INGRESS_MODE_DIRECT\",\n    \"INGRESS_MODE_GATEWAY\",\n    \"DockerConfig\",\n    \"StorageConfig\",\n    \"KubernetesRuntimeConfig\",\n    \"EgressConfig\",\n    \"EGRESS_MODE_DNS\",\n    \"EGRESS_MODE_DNS_NFT\",\n    \"SecureRuntimeConfig\",\n    \"DEFAULT_CONFIG_PATH\",\n    \"CONFIG_ENV_VAR\",\n    \"get_config\",\n    \"get_config_path\",\n    \"load_config\",\n]\n"
  },
  {
    "path": "server/src/main.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nFastAPI application entry point for OpenSandbox Lifecycle API.\n\nThis module initializes the FastAPI application with middleware, routes,\nand configuration for the sandbox lifecycle management service.\n\"\"\"\n\nimport copy\nimport logging.config\nfrom contextlib import asynccontextmanager\nfrom typing import Any\n\nimport httpx\nfrom fastapi import FastAPI, Request\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.responses import JSONResponse\n\nfrom src.config import load_config\nfrom uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG\n\n# Load configuration before initializing routers/middleware\napp_config = load_config()\n\n# Unify logging format (including uvicorn access/error logs) with timestamp prefix.\n_log_config = copy.deepcopy(UVICORN_LOGGING_CONFIG)\n_fmt = \"%(levelprefix)s %(asctime)s [%(request_id)s] %(name)s: %(message)s\"\n_datefmt = \"%Y-%m-%d %H:%M:%S%z\"\n\n# Inject request_id into log records so one request's logs can be correlated.\n_log_config[\"filters\"] = {\n    \"request_id\": {\"()\": \"src.middleware.request_id.RequestIdFilter\"},\n}\n_log_config[\"handlers\"][\"default\"][\"filters\"] = [\"request_id\"]\n_log_config[\"handlers\"][\"access\"][\"filters\"] = [\"request_id\"]\n\n# Enable colors and set format for both default and access loggers\n_log_config[\"formatters\"][\"default\"][\"fmt\"] = _fmt\n_log_config[\"formatters\"][\"default\"][\"datefmt\"] = _datefmt\n_log_config[\"formatters\"][\"default\"][\"use_colors\"] = True\n\n_log_config[\"formatters\"][\"access\"][\"fmt\"] = _fmt\n_log_config[\"formatters\"][\"access\"][\"datefmt\"] = _datefmt\n_log_config[\"formatters\"][\"access\"][\"use_colors\"] = True\n\n# Ensure project loggers (src.*) emit at configured level using the default handler.\n_log_config[\"loggers\"][\"src\"] = {\n    \"handlers\": [\"default\"],\n    \"level\": app_config.server.log_level.upper(),\n    \"propagate\": False,\n}\n\nlogging.config.dictConfig(_log_config)\nlogging.getLogger().setLevel(\n    getattr(logging, app_config.server.log_level.upper(), logging.INFO)\n)\n\nfrom src.api.lifecycle import router  # noqa: E402\nfrom src.middleware.auth import AuthMiddleware  # noqa: E402\nfrom src.middleware.request_id import RequestIdMiddleware  # noqa: E402\nfrom src.services.runtime_resolver import (  # noqa: E402\n    validate_secure_runtime_on_startup,\n)\n\nlogger = logging.getLogger(__name__)\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    app.state.http_client = httpx.AsyncClient(timeout=180.0)\n\n    # Validate secure runtime configuration at startup\n    try:\n        # Determine which runtime client to create based on config\n        docker_client = None\n        k8s_client = None\n        runtime_type = app_config.runtime.type\n\n        if runtime_type == \"docker\":\n            import docker\n\n            docker_client = docker.from_env()\n            logger.info(\"Validating secure runtime for Docker backend\")\n        elif runtime_type == \"kubernetes\":\n            from src.services.k8s.client import K8sClient\n\n            k8s_client = K8sClient(app_config.kubernetes)\n            logger.info(\"Validating secure runtime for Kubernetes backend\")\n\n        await validate_secure_runtime_on_startup(\n            app_config,\n            docker_client=docker_client,\n            k8s_client=k8s_client,\n        )\n\n    except Exception as exc:\n        logger.error(\"Secure runtime validation failed: %s\", exc)\n        raise\n\n    yield\n    await app.state.http_client.aclose()\n\n\n# Initialize FastAPI application\napp = FastAPI(\n    title=\"OpenSandbox Lifecycle API\",\n    version=\"0.1.0\",\n    description=\"The Sandbox Lifecycle API coordinates how untrusted workloads are created, \"\n                \"executed, paused, resumed, and finally disposed.\",\n    docs_url=\"/docs\",\n    redoc_url=\"/redoc\",\n    lifespan=lifespan,\n)\n\n# Attach global config for runtime access\napp.state.config = app_config\n\n# Middleware run in reverse order of addition: last added = first to run (outermost).\n# Add auth and CORS first so they run after RequestIdMiddleware.\napp.add_middleware(AuthMiddleware, config=app_config)\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n# RequestIdMiddleware last = outermost: runs first, so every response (including\n# 401 from AuthMiddleware) gets X-Request-ID and logs have request_id in context.\napp.add_middleware(RequestIdMiddleware)\n\n# Include API routes at root and versioned prefix\napp.include_router(router)\napp.include_router(router, prefix=\"/v1\")\n\nDEFAULT_ERROR_CODE = \"GENERAL::UNKNOWN_ERROR\"\nDEFAULT_ERROR_MESSAGE = \"An unexpected error occurred.\"\n\n\ndef _normalize_error_detail(detail: Any) -> dict[str, str]:\n    \"\"\"\n    Ensure HTTP errors always conform to {\"code\": \"...\", \"message\": \"...\"}.\n    \"\"\"\n    if isinstance(detail, dict):\n        code = detail.get(\"code\") or DEFAULT_ERROR_CODE\n        message = detail.get(\"message\") or DEFAULT_ERROR_MESSAGE\n        return {\"code\": code, \"message\": message}\n    message = str(detail) if detail else DEFAULT_ERROR_MESSAGE\n    return {\"code\": DEFAULT_ERROR_CODE, \"message\": message}\n\n\n@app.exception_handler(HTTPException)\nasync def sandbox_http_exception_handler(request: Request, exc: HTTPException):\n    \"\"\"\n    Flatten FastAPI HTTPException payload to the standard error schema.\n    \"\"\"\n    content = _normalize_error_detail(exc.detail)\n    return JSONResponse(\n        status_code=exc.status_code,\n        content=content,\n        headers=exc.headers,\n    )\n\n\n@app.get(\"/health\")\nasync def health_check():\n    \"\"\"\n    Health check endpoint.\n\n    Returns:\n        dict: Health status\n    \"\"\"\n    return {\"status\": \"healthy\"}\n\n\nif __name__ == \"__main__\":\n    import uvicorn\n\n    # Run the application\n    uvicorn.run(\n        \"src.main:app\",\n        host=app_config.server.host,\n        port=app_config.server.port,\n        reload=True,\n        log_config=_log_config,\n    )\n"
  },
  {
    "path": "server/src/middleware/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n"
  },
  {
    "path": "server/src/middleware/auth.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAuthentication middleware for OpenSandbox Lifecycle API.\n\nThis module implements API Key authentication as specified in the OpenAPI spec.\nAPI keys are configured via config.toml and validated against the OPEN-SANDBOX-API-KEY header.\n\"\"\"\n\nimport re\nfrom typing import Callable, Optional\n\nfrom fastapi import Request, Response, status\nfrom fastapi.responses import JSONResponse\nfrom starlette.middleware.base import BaseHTTPMiddleware\n\nfrom src.config import AppConfig, get_config\n\nclass AuthMiddleware(BaseHTTPMiddleware):\n    \"\"\"\n    Middleware for API Key authentication.\n\n    Validates the OPEN-SANDBOX-API-KEY header for all requests except health check.\n    Returns 401 Unauthorized if authentication fails.\n    \"\"\"\n\n    API_KEY_HEADER = \"OPEN-SANDBOX-API-KEY\"\n\n    # Paths that don't require authentication\n    EXEMPT_PATHS = [\"/health\", \"/docs\", \"/redoc\", \"/openapi.json\"]\n\n    # Strict pattern for proxy-to-sandbox: /sandboxes/{id}/proxy/{port}/... with numeric port only.\n    # Matches the actual route in lifecycle.py; rejects path traversal (..) and malformed port.\n    _PROXY_PATH_RE = re.compile(r\"^(/v1)?/sandboxes/[^/]+/proxy/\\d+(/|$)\")\n\n    @staticmethod\n    def _is_proxy_path(path: str) -> bool:\n        \"\"\"True only for the exact proxy-route shape; rejects path traversal (..).\"\"\"\n        if \"..\" in path:\n            return False\n        return bool(AuthMiddleware._PROXY_PATH_RE.match(path))\n\n    def __init__(self, app, config: Optional[AppConfig] = None):\n        \"\"\"\n        Initialize authentication middleware.\n\n        Args:\n            app: FastAPI application instance\n            config: Optional application configuration (for dependency injection)\n        \"\"\"\n        super().__init__(app)\n        self.config = config or get_config()\n        # Read the API key directly from config; suitable for dev/test usage\n        self.valid_api_keys = self._load_api_keys()\n\n    def _load_api_keys(self) -> set:\n        \"\"\"\n        Load valid API keys from configuration.\n\n        Returns:\n            set: Set of valid API keys\n        \"\"\"\n        # Supports a single API key from config; extend later for secret managers\n        api_key = self.config.server.api_key\n        # Treat empty string as no key configured\n        if api_key and api_key.strip():\n            return {api_key}\n        return set()\n\n    async def dispatch(self, request: Request, call_next: Callable) -> Response:\n        \"\"\"\n        Process each request and validate authentication.\n\n        Args:\n            request: Incoming HTTP request\n            call_next: Next middleware or route handler\n\n        Returns:\n            Response: HTTP response\n        \"\"\"\n        # Skip authentication for exempt paths\n        if any(request.url.path.startswith(path) for path in self.EXEMPT_PATHS):\n            return await call_next(request)\n\n        # Skip authentication only for the exact proxy-to-sandbox route shape\n        # (no path traversal, no loose substring match)\n        if self._is_proxy_path(request.url.path):\n            return await call_next(request)\n\n        # If no API keys are configured, skip authentication\n        if not self.valid_api_keys:\n            return await call_next(request)\n\n        # Extract API key from header\n        api_key = request.headers.get(self.API_KEY_HEADER)\n\n        # Validate API key\n        if not api_key:\n            return JSONResponse(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                content={\n                    \"code\": \"MISSING_API_KEY\",\n                    \"message\": \"Authentication credentials are missing. \"\n                              \"Provide API key via OPEN-SANDBOX-API-KEY header.\",\n                },\n            )\n\n        # Enforce strict comparison whenever API keys are configured\n        if self.valid_api_keys and api_key not in self.valid_api_keys:\n            return JSONResponse(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                content={\n                    \"code\": \"INVALID_API_KEY\",\n                    \"message\": \"Authentication credentials are invalid. \"\n                              \"Check your API key and try again.\",\n                },\n            )\n\n        # Authentication successful, proceed to next middleware/handler\n        response = await call_next(request)\n        return response\n"
  },
  {
    "path": "server/src/middleware/request_id.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nRequest ID middleware and logging context for OpenSandbox Lifecycle API.\n\nReads X-Request-ID from incoming requests (or generates one), stores it in\ncontextvars so that all logs emitted during that request can be correlated\nby request_id. Response includes X-Request-ID for client-side tracing.\n\"\"\"\n\nimport logging\nimport uuid\nfrom contextvars import ContextVar\nfrom typing import Callable\n\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\n# Context variable holding the current request ID for this async context.\n# Used by RequestIdFilter to attach request_id to log records.\nrequest_id_ctx: ContextVar[str | None] = ContextVar(\"request_id\", default=None)\n\nX_REQUEST_ID_HEADER = \"X-Request-ID\"\n\n\ndef get_request_id() -> str | None:\n    \"\"\"Return the current request ID in this async context, or None.\"\"\"\n    return request_id_ctx.get()\n\n\nclass RequestIdMiddleware(BaseHTTPMiddleware):\n    \"\"\"\n    Middleware that sets request ID from X-Request-ID header or generates one.\n\n    The ID is stored in a context variable so that any code (including service\n    layer) running in the same request context can correlate logs via\n    RequestIdFilter without passing request_id explicitly.\n    \"\"\"\n\n    async def dispatch(self, request: Request, call_next: Callable) -> Response:\n        raw = request.headers.get(X_REQUEST_ID_HEADER)\n        request_id = (raw and raw.strip()) or uuid.uuid4().hex\n        token = request_id_ctx.set(request_id)\n        try:\n            response = await call_next(request)\n            response.headers[X_REQUEST_ID_HEADER] = request_id\n            return response\n        finally:\n            request_id_ctx.reset(token)\n\n\nclass RequestIdFilter(logging.Filter):\n    \"\"\"\n    Injects the current request_id from context into each log record.\n\n    Attach this filter to handlers whose formatter uses %(request_id)s.\n    When no request context (e.g. startup or health check), request_id is \"-\".\n    \"\"\"\n\n    def filter(self, record: logging.LogRecord) -> bool:\n        rid = get_request_id()\n        setattr(record, \"request_id\", rid if rid else \"-\")\n        return True\n"
  },
  {
    "path": "server/src/py.typed",
    "content": ""
  },
  {
    "path": "server/src/services/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Sandbox service implementations.\"\"\"\n\nfrom src.services.docker import DockerSandboxService\nfrom src.services.k8s.kubernetes_service import KubernetesSandboxService\nfrom src.services.factory import create_sandbox_service\nfrom src.services.sandbox_service import SandboxService\n\n__all__ = [\n    \"SandboxService\",\n    \"DockerSandboxService\",\n    \"KubernetesSandboxService\",\n    \"create_sandbox_service\",\n]\n"
  },
  {
    "path": "server/src/services/constants.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Shared constants for sandbox services.\"\"\"\n\nRESERVED_LABEL_PREFIX = \"opensandbox.io/\"\n\nSANDBOX_ID_LABEL = \"opensandbox.io/id\"\nSANDBOX_EXPIRES_AT_LABEL = \"opensandbox.io/expires-at\"\nSANDBOX_MANUAL_CLEANUP_LABEL = \"opensandbox.io/manual-cleanup\"\n# Host-mapped ports recorded on containers (bridge mode).\nSANDBOX_EMBEDDING_PROXY_PORT_LABEL = \"opensandbox.io/embedding-proxy-port\"  # maps container 44772 -> host port\nSANDBOX_HTTP_PORT_LABEL = \"opensandbox.io/http-port\"  # maps container 8080 -> host port\nSANDBOX_OSSFS_MOUNTS_LABEL = \"opensandbox.io/ossfs-mounts\"\nOPEN_SANDBOX_INGRESS_HEADER = \"OpenSandbox-Ingress-To\"\nOPEN_SANDBOX_EGRESS_AUTH_HEADER = \"OPENSANDBOX-EGRESS-AUTH\"\nSANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY = \"opensandbox.io/egress-auth-token\"\n\n# Environment variable name for passing network policy to egress sidecar\nEGRESS_RULES_ENV = \"OPENSANDBOX_EGRESS_RULES\"\n# Must match components/egress/pkg/constants/configuration.go EnvEgressMode\nEGRESS_MODE_ENV = \"OPENSANDBOX_EGRESS_MODE\"\n# Must match components/egress/pkg/constants/configuration.go EnvEgressToken\nOPENSANDBOX_EGRESS_TOKEN = \"OPENSANDBOX_EGRESS_TOKEN\"\n\n\nclass SandboxErrorCodes:\n    \"\"\"Canonical error codes for sandbox service operations.\"\"\"\n\n    # Docker runtime error codes\n    DOCKER_INITIALIZATION_ERROR = \"DOCKER::INITIALIZATION_ERROR\"\n    CONTAINER_QUERY_FAILED = \"DOCKER::SANDBOX_QUERY_FAILED\"\n    SANDBOX_NOT_FOUND = \"DOCKER::SANDBOX_NOT_FOUND\"\n    IMAGE_PULL_FAILED = \"DOCKER::SANDBOX_IMAGE_PULL_FAILED\"\n    CONTAINER_START_FAILED = \"DOCKER::SANDBOX_START_FAILED\"\n    SANDBOX_DELETE_FAILED = \"DOCKER::SANDBOX_DELETE_FAILED\"\n    SANDBOX_NOT_RUNNING = \"DOCKER::SANDBOX_NOT_RUNNING\"\n    SANDBOX_PAUSE_FAILED = \"DOCKER::SANDBOX_PAUSE_FAILED\"\n    SANDBOX_NOT_PAUSED = \"DOCKER::SANDBOX_NOT_PAUSED\"\n    SANDBOX_RESUME_FAILED = \"DOCKER::SANDBOX_RESUME_FAILED\"\n    INVALID_EXPIRATION = \"DOCKER::INVALID_EXPIRATION\"\n    EXPIRATION_NOT_EXTENDED = \"DOCKER::EXPIRATION_NOT_EXTENDED\"\n    EXECD_START_FAILED = \"DOCKER::SANDBOX_EXECD_START_FAILED\"\n    EXECD_DISTRIBUTION_FAILED = \"DOCKER::SANDBOX_EXECD_DISTRIBUTION_FAILED\"\n    BOOTSTRAP_INSTALL_FAILED = \"DOCKER::SANDBOX_BOOTSTRAP_INSTALL_FAILED\"\n    INVALID_ENTRYPOINT = \"DOCKER::INVALID_ENTRYPOINT\"\n    INVALID_PORT = \"DOCKER::INVALID_PORT\"\n    NETWORK_MODE_ENDPOINT_UNAVAILABLE = \"DOCKER::NETWORK_MODE_ENDPOINT_UNAVAILABLE\"\n    \n    # Kubernetes runtime error codes\n    K8S_INITIALIZATION_ERROR = \"KUBERNETES::INITIALIZATION_ERROR\"\n    K8S_SANDBOX_NOT_FOUND = \"KUBERNETES::SANDBOX_NOT_FOUND\"\n    K8S_POD_FAILED = \"KUBERNETES::POD_FAILED\"\n    K8S_POD_READY_TIMEOUT = \"KUBERNETES::POD_READY_TIMEOUT\"\n    K8S_API_ERROR = \"KUBERNETES::API_ERROR\"\n    K8S_POD_IP_NOT_AVAILABLE = \"KUBERNETES::POD_IP_NOT_AVAILABLE\"\n    \n    # Common error codes\n    UNKNOWN_ERROR = \"SANDBOX::UNKNOWN_ERROR\"\n    API_NOT_SUPPORTED = \"SANDBOX::API_NOT_SUPPORTED\"\n    INVALID_METADATA_LABEL = \"SANDBOX::INVALID_METADATA_LABEL\"\n    INVALID_PARAMETER = \"SANDBOX::INVALID_PARAMETER\"\n\n    # Volume error codes\n    INVALID_VOLUME_NAME = \"VOLUME::INVALID_NAME\"\n    DUPLICATE_VOLUME_NAME = \"VOLUME::DUPLICATE_NAME\"\n    INVALID_VOLUME_BACKEND = \"VOLUME::INVALID_BACKEND\"\n    INVALID_MOUNT_PATH = \"VOLUME::INVALID_MOUNT_PATH\"\n    INVALID_SUB_PATH = \"VOLUME::INVALID_SUB_PATH\"\n    INVALID_HOST_PATH = \"VOLUME::INVALID_HOST_PATH\"\n    HOST_PATH_NOT_ALLOWED = \"VOLUME::HOST_PATH_NOT_ALLOWED\"\n    INVALID_PVC_NAME = \"VOLUME::INVALID_PVC_NAME\"\n    UNSUPPORTED_VOLUME_BACKEND = \"VOLUME::UNSUPPORTED_BACKEND\"\n    HOST_PATH_NOT_FOUND = \"VOLUME::HOST_PATH_NOT_FOUND\"\n    HOST_PATH_CREATE_FAILED = \"VOLUME::HOST_PATH_CREATE_FAILED\"\n    PVC_VOLUME_NOT_FOUND = \"VOLUME::PVC_NOT_FOUND\"\n    PVC_VOLUME_INSPECT_FAILED = \"VOLUME::PVC_INSPECT_FAILED\"\n    PVC_SUBPATH_UNSUPPORTED_DRIVER = \"VOLUME::PVC_SUBPATH_UNSUPPORTED_DRIVER\"\n    INVALID_OSSFS_VERSION = \"VOLUME::INVALID_OSSFS_VERSION\"\n    INVALID_OSSFS_ENDPOINT = \"VOLUME::INVALID_OSSFS_ENDPOINT\"\n    INVALID_OSSFS_BUCKET = \"VOLUME::INVALID_OSSFS_BUCKET\"\n    INVALID_OSSFS_OPTION = \"VOLUME::INVALID_OSSFS_OPTION\"\n    INVALID_OSSFS_CREDENTIALS = \"VOLUME::INVALID_OSSFS_CREDENTIALS\"\n    INVALID_OSSFS_MOUNT_ROOT = \"VOLUME::INVALID_OSSFS_MOUNT_ROOT\"\n    OSSFS_PATH_NOT_FOUND = \"VOLUME::OSSFS_PATH_NOT_FOUND\"\n    OSSFS_MOUNT_FAILED = \"VOLUME::OSSFS_MOUNT_FAILED\"\n    OSSFS_UNMOUNT_FAILED = \"VOLUME::OSSFS_UNMOUNT_FAILED\"\n\n\n__all__ = [\n    \"RESERVED_LABEL_PREFIX\",\n    \"SANDBOX_ID_LABEL\",\n    \"SANDBOX_EXPIRES_AT_LABEL\",\n    \"SANDBOX_MANUAL_CLEANUP_LABEL\",\n    \"SANDBOX_EMBEDDING_PROXY_PORT_LABEL\",\n    \"SANDBOX_HTTP_PORT_LABEL\",\n    \"SANDBOX_OSSFS_MOUNTS_LABEL\",\n    \"OPEN_SANDBOX_INGRESS_HEADER\",\n    \"OPEN_SANDBOX_EGRESS_AUTH_HEADER\",\n    \"SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY\",\n    \"EGRESS_RULES_ENV\",\n    \"EGRESS_MODE_ENV\",\n    \"OPENSANDBOX_EGRESS_TOKEN\",\n    \"SandboxErrorCodes\",\n]\n"
  },
  {
    "path": "server/src/services/docker.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nDocker-based implementation of SandboxService.\n\nThis module provides a Docker implementation of the sandbox service interface,\nusing Docker containers for sandbox lifecycle management.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport inspect\nimport io\nimport json\nimport logging\nimport math\nimport os\nimport posixpath\nimport random\nimport socket\nimport tarfile\nimport time\nfrom contextlib import contextmanager\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta, timezone\nfrom threading import Lock, Timer\nfrom typing import Any, Dict, Optional\nfrom uuid import uuid4\n\nimport docker\nfrom docker.errors import DockerException, ImageNotFound, NotFound as DockerNotFound\nfrom fastapi import HTTPException, status\n\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    CreateSandboxResponse,\n    Endpoint,\n    ImageSpec,\n    ListSandboxesRequest,\n    ListSandboxesResponse,\n    NetworkPolicy,\n    PaginationInfo,\n    RenewSandboxExpirationRequest,\n    RenewSandboxExpirationResponse,\n    Sandbox,\n    SandboxStatus,\n)\nfrom src.config import AppConfig, get_config\nfrom src.services.constants import (\n    EGRESS_MODE_ENV,\n    EGRESS_RULES_ENV,\n    OPENSANDBOX_EGRESS_TOKEN,\n    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,\n    SANDBOX_EMBEDDING_PROXY_PORT_LABEL,\n    SANDBOX_EXPIRES_AT_LABEL,\n    SANDBOX_HTTP_PORT_LABEL,\n    SANDBOX_ID_LABEL,\n    SANDBOX_MANUAL_CLEANUP_LABEL,\n    SANDBOX_OSSFS_MOUNTS_LABEL,\n    SandboxErrorCodes,\n)\nfrom src.services.endpoint_auth import (\n    build_egress_auth_headers,\n    generate_egress_token,\n    merge_endpoint_headers,\n)\nfrom src.services.helpers import (\n    matches_filter,\n    parse_memory_limit,\n    parse_nano_cpus,\n    parse_timestamp,\n)\nfrom src.services.ossfs_mixin import OSSFSMixin\nfrom src.services.sandbox_service import SandboxService\nfrom src.services.runtime_resolver import SecureRuntimeResolver\nfrom src.services.validators import (\n    calculate_expiration_or_raise,\n    ensure_egress_configured,\n    ensure_entrypoint,\n    ensure_future_expiration,\n    ensure_metadata_labels,\n    ensure_timeout_within_limit,\n    ensure_valid_host_path,\n    ensure_volumes_valid,\n)\nlogger = logging.getLogger(__name__)\n\n\ndef _running_inside_docker_container() -> bool:\n    \"\"\"Return True if the current process is running inside a Docker container.\"\"\"\n    return os.path.exists(\"/.dockerenv\")\n\n\nOPENSANDBOX_DIR = \"/opt/opensandbox\"\n# Use posixpath for container-internal paths so they always use forward slashes,\n# even when the server runs on Windows.\nEXECED_INSTALL_PATH = posixpath.join(OPENSANDBOX_DIR, \"execd\")\nBOOTSTRAP_PATH = posixpath.join(OPENSANDBOX_DIR, \"bootstrap.sh\")\n\nHOST_NETWORK_MODE = \"host\"\nBRIDGE_NETWORK_MODE = \"bridge\"\nPENDING_FAILURE_TTL_SECONDS = int(os.environ.get(\"PENDING_FAILURE_TTL\", \"3600\"))\nEGRESS_SIDECAR_LABEL = \"opensandbox.io/egress-sidecar-for\"\n\n\n@dataclass\nclass PendingSandbox:\n    request: CreateSandboxRequest\n    created_at: datetime\n    expires_at: Optional[datetime]\n    status: SandboxStatus\n\n\nclass DockerSandboxService(OSSFSMixin, SandboxService):\n    \"\"\"\n    Docker-based implementation of SandboxService.\n\n    This class implements sandbox lifecycle operations using Docker containers.\n    \"\"\"\n\n    def __init__(self, config: Optional[AppConfig] = None):\n        \"\"\"\n        Initialize Docker sandbox service.\n\n        Initializes Docker service from environment variables.\n        The service will read configuration from:\n        - DOCKER_HOST: Docker daemon URL (e.g., 'unix://var/run/docker.sock' or 'tcp://127.0.0.1:2376')\n        - DOCKER_TLS_CERTDIR: Directory containing TLS certificates\n        - Other Docker environment variables as needed\n\n        Note: Connection is not verified at initialization time.\n        Connection errors will be raised when Docker operations are performed.\n        \"\"\"\n        self.app_config = config or get_config()\n        runtime_config = self.app_config.runtime\n        if runtime_config.type != \"docker\":\n            raise ValueError(\"DockerSandboxService requires runtime.type = 'docker'.\")\n\n        self.execd_image = runtime_config.execd_image\n        self.network_mode = (self.app_config.docker.network_mode or HOST_NETWORK_MODE).lower()\n        self._execd_archive_cache: Optional[bytes] = None\n        self._api_timeout = self._resolve_api_timeout()\n        try:\n            # Initialize Docker service from environment variables\n            client_kwargs = {}\n            try:\n                signature = inspect.signature(docker.from_env)\n                if \"timeout\" in signature.parameters:\n                    client_kwargs[\"timeout\"] = self._api_timeout\n            except (ValueError, TypeError):\n                logger.debug(\n                    \"Unable to introspect docker.from_env signature; using default parameters.\"\n                )\n            self.docker_client = docker.from_env(**client_kwargs)\n            if not client_kwargs:\n                try:\n                    self.docker_client.api.timeout = self._api_timeout\n                except AttributeError:\n                    logger.debug(\"Docker client API does not expose timeout attribute.\")\n            logger.info(\"Docker service initialized from environment\")\n        except Exception as e:  # noqa: BLE001\n            # Common failure mode on macOS/dev machines: Docker daemon not running or socket path wrong.\n            hint = \"\"\n            msg = str(e)\n            if isinstance(e, FileNotFoundError) or \"No such file or directory\" in msg:\n                docker_host = os.environ.get(\"DOCKER_HOST\", \"\")\n                hint = (\n                    \" Docker daemon seems unavailable (unix socket not found). \"\n                    \"Make sure Docker Desktop (or Colima/Rancher Desktop) is running. \"\n                    \"If you use Colima on macOS, you may need to set \"\n                    \"DOCKER_HOST=unix://${HOME}/.colima/default/docker.sock before starting the server. \"\n                    f\"(current DOCKER_HOST='{docker_host}')\"\n                )\n            raise HTTPException(\n                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,\n                detail={\n                    \"code\": SandboxErrorCodes.DOCKER_INITIALIZATION_ERROR,\n                    \"message\": f\"Failed to initialize Docker service: {str(e)}.{hint}\",\n                },\n            )\n        self._expiration_lock = Lock()\n        self._execd_archive_lock = Lock()\n        self._sandbox_expirations: Dict[str, datetime] = {}\n        self._expiration_timers: Dict[str, Timer] = {}\n        self._pending_sandboxes: Dict[str, PendingSandbox] = {}\n        self._pending_lock = Lock()\n        self._pending_cleanup_timers: Dict[str, Timer] = {}\n        self._ossfs_mount_lock = Lock()\n        self._ossfs_mount_ref_counts: Dict[str, int] = {}\n        self._restore_existing_sandboxes()\n\n        # Initialize secure runtime resolver\n        self.resolver = SecureRuntimeResolver(self.app_config)\n        self.docker_runtime = self.resolver.get_docker_runtime()\n\n    def _resolve_api_timeout(self) -> int:\n        \"\"\"Docker API timeout in seconds: [docker].api_timeout if set, else default 180.\"\"\"\n        cfg = self.app_config.docker.api_timeout\n        if cfg is not None and cfg >= 1:\n            return cfg\n        return 180\n\n    @contextmanager\n    def _docker_operation(self, action: str, sandbox_id: Optional[str] = None):\n        \"\"\"Context manager to log duration for Docker API calls.\"\"\"\n        op_id = sandbox_id or \"shared\"\n        start = time.perf_counter()\n        try:\n            yield\n        except Exception as exc:\n            elapsed_ms = (time.perf_counter() - start) * 1000\n            logger.warning(\n                \"sandbox=%s | action=%s | duration=%.2f | error=%s\",\n                op_id,\n                action,\n                elapsed_ms,\n                exc,\n            )\n            raise\n        else:\n            elapsed_ms = (time.perf_counter() - start) * 1000\n            logger.info(\n                \"sandbox=%s | action=%s | duration=%.2f\",\n                op_id,\n                action,\n                elapsed_ms,\n            )\n\n    def _get_container_by_sandbox_id(self, sandbox_id: str):\n        \"\"\"Helper to fetch the Docker container associated with a sandbox ID.\"\"\"\n        label_selector = f\"{SANDBOX_ID_LABEL}={sandbox_id}\"\n        try:\n            containers = self.docker_client.containers.list(\n                all=True, filters={\"label\": label_selector}\n            )\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_QUERY_FAILED,\n                    \"message\": f\"Failed to query sandbox containers: {str(exc)}\",\n                },\n            ) from exc\n\n        if not containers:\n            raise HTTPException(\n                status_code=status.HTTP_404_NOT_FOUND,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_NOT_FOUND,\n                    \"message\": f\"Sandbox {sandbox_id} not found.\",\n                },\n            )\n\n        return containers[0]\n\n    def _schedule_expiration(\n        self,\n        sandbox_id: str,\n        expires_at: datetime,\n        *,\n        update_expiration: bool = True,\n        **expire_kwargs,\n    ) -> None:\n        \"\"\"Schedule automatic sandbox termination at expiration time.\"\"\"\n        # Delay might already be negative if the timer should fire immediately\n        delay = max(0.0, (expires_at - datetime.now(timezone.utc)).total_seconds())\n        timer = Timer(\n            delay,\n            self._expire_sandbox,\n            args=(sandbox_id,),\n            kwargs=expire_kwargs or None,\n        )\n        timer.daemon = True\n        with self._expiration_lock:\n            # Replace existing timer (if any) so renew operations take effect immediately\n            existing = self._expiration_timers.pop(sandbox_id, None)\n            if existing:\n                existing.cancel()\n            if update_expiration:\n                self._sandbox_expirations[sandbox_id] = expires_at\n            self._expiration_timers[sandbox_id] = timer\n        timer.start()\n\n    def _remove_expiration_tracking(self, sandbox_id: str) -> None:\n        \"\"\"Remove expiration tracking and cancel any pending timers.\"\"\"\n        with self._expiration_lock:\n            timer = self._expiration_timers.pop(sandbox_id, None)\n            if timer:\n                timer.cancel()\n            self._sandbox_expirations.pop(sandbox_id, None)\n\n    @staticmethod\n    def _has_manual_cleanup(labels: Dict[str, str]) -> bool:\n        \"\"\"Return True when labels indicate manual cleanup mode.\"\"\"\n        return labels.get(SANDBOX_MANUAL_CLEANUP_LABEL, \"\").lower() == \"true\"\n\n    def _get_tracked_expiration(\n        self,\n        sandbox_id: str,\n        labels: Dict[str, str],\n    ) -> Optional[datetime]:\n        \"\"\"Return the known expiration timestamp for the sandbox.\"\"\"\n        with self._expiration_lock:\n            tracked = self._sandbox_expirations.get(sandbox_id)\n        if tracked:\n            return tracked\n        label_value = labels.get(SANDBOX_EXPIRES_AT_LABEL)\n        if label_value:\n            return parse_timestamp(label_value)\n        return None\n\n    def _expire_sandbox(\n        self,\n        sandbox_id: str,\n        fallback_mount_keys: Optional[list[str]] = None,\n    ) -> None:\n        \"\"\"Timer callback to terminate expired sandboxes.\"\"\"\n        mount_keys: list[str] = []\n        try:\n            container = self._get_container_by_sandbox_id(sandbox_id)\n        except HTTPException as exc:\n            if exc.status_code == status.HTTP_404_NOT_FOUND:\n                self._remove_expiration_tracking(sandbox_id)\n                if fallback_mount_keys:\n                    self._release_ossfs_mounts(fallback_mount_keys)\n            else:\n                with self._expiration_lock:\n                    current_expires = self._sandbox_expirations.get(sandbox_id)\n                now = datetime.now(timezone.utc)\n                if current_expires and current_expires > now:\n                    logger.info(\n                        \"Sandbox %s expiration was renewed; skipping retry.\",\n                        sandbox_id,\n                    )\n                else:\n                    logger.warning(\n                        \"Failed to fetch sandbox %s for expiration: %s — \"\n                        \"scheduling retry in 30s\",\n                        sandbox_id,\n                        exc.detail,\n                    )\n                    retry_at = now + timedelta(seconds=30)\n                    self._schedule_expiration(\n                        sandbox_id,\n                        retry_at,\n                        update_expiration=False,\n                        fallback_mount_keys=fallback_mount_keys,\n                    )\n            return\n\n        with self._expiration_lock:\n            current_expires = self._sandbox_expirations.get(sandbox_id)\n        if current_expires and current_expires > datetime.now(timezone.utc):\n            logger.info(\n                \"Sandbox %s was renewed (expires %s); aborting expiration.\",\n                sandbox_id,\n                current_expires,\n            )\n            return\n\n        labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n        mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, \"[]\")\n        try:\n            parsed_mount_keys = json.loads(mount_keys_raw)\n            if isinstance(parsed_mount_keys, list):\n                mount_keys = [key for key in parsed_mount_keys if isinstance(key, str) and key]\n        except (TypeError, json.JSONDecodeError):\n            mount_keys = []\n\n        try:\n            state = container.attrs.get(\"State\", {})\n            if state.get(\"Running\", False):\n                container.kill()\n        except DockerException as exc:\n            logger.warning(\"Failed to stop expired sandbox %s: %s\", sandbox_id, exc)\n\n        try:\n            container.remove(force=True)\n        except DockerException as exc:\n            logger.warning(\"Failed to remove expired sandbox %s: %s\", sandbox_id, exc)\n\n        self._remove_expiration_tracking(sandbox_id)\n        # Ensure sidecar is also cleaned up on expiration\n        self._cleanup_egress_sidecar(sandbox_id)\n        self._release_ossfs_mounts(mount_keys)\n\n    def _restore_existing_sandboxes(self) -> None:\n        \"\"\"On startup, rebuild expiration timers for containers already running.\"\"\"\n        try:\n            containers = self.docker_client.containers.list(all=True)\n        except DockerException as exc:\n            logger.warning(\"Failed to restore existing sandboxes: %s\", exc)\n            return\n\n        restored = 0\n        seen_sidecars: set[str] = set()\n        restored_mount_refs: dict[str, int] = {}\n        expired_entries: list[tuple[str, list[str]]] = []\n        now = datetime.now(timezone.utc)\n\n        def _parse_and_accumulate_mount_refs(labels: dict) -> list[str]:\n            mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, \"[]\")\n            try:\n                parsed = json.loads(mount_keys_raw)\n            except (TypeError, json.JSONDecodeError):\n                parsed = []\n            keys: list[str] = []\n            if isinstance(parsed, list):\n                for key in parsed:\n                    if isinstance(key, str) and key:\n                        keys.append(key)\n                        restored_mount_refs[key] = restored_mount_refs.get(key, 0) + 1\n            return keys\n\n        # Pass 1: collect ref counts for ALL sandbox containers (alive + expired)\n        # and schedule timers for alive ones.  Expired sandboxes are deferred to\n        # pass 2 so that ref counts are fully populated before any release.\n        for container in containers:\n            labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n            sidecar_for = labels.get(EGRESS_SIDECAR_LABEL)\n            if sidecar_for:\n                seen_sidecars.add(sidecar_for)\n                continue\n\n            sandbox_id = labels.get(SANDBOX_ID_LABEL)\n            if not sandbox_id:\n                continue\n\n            mount_keys = _parse_and_accumulate_mount_refs(labels)\n\n            expires_label = labels.get(SANDBOX_EXPIRES_AT_LABEL)\n            if expires_label:\n                expires_at = parse_timestamp(expires_label)\n            elif self._has_manual_cleanup(labels):\n                restored += 1\n                continue\n            else:\n                logger.warning(\n                    \"Sandbox %s missing expires-at label; skipping expiration scheduling.\",\n                    sandbox_id,\n                )\n                continue\n\n            if expires_at <= now:\n                logger.info(\"Sandbox %s already expired; terminating now.\", sandbox_id)\n                expired_entries.append((sandbox_id, mount_keys))\n                continue\n\n            self._schedule_expiration(sandbox_id, expires_at)\n            restored += 1\n\n        # Populate ref counts before expiring anything so _release_ossfs_mount\n        # can properly decrement and unmount.\n        with self._ossfs_mount_lock:\n            self._ossfs_mount_ref_counts = restored_mount_refs\n\n        # Pass 2: expire deferred sandboxes (ref counts are now available).\n        # Cached mount keys are passed as fallback so that mounts are still\n        # released even if the container vanishes between pass 1 and pass 2.\n        for sandbox_id, cached_mount_keys in expired_entries:\n            self._expire_sandbox(sandbox_id, fallback_mount_keys=cached_mount_keys)\n\n        # Cleanup orphan sidecars (no matching sandbox container)\n        for orphan_id in seen_sidecars:\n            try:\n                self._get_container_by_sandbox_id(orphan_id)\n            except HTTPException as exc:\n                if exc.status_code == status.HTTP_404_NOT_FOUND:\n                    self._cleanup_egress_sidecar(orphan_id)\n                else:\n                    logger.warning(\n                        \"Failed to check sandbox %s for orphan sidecar cleanup: %s\", orphan_id, exc\n                    )\n\n        if restored:\n            logger.info(\"Restored expiration timers for %d sandbox(es).\", restored)\n\n    def _fetch_execd_archive(self) -> bytes:\n        \"\"\"Fetch (and memoize) the execd archive from the platform container.\"\"\"\n        if self._execd_archive_cache is not None:\n            return self._execd_archive_cache\n\n        with self._execd_archive_lock:\n            # Double-check locking to ensure only one thread initializes the cache\n            if self._execd_archive_cache is not None:\n                return self._execd_archive_cache\n\n            container = None\n            try:\n                try:\n                    # Prefer a locally built image (e.g., opensandbox/execd:local); pull only if missing.\n                    self.docker_client.images.get(self.execd_image)\n                    logger.info(\"Found execd image %s locally; skipping pull\", self.execd_image)\n                except ImageNotFound:\n                    with self._docker_operation(\n                        f\"pull execd image {self.execd_image}\",\n                        \"execd-cache\",\n                    ):\n                        self.docker_client.images.pull(self.execd_image)\n\n                with self._docker_operation(\"execd cache create container\", \"execd-cache\"):\n                    container = self.docker_client.containers.create(\n                        image=self.execd_image,\n                        command=[\"tail\", \"-f\", \"/dev/null\"],\n                        name=f\"sandbox-execd-{uuid4()}\",\n                        detach=True,\n                        auto_remove=False,\n                    )\n                with self._docker_operation(\"execd cache start container\", \"execd-cache\"):\n                    container.start()\n                    container.reload()\n                    logger.info(\"Created sandbox execd archive for container %s\", container.id)\n            except DockerException as exc:\n                raise HTTPException(\n                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,\n                    detail={\n                        \"code\": SandboxErrorCodes.EXECD_START_FAILED,\n                        \"message\": f\"Failed to start execd container: {str(exc)}\",\n                    },\n                ) from exc\n\n            try:\n                with self._docker_operation(\"execd cache read archive\", \"execd-cache\"):\n                    stream, _ = container.get_archive(\"/execd\")\n                    data = b\"\".join(stream)\n            except DockerException as exc:\n                raise HTTPException(\n                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                    detail={\n                        \"code\": SandboxErrorCodes.EXECD_DISTRIBUTION_FAILED,\n                        \"message\": f\"Failed to read execd artifacts: {str(exc)}\",\n                    },\n                ) from exc\n            finally:\n                if container:\n                    try:\n                        with self._docker_operation(\"execd cache cleanup container\", \"execd-cache\"):\n                            container.remove(force=True)\n                    except DockerException as cleanup_exc:\n                        logger.warning(\n                            \"Failed to cleanup temporary execd container: %s\", cleanup_exc\n                        )\n\n            self._execd_archive_cache = data\n            logger.info(\"Dumped execd archive to memory\")\n            return data\n\n    def _container_to_sandbox(self, container, sandbox_id: Optional[str] = None) -> Sandbox:\n        labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n        resolved_id = sandbox_id or labels.get(SANDBOX_ID_LABEL)\n        if not resolved_id:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_NOT_FOUND,\n                    \"message\": \"Container missing sandbox ID label.\",\n                },\n            )\n\n        status_section = container.attrs.get(\"State\", {})\n        status_value = (status_section.get(\"Status\") or container.status or \"\").lower()\n        running = status_section.get(\"Running\", False)\n        paused = status_section.get(\"Paused\", False)\n        restarting = status_section.get(\"Restarting\", False)\n        exit_code = status_section.get(\"ExitCode\")\n        finished_at = status_section.get(\"FinishedAt\")\n\n        if running and not paused:\n            state = \"Running\"\n            reason = \"CONTAINER_RUNNING\"\n            message = \"Sandbox container is running.\"\n        elif paused:\n            state = \"Paused\"\n            reason = \"CONTAINER_PAUSED\"\n            message = \"Sandbox container is paused.\"\n        elif restarting:\n            state = \"Running\"\n            reason = \"CONTAINER_RESTARTING\"\n            message = \"Sandbox container is restarting.\"\n        elif status_value in {\"created\", \"starting\"}:\n            state = \"Pending\"\n            reason = \"CONTAINER_STARTING\"\n            message = \"Sandbox container is starting.\"\n        elif status_value in {\"exited\", \"dead\"}:\n            if exit_code == 0:\n                state = \"Terminated\"\n                reason = \"CONTAINER_EXITED\"\n                message = \"Sandbox container exited successfully.\"\n            else:\n                state = \"Failed\"\n                reason = \"CONTAINER_EXITED_ERROR\"\n                message = f\"Sandbox container exited with code {exit_code}.\"\n        else:\n            state = \"Unknown\"\n            reason = \"CONTAINER_STATE_UNKNOWN\"\n            message = f\"Sandbox container is in state '{status_value or 'unknown'}'.\"\n\n        metadata = {\n            key: value\n            for key, value in labels.items()\n            if key not in {SANDBOX_ID_LABEL, SANDBOX_EXPIRES_AT_LABEL, SANDBOX_MANUAL_CLEANUP_LABEL}\n        } or None\n        entrypoint = container.attrs.get(\"Config\", {}).get(\"Cmd\") or []\n        if isinstance(entrypoint, str):\n            entrypoint = [entrypoint]\n        image_tags = container.image.tags\n        image_uri = image_tags[0] if image_tags else container.image.short_id\n        image_spec = ImageSpec(uri=image_uri)\n\n        created_at = parse_timestamp(container.attrs.get(\"Created\"))\n        last_transition_at = (\n            parse_timestamp(finished_at)\n            if finished_at and finished_at != \"0001-01-01T00:00:00Z\"\n            else created_at\n        )\n        expires_at = self._get_tracked_expiration(resolved_id, labels)\n\n        status_info = SandboxStatus(\n            state=state,\n            reason=reason,\n            message=message,\n            last_transition_at=last_transition_at,\n        )\n\n        return Sandbox(\n            id=resolved_id,\n            image=image_spec,\n            status=status_info,\n            metadata=metadata,\n            entrypoint=entrypoint,\n            expiresAt=expires_at,\n            createdAt=created_at,\n        )\n\n    def _ensure_directory(self, container, path: str, sandbox_id: Optional[str] = None) -> None:\n        \"\"\"Create a directory within the target container if it does not exist.\"\"\"\n        if not path or path == \"/\":\n            return\n        normalized_path = path.rstrip(\"/\")\n        if not normalized_path:\n            return\n        tar_stream = io.BytesIO()\n        with tarfile.open(fileobj=tar_stream, mode=\"w\") as tar:\n            dir_info = tarfile.TarInfo(name=normalized_path.lstrip(\"/\"))\n            dir_info.type = tarfile.DIRTYPE\n            dir_info.mode = 0o755\n            dir_info.mtime = int(time.time())\n            tar.addfile(dir_info)\n        tar_stream.seek(0)\n        try:\n            with self._docker_operation(f\"ensure directory {normalized_path}\", sandbox_id):\n                container.put_archive(path=\"/\", data=tar_stream.getvalue())\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.EXECD_DISTRIBUTION_FAILED,\n                    \"message\": f\"Failed to create directory {path} in sandbox: {str(exc)}\",\n                },\n            ) from exc\n\n    def _copy_execd_to_container(self, container, sandbox_id: str) -> None:\n        \"\"\"Copy execd artifacts from the platform container into the sandbox.\"\"\"\n        archive = self._fetch_execd_archive()\n        target_parent = posixpath.dirname(EXECED_INSTALL_PATH.rstrip(\"/\")) or \"/\"\n        self._ensure_directory(container, target_parent, sandbox_id)\n        try:\n            with self._docker_operation(\"copy execd archive to sandbox\", sandbox_id):\n                container.put_archive(path=target_parent, data=archive)\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.EXECD_DISTRIBUTION_FAILED,\n                    \"message\": f\"Failed to copy execd into sandbox: {str(exc)}\",\n                },\n            ) from exc\n\n    def _install_bootstrap_script(self, container, sandbox_id: str) -> None:\n        \"\"\"Install the bootstrap launcher that starts execd then chains to user command.\"\"\"\n        script_path = BOOTSTRAP_PATH\n        script_dir = posixpath.dirname(script_path)\n        self._ensure_directory(container, script_dir, sandbox_id)\n        execd_binary = EXECED_INSTALL_PATH\n        script_content = \"\\n\".join(\n            [\n                \"#!/bin/sh\",\n                \"set -e\",\n                f\"{execd_binary} >/tmp/execd.log 2>&1 &\",\n                'exec \"$@\"',\n                \"\",\n            ]\n        ).encode(\"utf-8\")\n\n        tar_stream = io.BytesIO()\n        with tarfile.open(fileobj=tar_stream, mode=\"w\") as tar:\n            info = tarfile.TarInfo(name=script_path.lstrip(\"/\"))\n            info.mode = 0o755\n            info.size = len(script_content)\n            info.mtime = int(time.time())\n            tar.addfile(info, io.BytesIO(script_content))\n        tar_stream.seek(0)\n        try:\n            with self._docker_operation(\"install bootstrap script\", sandbox_id):\n                container.put_archive(path=\"/\", data=tar_stream.getvalue())\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.BOOTSTRAP_INSTALL_FAILED,\n                    \"message\": f\"Failed to install bootstrap launcher: {str(exc)}\",\n                },\n            ) from exc\n\n    def _prepare_sandbox_runtime(self, container, sandbox_id: str) -> None:\n        \"\"\"Copy execd artifacts and bootstrap launcher into the sandbox container.\"\"\"\n        self._copy_execd_to_container(container, sandbox_id)\n        self._install_bootstrap_script(container, sandbox_id)\n\n    def _prepare_creation_context(\n        self,\n        request: CreateSandboxRequest,\n    ) -> tuple[str, datetime, Optional[datetime]]:\n        sandbox_id = self.generate_sandbox_id()\n        created_at = datetime.now(timezone.utc)\n        expires_at = None\n        if request.timeout is not None:\n            expires_at = calculate_expiration_or_raise(created_at, request.timeout)\n        return sandbox_id, created_at, expires_at\n\n    @staticmethod\n    def _allocate_host_port(\n        min_port: int = 40000, max_port: int = 60000, attempts: int = 50\n    ) -> Optional[int]:\n        \"\"\"Find an available TCP port on the host within the given range.\"\"\"\n        for _ in range(attempts):\n            port = random.randint(min_port, max_port)\n            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:\n                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n                try:\n                    sock.bind((\"0.0.0.0\", port))\n                except OSError:\n                    continue\n                return port\n        return None\n\n    async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n        \"\"\"\n        Create a new sandbox from a container image using Docker.\n\n        Args:\n            request: Sandbox creation request\n\n        Returns:\n            CreateSandboxResponse: Created sandbox information\n\n        Raises:\n            HTTPException: If sandbox creation fails\n        \"\"\"\n        ensure_entrypoint(request.entrypoint)\n        ensure_metadata_labels(request.metadata)\n        ensure_timeout_within_limit(\n            request.timeout,\n            self.app_config.server.max_sandbox_timeout_seconds,\n        )\n        self._ensure_network_policy_support(request)\n        self._validate_network_exists()\n        pvc_inspect_cache = self._validate_volumes(request)\n        sandbox_id, created_at, expires_at = self._prepare_creation_context(request)\n        return self._provision_sandbox(sandbox_id, request, created_at, expires_at, pvc_inspect_cache)\n\n    def _async_provision_worker(\n        self,\n        sandbox_id: str,\n        request: CreateSandboxRequest,\n        created_at: datetime,\n        expires_at: Optional[datetime],\n        pvc_inspect_cache: Optional[dict[str, dict]] = None,\n    ) -> None:\n        try:\n            self._provision_sandbox(sandbox_id, request, created_at, expires_at, pvc_inspect_cache)\n        except HTTPException as exc:\n            message = exc.detail.get(\"message\") if isinstance(exc.detail, dict) else str(exc)\n            self._mark_pending_failed(sandbox_id, message or \"Sandbox provisioning failed.\")\n            self._cleanup_failed_containers(sandbox_id)\n            self._schedule_pending_cleanup(sandbox_id)\n        except Exception as exc:  # noqa: BLE001\n            logger.exception(\"Unexpected error provisioning sandbox %s: %s\", sandbox_id, exc)\n            self._mark_pending_failed(sandbox_id, str(exc))\n            self._cleanup_failed_containers(sandbox_id)\n            self._schedule_pending_cleanup(sandbox_id)\n        else:\n            self._remove_pending_sandbox(sandbox_id)\n\n    def _mark_pending_failed(self, sandbox_id: str, message: str) -> None:\n        with self._pending_lock:\n            pending = self._pending_sandboxes.get(sandbox_id)\n            if not pending:\n                return\n            pending.status = SandboxStatus(\n                state=\"Failed\",\n                reason=\"PROVISIONING_ERROR\",\n                message=message,\n                last_transition_at=datetime.now(timezone.utc),\n            )\n\n    def _cleanup_failed_containers(self, sandbox_id: str) -> None:\n        \"\"\"\n        Best-effort cleanup for containers left behind after a failed provision.\n        \"\"\"\n        label_selector = f\"{SANDBOX_ID_LABEL}={sandbox_id}\"\n        try:\n            containers = self.docker_client.containers.list(\n                all=True, filters={\"label\": label_selector}\n            )\n        except DockerException as exc:\n            logger.warning(\"sandbox=%s | cleanup listing failed containers: %s\", sandbox_id, exc)\n            self._cleanup_egress_sidecar(sandbox_id)\n            return\n\n        for container in containers:\n            labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n            mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, \"[]\")\n            try:\n                mount_keys: list[str] = json.loads(mount_keys_raw)\n            except (TypeError, json.JSONDecodeError):\n                mount_keys = []\n            try:\n                with self._docker_operation(\"cleanup failed sandbox container\", sandbox_id):\n                    container.remove(force=True)\n            except DockerException as exc:\n                logger.warning(\n                    \"sandbox=%s | failed to remove leftover container %s: %s\",\n                    sandbox_id,\n                    container.id,\n                    exc,\n                )\n            finally:\n                self._release_ossfs_mounts(mount_keys)\n        # Always attempt to cleanup sidecar as well\n        self._cleanup_egress_sidecar(sandbox_id)\n\n    def _remove_pending_sandbox(self, sandbox_id: str) -> None:\n        with self._pending_lock:\n            timer = self._pending_cleanup_timers.pop(sandbox_id, None)\n            if timer:\n                timer.cancel()\n            self._pending_sandboxes.pop(sandbox_id, None)\n\n    def _get_pending_sandbox(self, sandbox_id: str) -> Optional[PendingSandbox]:\n        with self._pending_lock:\n            pending = self._pending_sandboxes.get(sandbox_id)\n            return pending\n\n    def _iter_pending_sandboxes(self) -> list[tuple[str, PendingSandbox]]:\n        with self._pending_lock:\n            return list(self._pending_sandboxes.items())\n\n    @staticmethod\n    def _pending_to_sandbox(sandbox_id: str, pending: PendingSandbox) -> Sandbox:\n        return Sandbox(\n            id=sandbox_id,\n            image=pending.request.image,\n            status=pending.status,\n            metadata=pending.request.metadata,\n            entrypoint=pending.request.entrypoint,\n            expiresAt=pending.expires_at,\n            createdAt=pending.created_at,\n        )\n\n    def _update_container_labels(self, container, labels: Dict[str, str]) -> None:\n        \"\"\"\n        Update container labels, falling back to raw API if docker-py lacks support.\n        \"\"\"\n        try:\n            container.update(labels=labels)\n        except TypeError:\n            # Older docker-py versions do not accept labels; call low-level API directly.\n            url = self.docker_client.api._url(f\"/containers/{container.id}/update\")  # noqa: SLF001\n            data = {\"Labels\": labels}\n            self.docker_client.api._post_json(url, data=data)  # noqa: SLF001\n        container.reload()\n\n    def _schedule_pending_cleanup(self, sandbox_id: str) -> None:\n        def _cleanup():\n            self._remove_pending_sandbox(sandbox_id)\n\n        timer = Timer(PENDING_FAILURE_TTL_SECONDS, _cleanup)\n        timer.daemon = True\n        with self._pending_lock:\n            existing = self._pending_cleanup_timers.pop(sandbox_id, None)\n            if existing:\n                existing.cancel()\n            self._pending_cleanup_timers[sandbox_id] = timer\n        timer.start()\n\n    def _pull_image(\n        self,\n        image_uri: str,\n        auth_config: Optional[dict],\n        sandbox_id: str,\n    ) -> None:\n        try:\n            with self._docker_operation(f\"pull image {image_uri}\", sandbox_id):\n                self.docker_client.images.pull(image_uri, auth_config=auth_config)\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.IMAGE_PULL_FAILED,\n                    \"message\": f\"Failed to pull image {image_uri}: {str(exc)}\",\n                },\n            ) from exc\n\n    def _ensure_image_available(\n        self,\n        image_uri: str,\n        auth_config: Optional[dict],\n        sandbox_id: str,\n    ) -> None:\n        try:\n            with self._docker_operation(f\"inspect image {image_uri}\", sandbox_id):\n                self.docker_client.images.get(image_uri)\n                logger.debug(\"Sandbox %s using cached image %s\", sandbox_id, image_uri)\n        except ImageNotFound:\n            self._pull_image(image_uri, auth_config, sandbox_id)\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.IMAGE_PULL_FAILED,\n                    \"message\": f\"Failed to inspect image {image_uri}: {str(exc)}\",\n                },\n            ) from exc\n\n    def _provision_sandbox(\n        self,\n        sandbox_id: str,\n        request: CreateSandboxRequest,\n        created_at: datetime,\n        expires_at: Optional[datetime],\n        pvc_inspect_cache: Optional[dict[str, dict]] = None,\n    ) -> CreateSandboxResponse:\n        labels, environment = self._build_labels_and_env(sandbox_id, request, expires_at)\n        image_uri, auth_config = self._resolve_image_auth(request, sandbox_id)\n        mem_limit, nano_cpus = self._resolve_resource_limits(request)\n        egress_token: Optional[str] = None\n\n        # Prepare OSSFS mounts first so binds can reference mounted host paths.\n        ossfs_mount_keys = self._prepare_ossfs_mounts(request.volumes)\n        if ossfs_mount_keys:\n            labels[SANDBOX_OSSFS_MOUNTS_LABEL] = json.dumps(\n                ossfs_mount_keys,\n                separators=(\",\", \":\"),\n            )\n\n        sidecar_container = None\n        try:\n            # Build volume bind mounts from request volumes.\n            # pvc_inspect_cache carries Docker volume inspect data from the\n            # validation phase, avoiding a redundant API call.\n            volume_binds = self._build_volume_binds(request.volumes, pvc_inspect_cache)\n\n            host_config_kwargs: Dict[str, Any]\n            exposed_ports: Optional[list[str]] = None\n\n            if request.network_policy:\n                egress_token = generate_egress_token()\n                labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_token\n                host_execd_port, host_http_port = self._allocate_distinct_host_ports()\n                sidecar_container = self._start_egress_sidecar(\n                    sandbox_id=sandbox_id,\n                    network_policy=request.network_policy,\n                    egress_token=egress_token,\n                    host_execd_port=host_execd_port,\n                    host_http_port=host_http_port,\n                )\n                labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)\n                labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)\n                host_config_kwargs = self._base_host_config_kwargs(\n                    mem_limit, nano_cpus, f\"container:{sidecar_container.id}\"\n                )\n                # Drop NET_ADMIN for the main container; only the sidecar should keep it\n                cap_drop = set(host_config_kwargs.get(\"cap_drop\") or [])\n                cap_drop.add(\"NET_ADMIN\")\n                if cap_drop:\n                    host_config_kwargs[\"cap_drop\"] = list(cap_drop)\n            else:\n                host_config_kwargs = self._base_host_config_kwargs(\n                    mem_limit, nano_cpus, self.network_mode\n                )\n                if self.network_mode != HOST_NETWORK_MODE:\n                    host_execd_port, host_http_port = self._allocate_distinct_host_ports()\n                    port_bindings = {\n                        \"44772\": (\"0.0.0.0\", host_execd_port),\n                        \"8080\": (\"0.0.0.0\", host_http_port),\n                    }\n                    host_config_kwargs[\"port_bindings\"] = port_bindings\n                    exposed_ports = list(port_bindings.keys())\n                    labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)\n                    labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)\n\n            # Inject volume bind mounts into Docker host config\n            if volume_binds:\n                host_config_kwargs[\"binds\"] = volume_binds\n\n            self._create_and_start_container(\n                sandbox_id,\n                image_uri,\n                request.entrypoint,\n                labels,\n                environment,\n                host_config_kwargs,\n                exposed_ports,\n            )\n        except Exception:\n            if sidecar_container is not None:\n                try:\n                    sidecar_container.remove(force=True)\n                except DockerException as cleanup_exc:\n                    logger.warning(\n                        \"Failed to cleanup egress sidecar for sandbox %s: %s\",\n                        sandbox_id,\n                        cleanup_exc,\n                    )\n            self._release_ossfs_mounts(ossfs_mount_keys)\n            raise\n\n        status_info = SandboxStatus(\n            state=\"Running\",\n            reason=\"CONTAINER_RUNNING\",\n            message=\"Sandbox container started successfully.\",\n            last_transition_at=created_at,\n        )\n\n        if expires_at is not None:\n            self._schedule_expiration(sandbox_id, expires_at)\n\n        return CreateSandboxResponse(\n            id=sandbox_id,\n            status=status_info,\n            metadata=request.metadata,\n            expiresAt=expires_at,\n            createdAt=created_at,\n            entrypoint=request.entrypoint,\n        )\n\n    def _is_user_defined_network(self) -> bool:\n        \"\"\"Return True when network_mode is a named user-defined network (not host/bridge/none/container:*).\"\"\"\n        return (\n            self.network_mode not in {HOST_NETWORK_MODE, BRIDGE_NETWORK_MODE, \"none\"}\n            and not self.network_mode.startswith(\"container:\")\n        )\n\n    def _validate_network_exists(self) -> None:\n        \"\"\"Verify the configured user-defined Docker network exists before creating a sandbox.\"\"\"\n        if not self._is_user_defined_network():\n            return\n        try:\n            self.docker_client.networks.get(self.network_mode)\n        except DockerNotFound:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": (\n                        f\"Docker network '{self.network_mode}' does not exist. \"\n                        \"Create it first with 'docker network create <name>'.\"\n                    ),\n                },\n            )\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                    \"message\": f\"Failed to inspect Docker network '{self.network_mode}': {exc}\",\n                },\n            ) from exc\n\n    def _ensure_network_policy_support(self, request: CreateSandboxRequest) -> None:\n        \"\"\"\n        Validate that network policy can be honored under the current runtime config.\n\n        This includes Docker-specific checks (network_mode) and common checks (egress.image).\n        \"\"\"\n        if not request.network_policy:\n            return\n\n        # Docker-specific validation: network_mode must be bridge\n        if self.network_mode == HOST_NETWORK_MODE:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": \"networkPolicy is not supported when docker network_mode=host.\",\n                },\n            )\n\n        # User-defined networks cannot be combined with networkPolicy: the egress sidecar\n        # always runs on the default bridge, which would silently discard the configured network.\n        if self._is_user_defined_network():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": (\n                        f\"networkPolicy is not supported when docker network_mode='{self.network_mode}' \"\n                        \"(user-defined network). Use network_mode='bridge' to enable network policy enforcement.\"\n                    ),\n                },\n            )\n\n        # Common validation: egress.image must be configured\n        ensure_egress_configured(request.network_policy, self.app_config.egress)\n\n    def _validate_volumes(self, request: CreateSandboxRequest) -> dict[str, dict]:\n        \"\"\"\n        Validate volume definitions for Docker runtime.\n\n        Performs comprehensive validation:\n        - Calls shared volume validation (name, mount path, sub path, backend count)\n        - Delegates to backend-specific validators for Docker-level checks\n\n        Args:\n            request: Sandbox creation request.\n\n        Returns:\n            A dict mapping PVC volume names (``pvc.claimName``) to their\n            ``docker volume inspect`` results.  Empty when there are no PVC\n            volumes.  This data is passed to ``_build_volume_binds`` so that\n            bind generation does not need a second API call.\n\n        Raises:\n            HTTPException: When any validation fails.\n        \"\"\"\n        if not request.volumes:\n            return {}\n\n        # Shared validation: names, mount paths, sub paths, backend count, host path allowlist\n        allowed_prefixes = self.app_config.storage.allowed_host_paths or None\n        ensure_volumes_valid(request.volumes, allowed_host_prefixes=allowed_prefixes)\n\n        pvc_inspect_cache: dict[str, dict] = {}\n        for volume in request.volumes:\n            if volume.host is not None:\n                self._validate_host_volume(volume, allowed_prefixes)\n            elif volume.pvc is not None:\n                vol_info = self._validate_pvc_volume(volume)\n                pvc_inspect_cache[volume.pvc.claim_name] = vol_info\n            elif volume.ossfs is not None:\n                self._validate_ossfs_volume(volume)\n\n        return pvc_inspect_cache\n\n    @staticmethod\n    def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None:\n        \"\"\"\n        Docker-specific validation for host bind mount volumes.\n\n        Validates that the resolved host path (host.path + optional subPath)\n        remains within allowed prefixes, then ensures the directory exists on\n        the filesystem — creating it automatically if it does not.\n\n        Args:\n            volume: Volume with host backend.\n            allowed_prefixes: Optional allowlist of host path prefixes.\n\n        Raises:\n            HTTPException: When the resolved path is invalid or cannot be created.\n        \"\"\"\n        resolved_path = volume.host.path\n        if volume.sub_path:\n            resolved_path = os.path.normpath(os.path.join(resolved_path, volume.sub_path))\n\n        # Defense in depth: re-validate the resolved path against the\n        # allowlist.  Even though sub_path traversal (../) is blocked by\n        # ensure_valid_sub_path(), normalizing and re-checking prevents\n        # any edge-case bypass.\n        if allowed_prefixes and resolved_path != volume.host.path:\n            ensure_valid_host_path(resolved_path, allowed_prefixes)\n\n        try:\n            os.makedirs(resolved_path, exist_ok=True)\n        except OSError as e:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.HOST_PATH_CREATE_FAILED,\n                    \"message\": (\n                        f\"Volume '{volume.name}': could not ensure host path \"\n                        f\"directory exists at '{resolved_path}': {type(e).__name__}\"\n                    ),\n                },\n            )\n\n    def _validate_pvc_volume(self, volume) -> dict:\n        \"\"\"\n        Docker-specific validation for PVC (named volume) backend.\n\n        In Docker runtime, the ``pvc`` backend maps to a Docker named volume.\n        ``pvc.claimName`` is used as the Docker volume name.  The volume must\n        already exist (created via ``docker volume create``).\n\n        When ``subPath`` is specified, the volume must use the ``local`` driver\n        so that the host-side ``Mountpoint`` is a real filesystem path.  The\n        resolved path (``Mountpoint + subPath``) is validated for path-traversal\n        safety but *not* for existence, because the Mountpoint directory is\n        typically owned by root and may not be stat-able by the server process.\n\n        Args:\n            volume: Volume with pvc backend.\n\n        Returns:\n            The ``docker volume inspect`` result dict for the named volume.\n\n        Raises:\n            HTTPException: When the named volume does not exist, inspection\n                fails, or subPath constraints are violated.\n        \"\"\"\n        volume_name = volume.pvc.claim_name\n        try:\n            vol_info = self.docker_client.api.inspect_volume(volume_name)\n        except DockerNotFound:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.PVC_VOLUME_NOT_FOUND,\n                    \"message\": (\n                        f\"Volume '{volume.name}': Docker named volume '{volume_name}' \"\n                        \"does not exist. Named volumes must be created before sandbox \"\n                        \"creation (e.g., 'docker volume create <name>').\"\n                    ),\n                },\n            )\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED,\n                    \"message\": (\n                        f\"Volume '{volume.name}': failed to inspect Docker named volume \"\n                        f\"'{volume_name}': {exc}\"\n                    ),\n                },\n            ) from exc\n\n        # --- subPath validation for Docker named volumes ---\n        if volume.sub_path:\n            driver = vol_info.get(\"Driver\", \"\")\n            if driver != \"local\":\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER,\n                        \"message\": (\n                            f\"Volume '{volume.name}': subPath is only supported for \"\n                            f\"Docker named volumes using the 'local' driver, but \"\n                            f\"volume '{volume_name}' uses driver '{driver}'.\"\n                        ),\n                    },\n                )\n\n            mountpoint = vol_info.get(\"Mountpoint\", \"\")\n            if not mountpoint:\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER,\n                        \"message\": (\n                            f\"Volume '{volume.name}': cannot resolve subPath because \"\n                            f\"Docker named volume '{volume_name}' has no Mountpoint.\"\n                        ),\n                    },\n                )\n\n            resolved_path = posixpath.normpath(\n                posixpath.join(mountpoint, volume.sub_path)\n            )\n\n            # ── Path-escape check (lexical + symlink) ──\n            #\n            # 1. Lexical check via normpath + path-boundary-aware startswith.\n            #    Use mountpoint + \"/\" to avoid false positives when one\n            #    mountpoint is a prefix of another (e.g., …/_data vs …/_data2).\n            #    Docker Mountpoint paths are always POSIX, so use \"/\" directly.\n            mountpoint_prefix = (\n                mountpoint if mountpoint.endswith(\"/\") else mountpoint + \"/\"\n            )\n            if resolved_path != mountpoint and not resolved_path.startswith(\n                mountpoint_prefix\n            ):\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_SUB_PATH,\n                        \"message\": (\n                            f\"Volume '{volume.name}': resolved subPath escapes the \"\n                            f\"volume mountpoint.\"\n                        ),\n                    },\n                )\n\n            # 2. Symlink-aware check (best-effort).\n            #    Docker volume Mountpoint dirs are typically root-owned and not\n            #    readable by the server process.  Using strict=True so that\n            #    realpath raises OSError when it cannot traverse a directory\n            #    instead of silently returning the unresolved lexical path\n            #    (which would make this check a no-op).  When the path IS\n            #    accessible, this detects symlink-escape attacks (e.g., a\n            #    malicious symlink datasets -> /).\n            try:\n                canonical_mountpoint = os.path.realpath(\n                    mountpoint, strict=True\n                )\n                canonical_resolved = os.path.realpath(\n                    resolved_path, strict=True\n                )\n                # os.path.realpath returns OS-native separators, so use\n                # os.sep here (unlike the lexical check above which operates\n                # on POSIX-normalised Docker Mountpoint strings).\n                canonical_prefix = (\n                    canonical_mountpoint\n                    if canonical_mountpoint.endswith(os.sep)\n                    else canonical_mountpoint + os.sep\n                )\n                if (\n                    canonical_resolved != canonical_mountpoint\n                    and not canonical_resolved.startswith(canonical_prefix)\n                ):\n                    raise HTTPException(\n                        status_code=status.HTTP_400_BAD_REQUEST,\n                        detail={\n                            \"code\": SandboxErrorCodes.INVALID_SUB_PATH,\n                            \"message\": (\n                                f\"Volume '{volume.name}': resolved subPath escapes \"\n                                f\"the volume mountpoint after symlink resolution.\"\n                            ),\n                        },\n                    )\n            except OSError:\n                # Cannot access volume paths (expected for non-root server).\n                # Lexical validation above is still enforced; the symlink\n                # check is skipped because we cannot resolve the real paths.\n                pass\n\n            # NOTE: We intentionally do NOT check os.path.exists(resolved_path)\n            # here.  Docker volume Mountpoint directories (e.g.,\n            # /var/lib/docker/volumes/…/_data) are typically owned by root and\n            # not readable by the server process.  os.path.exists() returns\n            # False when the process lacks permission to stat the path, causing\n            # false-negative rejections.  If the subPath does not actually\n            # exist, Docker will report the error at container creation time.\n\n        return vol_info\n\n    def _build_volume_binds(\n        self,\n        volumes: Optional[list],\n        pvc_inspect_cache: Optional[dict[str, dict]] = None,\n    ) -> list[str]:\n        \"\"\"\n        Convert Volume definitions into Docker bind/volume mount specs.\n\n        Supported backends:\n        - ``host``: host path bind mount.\n          Format: ``/host/path:/container/path:ro|rw``\n        - ``pvc``: Docker named volume mount.\n          Format (no subPath): ``volume-name:/container/path:ro|rw``\n          Docker recognises non-absolute-path sources as named volume references.\n          Format (with subPath): ``/var/lib/docker/volumes/…/subdir:/container/path:ro|rw``\n          When subPath is specified, the volume's host Mountpoint (obtained from\n          ``pvc_inspect_cache``) is used to produce a standard bind mount.\n        - ``ossfs``: host bind mount to runtime-mounted OSSFS path.\n          Format: ``/mnt/ossfs/<bucket>/<subPath?>:/container/path:ro|rw``\n\n        Each mount string uses ``:ro`` for read-only and ``:rw`` for read-write\n        (default).\n\n        Args:\n            volumes: List of Volume objects from the creation request.\n            pvc_inspect_cache: Dict mapping PVC claimNames to their\n                ``docker volume inspect`` results, populated by\n                ``_validate_volumes``.  Avoids a redundant API call and\n                eliminates the race window between validation and bind\n                generation.\n\n        Returns:\n            List of Docker bind/volume mount strings.\n        \"\"\"\n        if not volumes:\n            return []\n\n        cache = pvc_inspect_cache or {}\n        binds: list[str] = []\n        for volume in volumes:\n            container_path = volume.mount_path\n            mode = \"ro\" if volume.read_only else \"rw\"\n\n            if volume.host is not None:\n                # Resolve the concrete host path (host.path + optional subPath)\n                host_path = volume.host.path\n                if volume.sub_path:\n                    host_path = os.path.normpath(\n                        os.path.join(host_path, volume.sub_path)\n                    )\n                binds.append(f\"{host_path}:{container_path}:{mode}\")\n\n            elif volume.pvc is not None:\n                if volume.sub_path:\n                    # Resolve the named volume's host-side Mountpoint and append\n                    # the subPath to produce a regular bind mount.  Validation\n                    # has already ensured the driver is \"local\" and the resolved\n                    # path is safe.  Reuse cached inspect data to avoid a\n                    # redundant Docker API call and potential race condition.\n                    vol_info = cache.get(volume.pvc.claim_name, {})\n                    mountpoint = vol_info.get(\"Mountpoint\", \"\")\n                    resolved = posixpath.normpath(\n                        posixpath.join(mountpoint, volume.sub_path)\n                    )\n                    binds.append(f\"{resolved}:{container_path}:{mode}\")\n                else:\n                    # No subPath: use claimName directly as Docker volume ref.\n                    binds.append(\n                        f\"{volume.pvc.claim_name}:{container_path}:{mode}\"\n                    )\n            elif volume.ossfs is not None:\n                _, host_path = self._resolve_ossfs_paths(volume)\n                binds.append(f\"{host_path}:{container_path}:{mode}\")\n\n        return binds\n\n    def list_sandboxes(self, request: ListSandboxesRequest) -> ListSandboxesResponse:\n        \"\"\"\n        List sandboxes with optional filtering and pagination.\n        \"\"\"\n        try:\n            containers = self.docker_client.containers.list(\n                all=True,\n                filters={\"label\": [SANDBOX_ID_LABEL]},\n            )\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_QUERY_FAILED,\n                    \"message\": f\"Failed to query sandbox containers: {str(exc)}\",\n                },\n            ) from exc\n\n        sandboxes_by_id: dict[str, Sandbox] = {}\n        container_ids: set[str] = set()\n        for container in containers:\n            labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n            sandbox_id = labels.get(SANDBOX_ID_LABEL)\n            if not sandbox_id:\n                continue\n            sandbox_obj = self._container_to_sandbox(container, sandbox_id)\n            container_ids.add(sandbox_id)\n            if matches_filter(sandbox_obj, request.filter):\n                sandboxes_by_id[sandbox_id] = sandbox_obj\n\n        for sandbox_id, pending in self._iter_pending_sandboxes():\n            if sandbox_id in container_ids:\n                # If a real container exists, prefer its state regardless of filter outcome.\n                continue\n            sandbox_obj = self._pending_to_sandbox(sandbox_id, pending)\n            if matches_filter(sandbox_obj, request.filter):\n                sandboxes_by_id[sandbox_id] = sandbox_obj\n\n        sandboxes: list[Sandbox] = list(sandboxes_by_id.values())\n\n        sandboxes.sort(key=lambda s: s.created_at or datetime.min, reverse=True)\n\n        if request.pagination:\n            page = request.pagination.page\n            page_size = request.pagination.page_size\n        else:\n            page = 1\n            page_size = 20\n\n        total_items = len(sandboxes)\n        total_pages = math.ceil(total_items / page_size) if total_items else 0\n        start_index = (page - 1) * page_size\n        end_index = start_index + page_size\n        items = sandboxes[start_index:end_index]\n        has_next_page = page < total_pages\n\n        pagination_info = PaginationInfo(\n            page=page,\n            page_size=page_size,\n            total_items=total_items,\n            total_pages=total_pages,\n            has_next_page=has_next_page,\n        )\n\n        return ListSandboxesResponse(items=items, pagination=pagination_info)\n\n    def get_sandbox(self, sandbox_id: str) -> Sandbox:\n        \"\"\"\n        Fetch a sandbox by id.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Returns:\n            Sandbox: Complete sandbox information\n\n        Raises:\n            HTTPException: If sandbox not found\n        \"\"\"\n        # Prefer real container state; fall back to pending record only if no container exists.\n        try:\n            container = self._get_container_by_sandbox_id(sandbox_id)\n        except HTTPException as exc:\n            if exc.status_code != status.HTTP_404_NOT_FOUND:\n                raise\n            pending = self._get_pending_sandbox(sandbox_id)\n            if pending:\n                return self._pending_to_sandbox(sandbox_id, pending)\n            raise\n        return self._container_to_sandbox(container, sandbox_id)\n\n    def delete_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Delete a sandbox using Docker.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or deletion fails\n        \"\"\"\n        container = self._get_container_by_sandbox_id(sandbox_id)\n        labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n        mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, \"[]\")\n        try:\n            mount_keys: list[str] = json.loads(mount_keys_raw)\n        except (TypeError, json.JSONDecodeError):\n            mount_keys = []\n        try:\n            try:\n                with self._docker_operation(\"kill sandbox container\", sandbox_id):\n                    container.kill()\n            except DockerException as exc:\n                # Ignore error if container is already stopped\n                if \"is not running\" not in str(exc).lower():\n                    raise\n            with self._docker_operation(\"remove sandbox container\", sandbox_id):\n                container.remove(force=True)\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_DELETE_FAILED,\n                    \"message\": f\"Failed to delete sandbox container: {str(exc)}\",\n                },\n            ) from exc\n        finally:\n            self._remove_expiration_tracking(sandbox_id)\n            self._cleanup_egress_sidecar(sandbox_id)\n            self._release_ossfs_mounts(mount_keys)\n\n    def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a running sandbox using Docker.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or cannot be paused\n        \"\"\"\n        container = self._get_container_by_sandbox_id(sandbox_id)\n        state = container.attrs.get(\"State\", {})\n        if not state.get(\"Running\", False):\n            raise HTTPException(\n                status_code=status.HTTP_409_CONFLICT,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_NOT_RUNNING,\n                    \"message\": \"Sandbox is not in a running state.\",\n                },\n            )\n\n        try:\n            with self._docker_operation(\"pause sandbox container\", sandbox_id):\n                container.pause()\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_PAUSE_FAILED,\n                    \"message\": f\"Failed to pause sandbox container: {str(exc)}\",\n                },\n            ) from exc\n\n    def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a paused sandbox using Docker.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or cannot be resumed\n        \"\"\"\n        container = self._get_container_by_sandbox_id(sandbox_id)\n        state = container.attrs.get(\"State\", {})\n        if not state.get(\"Paused\", False):\n            raise HTTPException(\n                status_code=status.HTTP_409_CONFLICT,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_NOT_PAUSED,\n                    \"message\": \"Sandbox is not in a paused state.\",\n                },\n            )\n\n        try:\n            with self._docker_operation(\"resume sandbox container\", sandbox_id):\n                container.unpause()\n        except DockerException as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.SANDBOX_RESUME_FAILED,\n                    \"message\": f\"Failed to resume sandbox container: {str(exc)}\",\n                },\n            ) from exc\n\n    def renew_expiration(\n        self,\n        sandbox_id: str,\n        request: RenewSandboxExpirationRequest,\n    ) -> RenewSandboxExpirationResponse:\n        \"\"\"\n        Renew sandbox expiration time.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            request: Renewal request with new expiration time\n\n        Returns:\n            RenewSandboxExpirationResponse: Updated expiration time\n\n        Raises:\n            HTTPException: If sandbox not found or renewal fails\n        \"\"\"\n        container = self._get_container_by_sandbox_id(sandbox_id)\n        new_expiration = ensure_future_expiration(request.expires_at)\n\n        labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n        if self._has_manual_cleanup(labels):\n            raise HTTPException(\n                status_code=status.HTTP_409_CONFLICT,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_EXPIRATION,\n                    \"message\": f\"Sandbox {sandbox_id} does not have automatic expiration enabled.\",\n                },\n            )\n        if self._get_tracked_expiration(sandbox_id, labels) is None:\n            raise HTTPException(\n                status_code=status.HTTP_409_CONFLICT,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_EXPIRATION,\n                    \"message\": (\n                        f\"Sandbox {sandbox_id} is missing expiration metadata and cannot be renewed safely.\"\n                    ),\n                },\n            )\n\n        # Persist the new timeout in memory; it will also be respected on restart via _restore_existing_sandboxes\n        self._schedule_expiration(sandbox_id, new_expiration)\n        labels[SANDBOX_EXPIRES_AT_LABEL] = new_expiration.isoformat()\n        try:\n            with self._docker_operation(\"update sandbox labels\", sandbox_id):\n                self._update_container_labels(container, labels)\n        except (DockerException, TypeError) as exc:\n            logger.warning(\"Failed to refresh labels for sandbox %s: %s\", sandbox_id, exc)\n\n        return RenewSandboxExpirationResponse(expires_at=new_expiration)\n\n    def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n        \"\"\"\n        Get sandbox access endpoint.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            port: Port number where the service is listening inside the sandbox\n            resolve_internal: If True, return the internal container IP (for proxy), ignoring router config.\n\n        Returns:\n            Endpoint: Public endpoint URL\n\n        Raises:\n            HTTPException: If sandbox not found or endpoint not available\n        \"\"\"\n        try:\n            self.validate_port(port)\n        except ValueError as exc:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PORT,\n                    \"message\": str(exc),\n                },\n            ) from exc\n\n        if resolve_internal:\n            container = self._get_container_by_sandbox_id(sandbox_id)\n            labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n            # Sandboxes created with egress sidecar share the sidecar network namespace, so the\n            # main container's private IP is not a stable proxy target. In that case, treat the\n            # server-proxy target as the server-local host-mapped endpoint instead of a container IP.\n            if labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY):\n                return self._resolve_host_mapped_endpoint(\n                    self._resolve_proxy_host(),\n                    labels,\n                    port,\n                )\n            return self._resolve_internal_endpoint(container, port)\n\n        public_host = self._resolve_public_host()\n\n        if self.network_mode == HOST_NETWORK_MODE:\n            endpoint = Endpoint(endpoint=f\"{public_host}:{port}\")\n            container = self._get_container_by_sandbox_id(sandbox_id)\n            self._attach_egress_auth_headers(\n                endpoint,\n                (container.attrs.get(\"Config\", {}).get(\"Labels\") or {}),\n            )\n            return endpoint\n\n        # non-host mode (bridge / user-defined network)\n        container = self._get_container_by_sandbox_id(sandbox_id)\n        labels = container.attrs.get(\"Config\", {}).get(\"Labels\") or {}\n        return self._resolve_host_mapped_endpoint(public_host, labels, port)\n\n    def _resolve_host_mapped_endpoint(\n        self,\n        public_host: str,\n        labels: dict[str, str],\n        port: int,\n    ) -> Endpoint:\n        execd_host_port = self._parse_host_port_label(\n            labels.get(SANDBOX_EMBEDDING_PROXY_PORT_LABEL),\n            SANDBOX_EMBEDDING_PROXY_PORT_LABEL,\n        )\n        http_host_port = self._parse_host_port_label(\n            labels.get(SANDBOX_HTTP_PORT_LABEL),\n            SANDBOX_HTTP_PORT_LABEL,\n        )\n\n        if port == 8080:\n            if http_host_port is None:\n                raise HTTPException(\n                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                    detail={\n                        \"code\": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,\n                        \"message\": \"Missing host port mapping for container port 8080.\",\n                    },\n                )\n            return Endpoint(endpoint=f\"{public_host}:{http_host_port}\")\n\n        if execd_host_port is None:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,\n                    \"message\": \"Missing host port mapping for execd proxy port 44772.\",\n                },\n            )\n\n        endpoint = Endpoint(endpoint=f\"{public_host}:{execd_host_port}/proxy/{port}\")\n        self._attach_egress_auth_headers(endpoint, labels)\n        return endpoint\n\n    def _attach_egress_auth_headers(\n        self,\n        endpoint: Endpoint,\n        labels: dict[str, str],\n    ) -> None:\n        token = labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY)\n        if not token:\n            return\n        endpoint.headers = merge_endpoint_headers(\n            endpoint.headers,\n            build_egress_auth_headers(token),\n        )\n\n    def _get_docker_host_ip(self) -> Optional[str]:\n        \"\"\"When running inside a container, return [docker].host_ip for endpoint URLs (if set).\"\"\"\n        ip = (self.app_config.docker.host_ip or \"\").strip()\n        return ip or None\n\n    def _resolve_public_host(self) -> str:\n        \"\"\"Resolve the host used in endpoint URLs. If [server].eip is set, use it directly without resolving host.\"\"\"\n        eip_cfg = (self.app_config.server.eip or \"\").strip()\n        if eip_cfg:\n            return eip_cfg\n        host_cfg = (self.app_config.server.host or \"\").strip()\n        host_key = host_cfg.lower()\n        if host_key in {\"\", \"0.0.0.0\", \"::\"}:\n            if _running_inside_docker_container():\n                host_ip = self._get_docker_host_ip()\n                if host_ip:\n                    return host_ip\n            return self._resolve_bind_ip(socket.AF_INET)\n        return host_cfg\n\n    def _resolve_proxy_host(self) -> str:\n        \"\"\"Resolve the server-local host used for proxying to host-mapped Docker endpoints.\n\n        This intentionally does not use ``server.eip`` because the proxy target must be reachable\n        from the server process itself, even in deployments without hairpin access to the public EIP.\n        \"\"\"\n        host_cfg = (self.app_config.server.host or \"\").strip()\n        host_key = host_cfg.lower()\n        if host_key in {\"\", \"0.0.0.0\", \"::\"}:\n            if _running_inside_docker_container():\n                host_ip = self._get_docker_host_ip()\n                if host_ip:\n                    return host_ip\n            return \"127.0.0.1\"\n        return host_cfg\n\n    def _resolve_internal_endpoint(self, container, port: int) -> Endpoint:\n        \"\"\"Return the internal endpoint used when bypassing host mapping.\"\"\"\n        if self.network_mode == HOST_NETWORK_MODE:\n            return Endpoint(endpoint=f\"127.0.0.1:{port}\")\n\n        ip_address = self._extract_bridge_ip(container)\n        return Endpoint(endpoint=f\"{ip_address}:{port}\")\n\n    # ---------------------------\n    # Common helpers for creation\n    # ---------------------------\n    def _build_labels_and_env(\n        self,\n        sandbox_id: str,\n        request: CreateSandboxRequest,\n        expires_at: Optional[datetime],\n    ) -> tuple[dict[str, str], list[str]]:\n        metadata = request.metadata or {}\n        labels = {key: str(value) for key, value in metadata.items()}\n        labels[SANDBOX_ID_LABEL] = sandbox_id\n        if expires_at is None:\n            labels[SANDBOX_MANUAL_CLEANUP_LABEL] = \"true\"\n        else:\n            labels[SANDBOX_EXPIRES_AT_LABEL] = expires_at.isoformat()\n\n        env_dict = request.env or {}\n        environment = []\n        for key, value in env_dict.items():\n            if value is None:\n                continue\n            environment.append(f\"{key}={value}\")\n        return labels, environment\n\n    def _resolve_image_auth(\n        self, request: CreateSandboxRequest, sandbox_id: str\n    ) -> tuple[str, Optional[dict]]:\n        image_uri = request.image.uri\n        auth_config = None\n        if request.image.auth:\n            auth_config = {\n                \"username\": request.image.auth.username,\n                \"password\": request.image.auth.password,\n            }\n        self._ensure_image_available(image_uri, auth_config, sandbox_id)\n        return image_uri, auth_config\n\n    def _resolve_resource_limits(\n        self, request: CreateSandboxRequest\n    ) -> tuple[Optional[int], Optional[int]]:\n        resource_limits = request.resource_limits.root or {}\n        mem_limit = parse_memory_limit(resource_limits.get(\"memory\"))\n        nano_cpus = parse_nano_cpus(resource_limits.get(\"cpu\"))\n        return mem_limit, nano_cpus\n\n    def _base_host_config_kwargs(\n        self,\n        mem_limit: Optional[int],\n        nano_cpus: Optional[int],\n        network_mode: str,\n    ) -> Dict[str, Any]:\n        host_config_kwargs: Dict[str, Any] = {\"network_mode\": network_mode}\n        security_opts: list[str] = []\n        docker_cfg = self.app_config.docker\n        if docker_cfg.no_new_privileges:\n            security_opts.append(\"no-new-privileges:true\")\n        if docker_cfg.apparmor_profile:\n            security_opts.append(f\"apparmor={docker_cfg.apparmor_profile}\")\n        if docker_cfg.seccomp_profile:\n            security_opts.append(f\"seccomp={docker_cfg.seccomp_profile}\")\n        if security_opts:\n            host_config_kwargs[\"security_opt\"] = security_opts\n        if docker_cfg.drop_capabilities:\n            host_config_kwargs[\"cap_drop\"] = docker_cfg.drop_capabilities\n        if docker_cfg.pids_limit is not None:\n            host_config_kwargs[\"pids_limit\"] = docker_cfg.pids_limit\n        if mem_limit:\n            host_config_kwargs[\"mem_limit\"] = mem_limit\n        if nano_cpus:\n            host_config_kwargs[\"nano_cpus\"] = nano_cpus\n        # Inject secure runtime into host_config\n        if self.docker_runtime:\n            logger.info(\n                \"Using Docker runtime '%s' for container creation\",\n                self.docker_runtime,\n            )\n            host_config_kwargs[\"runtime\"] = self.docker_runtime\n        return host_config_kwargs\n\n    def _allocate_distinct_host_ports(self) -> tuple[int, int]:\n        host_execd_port = self._allocate_host_port()\n        host_http_port = self._allocate_host_port()\n        if host_execd_port is None or host_http_port is None:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                    \"message\": \"Failed to allocate host ports for sandbox container.\",\n                },\n            )\n        while host_http_port == host_execd_port:\n            host_http_port = self._allocate_host_port()\n            if host_http_port is None:\n                raise HTTPException(\n                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                    detail={\n                        \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                        \"message\": \"Failed to allocate distinct host ports for sandbox container.\",\n                    },\n                )\n        return host_execd_port, host_http_port\n\n    def _cleanup_egress_sidecar(self, sandbox_id: str) -> None:\n        \"\"\"\n        Remove egress sidecar associated with sandbox_id (best effort).\n        \"\"\"\n        try:\n            containers = self.docker_client.containers.list(\n                all=True, filters={\"label\": f\"{EGRESS_SIDECAR_LABEL}={sandbox_id}\"}\n            )\n        except DockerException as exc:\n            logger.warning(\"sandbox=%s | failed to list egress sidecar: %s\", sandbox_id, exc)\n            return\n\n        for container in containers:\n            try:\n                with self._docker_operation(\"cleanup egress sidecar\", sandbox_id):\n                    container.remove(force=True)\n            except DockerException as exc:\n                logger.warning(\n                    \"sandbox=%s | failed to remove egress sidecar %s: %s\",\n                    sandbox_id,\n                    container.id,\n                    exc,\n                )\n\n    def _start_egress_sidecar(\n        self,\n        sandbox_id: str,\n        network_policy: NetworkPolicy,\n        egress_token: str,\n        host_execd_port: int,\n        host_http_port: int,\n    ):\n        sidecar_name = f\"sandbox-egress-{sandbox_id}\"\n        sidecar_labels = {\n            EGRESS_SIDECAR_LABEL: sandbox_id,\n        }\n\n        # Ensure sidecar image is available before create/start.\n        egress_image = self.app_config.egress.image if self.app_config.egress else None\n        if not egress_image:\n            raise ValueError(\"egress.image must be configured when networkPolicy is provided.\")\n        self._ensure_image_available(egress_image, None, sandbox_id)\n\n        policy_payload = json.dumps(network_policy.model_dump(by_alias=True, exclude_none=True))\n        assert self.app_config.egress is not None  # validated by ensure_egress_configured with networkPolicy\n        egress_mode = self.app_config.egress.mode\n        sidecar_env = [\n            f\"{EGRESS_RULES_ENV}={policy_payload}\",\n            f\"{EGRESS_MODE_ENV}={egress_mode}\",\n            f\"{OPENSANDBOX_EGRESS_TOKEN}={egress_token}\",\n        ]\n\n        sidecar_host_config_kwargs: dict[str, Any] = {\n            \"network_mode\": BRIDGE_NETWORK_MODE,\n            \"cap_add\": [\"NET_ADMIN\"],\n            \"port_bindings\": {\n                \"44772\": (\"0.0.0.0\", host_execd_port),\n                \"8080\": (\"0.0.0.0\", host_http_port),\n            },\n            # FIXME(Pangjiping): Disable IPv6 in the shared namespace to keep policy enforcement consistent.\n            \"sysctls\": {\n                \"net.ipv6.conf.all.disable_ipv6\": 1,\n                \"net.ipv6.conf.default.disable_ipv6\": 1,\n                \"net.ipv6.conf.lo.disable_ipv6\": 1,\n            },\n        }\n\n        sidecar_host_config = self.docker_client.api.create_host_config(\n            **sidecar_host_config_kwargs\n        )\n\n        sidecar_container = None\n        sidecar_container_id: Optional[str] = None\n        try:\n            with self._docker_operation(\"create egress sidecar\", sandbox_id):\n                sidecar_resp = self.docker_client.api.create_container(\n                    image=egress_image,\n                    name=sidecar_name,\n                    host_config=sidecar_host_config,\n                    labels=sidecar_labels,\n                    environment=sidecar_env,\n                    # Expose the ports that have host bindings so Docker publishes them in bridge mode.\n                    ports=[\"44772\", \"8080\"],\n                )\n            sidecar_container_id = sidecar_resp.get(\"Id\")\n            if not sidecar_container_id:\n                raise HTTPException(\n                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                    detail={\n                        \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                        \"message\": \"Docker did not return an egress sidecar container ID.\",\n                    },\n                )\n            sidecar_container = self.docker_client.containers.get(sidecar_container_id)\n            with self._docker_operation(\"start egress sidecar\", sandbox_id):\n                sidecar_container.start()\n            return sidecar_container\n        except Exception as exc:\n            if sidecar_container is not None:\n                try:\n                    with self._docker_operation(\"cleanup egress sidecar\", sandbox_id):\n                        sidecar_container.remove(force=True)\n                except DockerException as cleanup_exc:\n                    logger.warning(\n                        \"Failed to cleanup egress sidecar for sandbox %s: %s\",\n                        sandbox_id,\n                        cleanup_exc,\n                    )\n            elif sidecar_container_id:\n                try:\n                    with self._docker_operation(\"cleanup egress sidecar (API)\", sandbox_id):\n                        self.docker_client.api.remove_container(sidecar_container_id, force=True)\n                except DockerException as cleanup_exc:\n                    logger.warning(\n                        \"Failed to cleanup egress sidecar for sandbox %s: %s\",\n                        sandbox_id,\n                        cleanup_exc,\n                    )\n            if isinstance(exc, HTTPException):\n                raise exc\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                    \"message\": \"Egress sidecar container failed to start.\",\n                },\n            ) from exc\n\n    def _create_and_start_container(\n        self,\n        sandbox_id: str,\n        image_uri: str,\n        bootstrap_command: list[str],\n        labels: dict[str, str],\n        environment: list[str],\n        host_config_kwargs: Dict[str, Any],\n        exposed_ports: Optional[list[str]],\n    ):\n        # Normalize single-string entrypoint containing spaces to avoid shell path issues in bootstrap.\n        if len(bootstrap_command) == 1 and \" \" in bootstrap_command[0]:\n            import shlex\n\n            bootstrap_command = shlex.split(bootstrap_command[0])\n\n        host_config = self.docker_client.api.create_host_config(**host_config_kwargs)\n        container = None\n        container_id: Optional[str] = None\n        try:\n            with self._docker_operation(\"create sandbox container\", sandbox_id):\n                container_kwargs = {\n                    \"image\": image_uri,\n                    \"entrypoint\": [BOOTSTRAP_PATH],\n                    \"command\": bootstrap_command,\n                    \"ports\": exposed_ports,\n                    \"name\": f\"sandbox-{sandbox_id}\",\n                    \"environment\": environment,\n                    \"labels\": labels,\n                    \"host_config\": host_config,\n                }\n\n                response = self.docker_client.api.create_container(**container_kwargs)\n            container_id = response.get(\"Id\")\n            if not container_id:\n                raise HTTPException(\n                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                    detail={\n                        \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                        \"message\": \"Docker did not return a container ID.\",\n                    },\n                )\n            container = self.docker_client.containers.get(container_id)\n            self._prepare_sandbox_runtime(container, sandbox_id)\n            with self._docker_operation(\"start sandbox container\", sandbox_id):\n                container.start()\n            return container\n        except Exception as exc:\n            if container is not None:\n                try:\n                    with self._docker_operation(\"cleanup sandbox container\", sandbox_id):\n                        container.remove(force=True)\n                except DockerException as cleanup_exc:\n                    logger.warning(\n                        \"Failed to cleanup container for sandbox %s: %s\",\n                        sandbox_id,\n                        cleanup_exc,\n                    )\n            elif container_id:\n                try:\n                    with self._docker_operation(\"cleanup sandbox container (API)\", sandbox_id):\n                        self.docker_client.api.remove_container(container_id, force=True)\n                except DockerException as cleanup_exc:\n                    logger.warning(\n                        \"Failed to cleanup container for sandbox %s: %s\",\n                        sandbox_id,\n                        cleanup_exc,\n                    )\n\n            if isinstance(exc, HTTPException):\n                raise exc\n\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.CONTAINER_START_FAILED,\n                    \"message\": f\"Failed to create or start container: {str(exc)}\",\n                },\n            ) from exc\n\n    @staticmethod\n    def _parse_host_port_label(value: Optional[str], label_name: str) -> Optional[int]:\n        if not value:\n            return None\n        try:\n            port = int(value)\n            if port <= 0 or port > 65535:\n                raise ValueError\n            return port\n        except ValueError:\n            logger.warning(\"Invalid port label %s=%s\", label_name, value)\n            return None\n\n    def _extract_bridge_ip(self, container) -> str:\n        \"\"\"Extract the IP address assigned to a container on a bridge or user-defined network.\n\n        For user-defined networks, the top-level ``NetworkSettings.IPAddress`` is empty;\n        the IP lives under ``NetworkSettings.Networks[<network-name>].IPAddress``.\n        This method prefers the configured ``network_mode`` entry when it is a user-defined\n        network, then falls back to any non-empty entry for robustness.\n        \"\"\"\n        network_settings = container.attrs.get(\"NetworkSettings\", {}) or {}\n        networks = network_settings.get(\"Networks\", {}) or {}\n        ip_address: Optional[str] = None\n\n        if self._is_user_defined_network():\n            # Prefer the explicit network entry for the configured named network.\n            net_conf = networks.get(self.network_mode) or {}\n            ip_address = net_conf.get(\"IPAddress\") or None\n\n        if not ip_address:\n            # Default bridge path (or fallback): check the top-level IPAddress first.\n            ip_address = network_settings.get(\"IPAddress\") or None\n\n        if not ip_address:\n            # Last resort: iterate all network entries and take the first populated IP.\n            for net_conf in networks.values():\n                if net_conf and net_conf.get(\"IPAddress\"):\n                    ip_address = net_conf.get(\"IPAddress\")\n                    break\n\n        if not ip_address:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,\n                    \"message\": \"Container is running but has no assigned IP address.\",\n                },\n            )\n        return ip_address\n"
  },
  {
    "path": "server/src/services/endpoint_auth.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Helpers for sandbox endpoint authentication.\"\"\"\n\nfrom __future__ import annotations\n\nimport secrets\n\nfrom src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER\n\nEGRESS_AUTH_TOKEN_BYTES = 24\n\n\ndef generate_egress_token() -> str:\n    \"\"\"Return a random URL-safe token for egress endpoint auth.\"\"\"\n    return secrets.token_urlsafe(EGRESS_AUTH_TOKEN_BYTES)\n\n\ndef build_egress_auth_headers(token: str) -> dict[str, str]:\n    \"\"\"Build endpoint headers for egress auth.\"\"\"\n    return {OPEN_SANDBOX_EGRESS_AUTH_HEADER: token}\n\n\ndef merge_endpoint_headers(\n    existing: dict[str, str] | None,\n    extra: dict[str, str],\n) -> dict[str, str]:\n    \"\"\"Merge auth headers into existing endpoint headers without mutating input.\"\"\"\n    merged: dict[str, str] = dict(existing or {})\n    merged.update(extra)\n    return merged\n\n\n__all__ = [\n    \"EGRESS_AUTH_TOKEN_BYTES\",\n    \"build_egress_auth_headers\",\n    \"generate_egress_token\",\n    \"merge_endpoint_headers\",\n]\n"
  },
  {
    "path": "server/src/services/factory.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nFactory for creating sandbox service instances.\n\nThis module provides a factory function to create sandbox service implementations\nbased on application configuration loaded from sandbox_server.config.\n\"\"\"\n\nimport logging\nfrom typing import Optional\n\nfrom src.config import AppConfig, get_config\nfrom src.services.docker import DockerSandboxService\nfrom src.services.k8s import KubernetesSandboxService\nfrom src.services.sandbox_service import SandboxService\n\nlogger = logging.getLogger(__name__)\n\n\ndef create_sandbox_service(\n    service_type: Optional[str] = None,\n    config: Optional[AppConfig] = None,\n) -> SandboxService:\n    \"\"\"\n    Create a sandbox service instance based on configuration.\n\n    Args:\n        service_type: Optional override for service implementation type.\n        config: Optional application configuration. Defaults to global config.\n\n    Returns:\n        SandboxService: An instance of the configured sandbox service implementation.\n\n    Raises:\n        ValueError: If the configured service type is not supported.\n    \"\"\"\n    active_config = config or get_config()\n    selected_type = (service_type or active_config.runtime.type).lower()\n\n    logger.info(\"Creating sandbox service with type: %s\", selected_type)\n\n    # Service implementation registry\n    # Add new implementations here as they are created\n    implementations: dict[str, type[SandboxService]] = {\n        \"docker\": DockerSandboxService,\n        \"kubernetes\": KubernetesSandboxService,\n        # Future implementations can be added here:\n        # \"containerd\": ContainerdSandboxService,\n    }\n\n    if selected_type not in implementations:\n        supported_types = \", \".join(implementations.keys())\n        raise ValueError(\n            f\"Unsupported sandbox service type: {selected_type}. \"\n            f\"Supported types: {supported_types}\"\n        )\n\n    implementation_class = implementations[selected_type]\n    return implementation_class(config=active_config)\n"
  },
  {
    "path": "server/src/services/helpers.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nShared helpers for container-based sandbox services.\n\nThese utilities centralize common parsing, filtering, and transformation logic\nso multiple container runtimes (docker, kubernetes, etc.) can reuse them.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport re\nfrom datetime import datetime, timezone\nfrom typing import Dict, Optional\n\nfrom src.api.schema import Endpoint, Sandbox, SandboxFilter\nfrom src.services.constants import OPEN_SANDBOX_INGRESS_HEADER\nfrom src.config import (\n    GATEWAY_ROUTE_MODE_HEADER,\n    GATEWAY_ROUTE_MODE_URI,\n    GATEWAY_ROUTE_MODE_WILDCARD,\n    INGRESS_MODE_GATEWAY,\n    IngressConfig,\n)\n\nlogger = logging.getLogger(__name__)\n\nMEMORY_PATTERN = re.compile(r\"^\\s*(\\d+)([kmgti]i?|[kmgti]?b)?\\s*$\", re.IGNORECASE)\nMEMORY_MULTIPLIERS: Dict[str, int] = {\n    \"\": 1,\n    \"b\": 1,\n    \"k\": 1_000,\n    \"kb\": 1_000,\n    \"ki\": 1024,\n    \"m\": 1_000_000,\n    \"mb\": 1_000_000,\n    \"mi\": 1024**2,\n    \"g\": 1_000_000_000,\n    \"gb\": 1_000_000_000,\n    \"gi\": 1024**3,\n    \"t\": 1_000_000_000_000,\n    \"tb\": 1_000_000_000_000,\n    \"ti\": 1024**4,\n}\n\n\ndef parse_memory_limit(value: Optional[str]) -> Optional[int]:\n    \"\"\"Convert memory string (e.g., 512Mi) to bytes.\"\"\"\n    if not value:\n        return None\n    match = MEMORY_PATTERN.match(value)\n    if not match:\n        logger.warning(\"Invalid memory limit format '%s'; ignoring.\", value)\n        return None\n    amount = int(match.group(1))\n    unit = (match.group(2) or \"\").lower()\n    multiplier = MEMORY_MULTIPLIERS.get(unit)\n    if not multiplier:\n        logger.warning(\"Unsupported memory unit '%s'; ignoring.\", unit)\n        return None\n    return amount * multiplier\n\n\ndef parse_nano_cpus(value: Optional[str]) -> Optional[int]:\n    \"\"\"Convert CPU string (e.g., 500m, 2) to nano_cpus.\"\"\"\n    if not value:\n        return None\n    cpu_str = value.strip().lower()\n    try:\n        if cpu_str.endswith(\"m\"):\n            cpus = float(cpu_str[:-1]) / 1000\n        else:\n            cpus = float(cpu_str)\n    except ValueError:\n        logger.warning(\"Invalid CPU limit format '%s'; ignoring.\", value)\n        return None\n    if cpus <= 0:\n        logger.warning(\"CPU limit must be positive. Got '%s'. Ignoring.\", value)\n        return None\n    return int(cpus * 1_000_000_000)\n\n\ndef parse_timestamp(timestamp: Optional[str]) -> datetime:\n    \"\"\"\n    Parse RFC3339 timestamp into timezone-aware datetime. Fallback to now.\n\n    Docker often returns RFC3339Nano (up to 9 fractional digits). Python's\n    datetime.fromisoformat only supports microseconds (6 digits), so we\n    truncate the fractional part to 6 digits before parsing.\n    \"\"\"\n    if not timestamp or timestamp == \"0001-01-01T00:00:00Z\":\n        return datetime.now(timezone.utc)\n\n    normalized = timestamp\n    if normalized.endswith(\"Z\"):\n        normalized = normalized[:-1] + \"+00:00\"\n\n    if \".\" in normalized:\n        main, rest = normalized.split(\".\", 1)\n        tz_sep = None\n        for sep in (\"+\", \"-\"):\n            pos = rest.find(sep)\n            if pos != -1:\n                tz_sep = pos\n                break\n        if tz_sep is None:\n            frac = rest\n            tz = \"\"\n        else:\n            frac = rest[:tz_sep]\n            tz = rest[tz_sep:]\n        frac = frac[:6]  # truncate to microseconds precision\n        normalized = f\"{main}.{frac}{tz}\" if frac else f\"{main}{tz}\"\n\n    try:\n        return datetime.fromisoformat(normalized)\n    except ValueError:\n        logger.warning(\"Invalid timestamp '%s'; defaulting to current time.\", timestamp)\n        return datetime.now(timezone.utc)\n\n\ndef normalize_external_endpoint_url(endpoint: str, default_scheme: str = \"https\") -> str:\n    \"\"\"Normalize host or URL to a full URL with an explicit scheme.\"\"\"\n    endpoint = endpoint.strip()\n    if endpoint.startswith(\"http://\") or endpoint.startswith(\"https://\"):\n        return endpoint\n    return f\"{default_scheme}://{endpoint}\"\n\n\ndef matches_filter(sandbox: Sandbox, filter_: SandboxFilter) -> bool:\n    \"\"\"Apply state/metadata filters to a sandbox instance.\"\"\"\n    if not filter_:\n        return True\n    if filter_.state:\n        desired = {state.lower() for state in filter_.state}\n        current_state = (sandbox.status.state or \"\").lower()\n        if current_state not in desired:\n            return False\n    if filter_.metadata:\n        metadata = sandbox.metadata or {}\n        for key, value in filter_.metadata.items():\n            if metadata.get(key) != value:\n                return False\n    return True\n\n\n# ============================================================================\n# Ingress helpers\n# ============================================================================\ndef format_ingress_endpoint(\n    ingress_config: Optional[IngressConfig],\n    sandbox_id: str,\n    port: int,\n) -> Optional[Endpoint]:\n    \"\"\"\n    Build an ingress-based endpoint string for a sandbox.\n\n    Returns None when ingress is not in gateway mode.\n    \"\"\"\n    if not ingress_config or ingress_config.mode != INGRESS_MODE_GATEWAY:\n        return None\n    gateway_cfg = ingress_config.gateway\n    if gateway_cfg is None:\n        return None\n\n    address = gateway_cfg.address\n    route_mode = gateway_cfg.route.mode\n\n    if route_mode == GATEWAY_ROUTE_MODE_WILDCARD:\n        base = address[2:] if address.startswith(\"*.\") else address\n        return Endpoint(endpoint=f\"{sandbox_id}-{port}.{base}\")\n\n    if route_mode == GATEWAY_ROUTE_MODE_URI:\n        return Endpoint(endpoint=f\"{address}/{sandbox_id}/{port}\")\n\n    if route_mode == GATEWAY_ROUTE_MODE_HEADER:\n        header_value = f\"{sandbox_id}-{port}\"\n        return Endpoint(\n            endpoint=address,\n            headers={OPEN_SANDBOX_INGRESS_HEADER: header_value},\n        )\n\n    raise RuntimeError(f\"Unsupported route mode: {route_mode}\")\n\n\n__all__ = [\n    \"parse_memory_limit\",\n    \"parse_nano_cpus\",\n    \"parse_timestamp\",\n    \"normalize_external_endpoint_url\",\n    \"format_ingress_endpoint\",\n    \"matches_filter\",\n]\n"
  },
  {
    "path": "server/src/services/k8s/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nKubernetes runtime implementation for OpenSandbox.\n\"\"\"\n\nfrom src.services.k8s.kubernetes_service import KubernetesSandboxService\nfrom src.services.k8s.provider_factory import (\n    create_workload_provider,\n    register_provider,\n    list_available_providers,\n    PROVIDER_TYPE_BATCHSANDBOX,\n    PROVIDER_TYPE_AGENT_SANDBOX,\n)\n\n__all__ = [\n    \"KubernetesSandboxService\",\n    \"create_workload_provider\",\n    \"register_provider\",\n    \"list_available_providers\",\n    \"PROVIDER_TYPE_BATCHSANDBOX\",\n    \"PROVIDER_TYPE_AGENT_SANDBOX\",\n]\n"
  },
  {
    "path": "server/src/services/k8s/agent_sandbox_provider.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAgent-sandbox workload provider implementation.\n\"\"\"\n\nimport hashlib\nimport logging\nimport re\nfrom datetime import datetime\nfrom typing import Dict, List, Any, Optional\n\nfrom kubernetes.client import (\n    V1Container,\n    V1EnvVar,\n    V1ResourceRequirements,\n    V1VolumeMount,\n)\n\nfrom src.config import AppConfig, EGRESS_MODE_DNS\nfrom src.services.helpers import format_ingress_endpoint\nfrom src.api.schema import Endpoint, ImageSpec, NetworkPolicy, Volume\nfrom src.services.k8s.agent_sandbox_template import AgentSandboxTemplateManager\nfrom src.services.k8s.client import K8sClient\nfrom src.services.k8s.egress_helper import (\n    apply_egress_to_spec,\n    build_security_context_for_sandbox_container,\n    prep_execd_init_for_egress,\n)\nfrom src.services.k8s.security_context import (\n    build_security_context_from_dict,\n    serialize_security_context_to_dict,\n)\nfrom src.services.k8s.volume_helper import apply_volumes_to_pod_spec\nfrom src.services.k8s.workload_provider import WorkloadProvider\nfrom src.services.runtime_resolver import SecureRuntimeResolver\n\nlogger = logging.getLogger(__name__)\n\nDNS1035_LABEL_MAX_LENGTH = 63\nDNS1035_INVALID_CHARS = re.compile(r\"[^a-z0-9-]+\")\nDNS1035_DUPLICATE_HYPHENS = re.compile(r\"-+\")\n\n\ndef _to_dns1035_label(value: str, prefix: str = \"sandbox\") -> str:\n    normalized = DNS1035_INVALID_CHARS.sub(\"-\", value.strip().lower())\n    normalized = DNS1035_DUPLICATE_HYPHENS.sub(\"-\", normalized).strip(\"-\")\n\n    hash_suffix = hashlib.sha256(value.encode(\"utf-8\")).hexdigest()[:8]\n\n    if not normalized:\n        normalized = f\"{prefix}-{hash_suffix}\"\n    elif not normalized[0].isalpha():\n        normalized = f\"{prefix}-{normalized}\"\n\n    if len(normalized) > DNS1035_LABEL_MAX_LENGTH:\n        max_base = DNS1035_LABEL_MAX_LENGTH - len(hash_suffix) - 1\n        base = normalized[:max_base].rstrip(\"-\")\n        if not base or not base[0].isalpha():\n            base = prefix\n        normalized = f\"{base}-{hash_suffix}\"\n\n    return normalized.strip(\"-\")\n\n\nclass AgentSandboxProvider(WorkloadProvider):\n    \"\"\"\n    Workload provider using kubernetes-sigs/agent-sandbox Sandbox CRD.\n    \"\"\"\n\n    def __init__(\n        self,\n        k8s_client: K8sClient,\n        app_config: Optional[AppConfig] = None,\n    ):\n        self.k8s_client = k8s_client\n\n        self.group = \"agents.x-k8s.io\"\n        self.version = \"v1alpha1\"\n        self.plural = \"sandboxes\"\n\n        k8s_config = app_config.kubernetes if app_config else None\n        agent_config = app_config.agent_sandbox if app_config else None\n\n        self.shutdown_policy = agent_config.shutdown_policy if agent_config else \"Delete\"\n        self.service_account = k8s_config.service_account if k8s_config else None\n        self.template_manager = AgentSandboxTemplateManager(\n            agent_config.template_file if agent_config else None\n        )\n        self.ingress_config = app_config.ingress if app_config else None\n        self.execd_init_resources = k8s_config.execd_init_resources if k8s_config else None\n\n        # Initialize secure runtime resolver\n        self.resolver = SecureRuntimeResolver(app_config) if app_config else None\n        self.runtime_class = (\n            self.resolver.get_k8s_runtime_class() if self.resolver else None\n        )\n\n    def _resource_name(self, sandbox_id: str) -> str:\n        return _to_dns1035_label(sandbox_id, prefix=\"sandbox\")\n\n    def _resource_name_candidates(self, sandbox_id: str) -> List[str]:\n        candidates = []\n        primary = self._resource_name(sandbox_id)\n        candidates.append(primary)\n        if sandbox_id not in candidates:\n            candidates.append(sandbox_id)\n        legacy = self.legacy_resource_name(sandbox_id)\n        if legacy not in candidates:\n            candidates.append(legacy)\n        return candidates\n\n    def create_workload(\n        self,\n        sandbox_id: str,\n        namespace: str,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        labels: Dict[str, str],\n        expires_at: Optional[datetime],\n        execd_image: str,\n        extensions: Optional[Dict[str, str]] = None,\n        network_policy: Optional[NetworkPolicy] = None,\n        egress_image: Optional[str] = None,\n        volumes: Optional[List[Volume]] = None,\n        annotations: Optional[Dict[str, str]] = None,\n        egress_auth_token: Optional[str] = None,\n        egress_mode: str = EGRESS_MODE_DNS,\n    ) -> Dict[str, Any]:\n        \"\"\"Create an agent-sandbox Sandbox CRD workload.\"\"\"\n        if self.runtime_class:\n            logger.info(\n                \"Using Kubernetes RuntimeClass '%s' for sandbox %s\",\n                self.runtime_class,\n                sandbox_id,\n            )\n\n        pod_spec = self._build_pod_spec(\n            image_spec=image_spec,\n            entrypoint=entrypoint,\n            env=env,\n            resource_limits=resource_limits,\n            execd_image=execd_image,\n            network_policy=network_policy,\n            egress_image=egress_image,\n            egress_auth_token=egress_auth_token,\n            egress_mode=egress_mode,\n        )\n\n        # Add user-specified volumes if provided\n        if volumes:\n            apply_volumes_to_pod_spec(pod_spec, volumes)\n\n        if self.service_account:\n            pod_spec[\"serviceAccountName\"] = self.service_account\n\n        resource_name = self._resource_name(sandbox_id)\n        spec = {\n            \"replicas\": 1,\n            \"shutdownPolicy\": self.shutdown_policy,\n            \"podTemplate\": {\n                \"metadata\": {\n                    \"labels\": labels,\n                },\n                \"spec\": pod_spec,\n            },\n        }\n        runtime_manifest = {\n            \"apiVersion\": f\"{self.group}/{self.version}\",\n            \"kind\": \"Sandbox\",\n            \"metadata\": {\n                \"name\": resource_name,\n                \"namespace\": namespace,\n                \"labels\": labels,\n            },\n            \"spec\": spec,\n        }\n        if annotations:\n            runtime_manifest[\"metadata\"][\"annotations\"] = annotations\n\n        sandbox = self.template_manager.merge_with_runtime_values(runtime_manifest)\n        # Set or strip shutdownTime after merge so we override any template value\n        if expires_at is None:\n            sandbox[\"spec\"].pop(\"shutdownTime\", None)\n        else:\n            sandbox[\"spec\"][\"shutdownTime\"] = expires_at.isoformat()\n\n        created = self.k8s_client.create_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            body=sandbox,\n        )\n\n        return {\n            \"name\": created[\"metadata\"][\"name\"],\n            \"uid\": created[\"metadata\"][\"uid\"],\n        }\n\n    def _build_pod_spec(\n        self,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        execd_image: str,\n        network_policy: Optional[NetworkPolicy] = None,\n        egress_image: Optional[str] = None,\n        egress_auth_token: Optional[str] = None,\n        egress_mode: str = EGRESS_MODE_DNS,\n    ) -> Dict[str, Any]:\n        \"\"\"Build pod spec dict for the Sandbox CRD.\"\"\"\n        disable_ipv6_for_egress = network_policy is not None and egress_image is not None\n        init_container = self._build_execd_init_container(\n            execd_image, disable_ipv6_for_egress=disable_ipv6_for_egress\n        )\n        main_container = self._build_main_container(\n            image_spec=image_spec,\n            entrypoint=entrypoint,\n            env=env,\n            resource_limits=resource_limits,\n            include_execd_volume=True,\n            has_network_policy=network_policy is not None,\n        )\n        \n        containers = [self._container_to_dict(main_container)]\n        \n        # Build base pod spec\n        pod_spec: Dict[str, Any] = {\n            \"initContainers\": [self._container_to_dict(init_container)],\n            \"containers\": containers,\n            \"volumes\": [\n                {\n                    \"name\": \"opensandbox-bin\",\n                    \"emptyDir\": {},\n                }\n            ],\n        }\n\n        # Inject runtimeClassName if secure runtime is configured\n        if self.runtime_class:\n            pod_spec[\"runtimeClassName\"] = self.runtime_class\n\n        # Add egress sidecar if network policy is provided\n        apply_egress_to_spec(\n            containers=containers,\n            network_policy=network_policy,\n            egress_image=egress_image,\n            egress_auth_token=egress_auth_token,\n            egress_mode=egress_mode,\n        )\n\n        return pod_spec\n\n    def _build_execd_init_container(\n        self,\n        execd_image: str,\n        *,\n        disable_ipv6_for_egress: bool = False,\n    ) -> V1Container:\n        \"\"\"Build init container that copies execd binary to the shared volume.\"\"\"\n        script = (\n            \"cp ./execd /opt/opensandbox/bin/execd && \"\n            \"cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && \"\n            \"chmod +x /opt/opensandbox/bin/execd && \"\n            \"chmod +x /opt/opensandbox/bin/bootstrap.sh\"\n        )\n        security_context = None\n        if disable_ipv6_for_egress:\n            script, sc_dict = prep_execd_init_for_egress(script)\n            security_context = build_security_context_from_dict(sc_dict)\n\n        resources = None\n        if self.execd_init_resources:\n            resources = V1ResourceRequirements(\n                limits=self.execd_init_resources.limits,\n                requests=self.execd_init_resources.requests,\n            )\n\n        return V1Container(\n            name=\"execd-installer\",\n            image=execd_image,\n            command=[\"/bin/sh\", \"-c\"],\n            args=[script],\n            volume_mounts=[\n                V1VolumeMount(\n                    name=\"opensandbox-bin\",\n                    mount_path=\"/opt/opensandbox/bin\",\n                )\n            ],\n            resources=resources,\n            security_context=security_context,\n        )\n\n    def _build_main_container(\n        self,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        include_execd_volume: bool,\n        has_network_policy: bool = False,\n    ) -> V1Container:\n        env_vars = [V1EnvVar(name=k, value=v) for k, v in env.items()]\n        env_vars.append(V1EnvVar(name=\"EXECD\", value=\"/opt/opensandbox/bin/execd\"))\n\n        resources = None\n        if resource_limits:\n            resources = V1ResourceRequirements(\n                limits=resource_limits,\n                requests=resource_limits,\n            )\n\n        wrapped_command = [\"/opt/opensandbox/bin/bootstrap.sh\"] + entrypoint\n\n        volume_mounts = None\n        if include_execd_volume:\n            volume_mounts = [\n                V1VolumeMount(\n                    name=\"opensandbox-bin\",\n                    mount_path=\"/opt/opensandbox/bin\",\n                )\n            ]\n\n        # Apply security context when network policy is enabled\n        security_context = None\n        if has_network_policy:\n            security_context_dict = build_security_context_for_sandbox_container(True)\n            security_context = build_security_context_from_dict(security_context_dict)\n\n        return V1Container(\n            name=\"sandbox\",\n            image=image_spec.uri,\n            command=wrapped_command,\n            env=env_vars if env_vars else None,\n            resources=resources,\n            volume_mounts=volume_mounts,\n            security_context=security_context,\n        )\n\n    def _container_to_dict(self, container: V1Container) -> Dict[str, Any]:\n        \"\"\"Convert a V1Container object to a plain dict for CRD body.\"\"\"\n        result: Dict[str, Any] = {\n            \"name\": container.name,\n            \"image\": container.image,\n        }\n\n        if container.command:\n            result[\"command\"] = container.command\n        if container.args:\n            result[\"args\"] = container.args\n        if container.env:\n            result[\"env\"] = [{\"name\": e.name, \"value\": e.value} for e in container.env]\n        if container.resources:\n            result[\"resources\"] = {}\n            if container.resources.limits:\n                result[\"resources\"][\"limits\"] = container.resources.limits\n            if container.resources.requests:\n                result[\"resources\"][\"requests\"] = container.resources.requests\n        if container.volume_mounts:\n            result[\"volumeMounts\"] = [\n                {\"name\": vm.name, \"mountPath\": vm.mount_path}\n                for vm in container.volume_mounts\n            ]\n        if container.security_context:\n            security_context_dict = serialize_security_context_to_dict(container.security_context)\n            if security_context_dict:\n                result[\"securityContext\"] = security_context_dict\n\n        return result\n\n    def get_workload(self, sandbox_id: str, namespace: str) -> Optional[Dict[str, Any]]:\n        \"\"\"Get Sandbox CRD by sandbox ID, trying all candidate resource names.\"\"\"\n        candidates = self._resource_name_candidates(sandbox_id)\n\n        for name in candidates:\n            workload = self.k8s_client.get_custom_object(\n                group=self.group,\n                version=self.version,\n                namespace=namespace,\n                plural=self.plural,\n                name=name,\n            )\n            if workload:\n                return workload\n\n        return None\n\n    def delete_workload(self, sandbox_id: str, namespace: str) -> None:\n        \"\"\"Delete the Sandbox CRD for the given sandbox ID.\"\"\"\n        sandbox = self.get_workload(sandbox_id, namespace)\n        if not sandbox:\n            raise Exception(f\"Sandbox for sandbox {sandbox_id} not found\")\n\n        self.k8s_client.delete_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            name=sandbox[\"metadata\"][\"name\"],\n            grace_period_seconds=0,\n        )\n\n    def list_workloads(self, namespace: str, label_selector: str) -> List[Dict[str, Any]]:\n        \"\"\"List Sandbox CRDs matching the given label selector.\"\"\"\n        return self.k8s_client.list_custom_objects(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            label_selector=label_selector,\n        )\n\n    def update_expiration(self, sandbox_id: str, namespace: str, expires_at: datetime) -> None:\n        \"\"\"Patch the Sandbox CRD shutdownTime field.\"\"\"\n        sandbox = self.get_workload(sandbox_id, namespace)\n        if not sandbox:\n            raise Exception(f\"Sandbox for sandbox {sandbox_id} not found\")\n\n        body = {\n            \"spec\": {\n                \"shutdownTime\": expires_at.isoformat(),\n            }\n        }\n\n        self.k8s_client.patch_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            name=sandbox[\"metadata\"][\"name\"],\n            body=body,\n        )\n\n    def get_expiration(self, workload: Dict[str, Any]) -> Optional[datetime]:\n        \"\"\"Parse shutdownTime from Sandbox CRD spec.\"\"\"\n        spec = workload.get(\"spec\", {})\n        shutdown_time_str = spec.get(\"shutdownTime\")\n\n        if not shutdown_time_str:\n            return None\n\n        try:\n            return datetime.fromisoformat(shutdown_time_str.replace(\"Z\", \"+00:00\"))\n        except (ValueError, TypeError) as e:\n            logger.warning(\"Invalid shutdownTime format: %s, error: %s\", shutdown_time_str, e)\n            return None\n\n    def get_status(self, workload: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"Derive sandbox state from the Sandbox CRD status conditions.\"\"\"\n        status = workload.get(\"status\", {})\n        conditions = status.get(\"conditions\", [])\n\n        ready_condition = None\n        for condition in conditions:\n            if condition.get(\"type\") == \"Ready\":\n                ready_condition = condition\n                break\n\n        creation_timestamp = workload.get(\"metadata\", {}).get(\"creationTimestamp\")\n\n        if not ready_condition:\n            pod_state = self._pod_state_from_selector(workload)\n            if pod_state:\n                state, reason, message = pod_state\n                return {\n                    \"state\": state,\n                    \"reason\": reason,\n                    \"message\": message,\n                    \"last_transition_at\": creation_timestamp,\n                }\n            return {\n                \"state\": \"Pending\",\n                \"reason\": \"SANDBOX_PENDING\",\n                \"message\": \"Sandbox is pending scheduling\",\n                \"last_transition_at\": creation_timestamp,\n            }\n\n        cond_status = ready_condition.get(\"status\")\n        reason = ready_condition.get(\"reason\")\n        message = ready_condition.get(\"message\")\n        last_transition_at = ready_condition.get(\"lastTransitionTime\") or creation_timestamp\n\n        if cond_status == \"True\":\n            state = \"Running\"\n        elif reason == \"SandboxExpired\":\n            state = \"Terminated\"\n        elif cond_status == \"False\":\n            state = \"Pending\"\n        else:\n            state = \"Pending\"\n\n        return {\n            \"state\": state,\n            \"reason\": reason,\n            \"message\": message,\n            \"last_transition_at\": last_transition_at,\n        }\n\n    def _pod_state_from_selector(self, workload: Dict[str, Any]) -> Optional[tuple[str, str, str]]:\n        \"\"\"Resolve state from Pod list via label selector.\n\n        Returns three-state tuple (state, reason, message):\n        - Running: Pod phase Running and has IP\n        - Allocated: Pod has IP assigned but not Running yet\n        - Pending: Pod scheduled but no IP yet\n        Returns None if selector/namespace missing or API call fails.\n        \"\"\"\n        status = workload.get(\"status\", {})\n        selector = status.get(\"selector\")\n        namespace = workload.get(\"metadata\", {}).get(\"namespace\")\n        if not selector or not namespace:\n            return None\n\n        try:\n            pods = self.k8s_client.list_pods(\n                namespace=namespace,\n                label_selector=selector,\n            )\n        except Exception:\n            return None\n\n        for pod in pods:\n            if pod.status:\n                if pod.status.pod_ip and pod.status.phase == \"Running\":\n                    return (\n                        \"Running\",\n                        \"POD_READY\",\n                        \"Pod is running with IP assigned\",\n                    )\n                if pod.status.pod_ip:\n                    return (\n                        \"Allocated\",\n                        \"IP_ASSIGNED\",\n                        \"Pod has IP assigned but not running yet\",\n                    )\n                return (\n                    \"Pending\",\n                    \"POD_SCHEDULED\",\n                    \"Pod is scheduled but waiting for IP assignment\",\n                )\n\n        if pods:\n            return (\"Pending\", \"POD_PENDING\", \"Pod is pending\")\n\n        return None\n\n    def get_endpoint_info(self, workload: Dict[str, Any], port: int, sandbox_id: str) -> Optional[Endpoint]:\n        # ingress-based endpoint if configured (gateway)\n        ingress_endpoint = format_ingress_endpoint(self.ingress_config, sandbox_id, port)\n        if ingress_endpoint:\n            return ingress_endpoint\n\n        status = workload.get(\"status\", {})\n        selector = status.get(\"selector\")\n        namespace = workload.get(\"metadata\", {}).get(\"namespace\")\n        if selector and namespace:\n            try:\n                pods = self.k8s_client.list_pods(\n                    namespace=namespace,\n                    label_selector=selector,\n                )\n                for pod in pods:\n                    if pod.status and pod.status.pod_ip and pod.status.phase == \"Running\":\n                        return Endpoint(endpoint=f\"{pod.status.pod_ip}:{port}\")\n            except Exception as e:\n                logger.warning(\"Failed to resolve pod endpoint: %s\", e)\n\n        service_fqdn = status.get(\"serviceFQDN\")\n        if service_fqdn:\n            return Endpoint(endpoint=f\"{service_fqdn}:{port}\")\n\n        return None\n"
  },
  {
    "path": "server/src/services/k8s/agent_sandbox_template.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAgent-sandbox template loader and merger.\n\"\"\"\n\nfrom typing import Optional\n\nfrom src.services.k8s.template_manager import BaseSandboxTemplateManager\n\n\nclass AgentSandboxTemplateManager(BaseSandboxTemplateManager):\n    \"\"\"\n    Manager for agent-sandbox Sandbox CR templates.\n    \"\"\"\n\n    def __init__(self, template_file_path: Optional[str] = None):\n        super().__init__(template_file_path, template_kind=\"Agent-sandbox\")\n"
  },
  {
    "path": "server/src/services/k8s/batchsandbox_provider.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nBatchSandbox-based workload provider implementation.\n\"\"\"\n\nimport logging\nimport json\nimport shlex\nfrom datetime import datetime\nfrom typing import Dict, List, Any, Optional\n\nfrom kubernetes.client import (\n    V1Container,\n    V1EnvVar,\n    V1ResourceRequirements,\n    V1VolumeMount,\n)\n\nfrom src.config import AppConfig, EGRESS_MODE_DNS, INGRESS_MODE_GATEWAY\nfrom src.services.helpers import format_ingress_endpoint\nfrom src.api.schema import Endpoint, ImageSpec, NetworkPolicy, Volume\nfrom src.services.k8s.image_pull_secret_helper import (\n    build_image_pull_secret,\n    build_image_pull_secret_name,\n)\nfrom src.services.k8s.batchsandbox_template import BatchSandboxTemplateManager\nfrom src.services.k8s.client import K8sClient\nfrom src.services.k8s.egress_helper import (\n    apply_egress_to_spec,\n    build_security_context_for_sandbox_container,\n    prep_execd_init_for_egress,\n)\nfrom src.services.k8s.security_context import (\n    build_security_context_from_dict,\n    serialize_security_context_to_dict,\n)\nfrom src.services.k8s.volume_helper import apply_volumes_to_pod_spec\nfrom src.services.k8s.workload_provider import WorkloadProvider\nfrom src.services.runtime_resolver import SecureRuntimeResolver\n\nlogger = logging.getLogger(__name__)\n\n\nclass BatchSandboxProvider(WorkloadProvider):\n    \"\"\"\n    Workload provider using BatchSandbox CRD.\n    \n    BatchSandbox is a custom resource that manages Pod lifecycle\n    and provides additional features like task management.\n    \"\"\"\n    \n    def __init__(\n        self,\n        k8s_client: K8sClient,\n        app_config: Optional[AppConfig] = None,\n    ):\n        \"\"\"\n        Initialize BatchSandbox provider.\n\n        Args:\n            k8s_client: Kubernetes client wrapper\n            app_config: Application config; kubernetes/ingress sub-configs are read from it directly.\n        \"\"\"\n        self.k8s_client = k8s_client\n        self.ingress_config = app_config.ingress if app_config else None\n\n        k8s_config = app_config.kubernetes if app_config else None\n        template_file_path = k8s_config.batchsandbox_template_file if k8s_config else None\n        if template_file_path:\n            logger.info(\"Using BatchSandbox template file: %s\", template_file_path)\n        self.execd_init_resources = k8s_config.execd_init_resources if k8s_config else None\n\n        # Initialize secure runtime resolver\n        self.resolver = SecureRuntimeResolver(app_config) if app_config else None\n        self.runtime_class = (\n            self.resolver.get_k8s_runtime_class() if self.resolver else None\n        )\n\n        # CRD constants\n        self.group = \"sandbox.opensandbox.io\"\n        self.version = \"v1alpha1\"\n        self.plural = \"batchsandboxes\"\n        \n        # Template manager\n        self.template_manager = BatchSandboxTemplateManager(template_file_path)\n\n    def supports_image_auth(self) -> bool:\n        \"\"\"BatchSandbox supports image pull auth via imagePullSecrets injection.\"\"\"\n        return True\n\n    def create_workload(\n        self,\n        sandbox_id: str,\n        namespace: str,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        labels: Dict[str, str],\n        expires_at: Optional[datetime],\n        execd_image: str,\n        extensions: Optional[Dict[str, str]] = None,\n        network_policy: Optional[NetworkPolicy] = None,\n        egress_image: Optional[str] = None,\n        volumes: Optional[List[Volume]] = None,\n        annotations: Optional[Dict[str, str]] = None,\n        egress_auth_token: Optional[str] = None,\n        egress_mode: str = EGRESS_MODE_DNS,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Create a BatchSandbox workload.\n\n        Supports both template-based and pool-based creation:\n        - Template mode (default): Creates workload with user-specified image, resources, and env\n        - Pool mode (when extensions contains 'poolRef'): Creates workload from pre-warmed pool,\n          only entrypoint and env can be customized\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            namespace: Kubernetes namespace\n            image_spec: Container image specification (not used in pool mode)\n            entrypoint: Container entrypoint command\n            env: Environment variables\n            resource_limits: Resource limits (not used in pool mode)\n            labels: Labels to apply\n            expires_at: Expiration time\n            execd_image: execd daemon image (not used in pool mode)\n            extensions: General extension field for additional configuration.\n                When contains 'poolRef', enables pool-based creation.\n            network_policy: Optional network policy for egress traffic control.\n                When provided, an egress sidecar container will be added to the Pod.\n            egress_image: Container image for the egress sidecar (required when network_policy is set).\n            volumes: Optional list of volume mounts for the sandbox.\n\n        Returns:\n            Dict with 'name' and 'uid' of created BatchSandbox\n\n        Raises:\n            SandboxError: If pool mode is used with volumes (not supported).\n        \"\"\"\n        extensions = extensions or {}\n\n        # Log runtime class usage for debugging\n        if self.runtime_class:\n            logger.info(\n                \"Using Kubernetes RuntimeClass '%s' for sandbox %s\",\n                self.runtime_class,\n                sandbox_id,\n            )\n\n        # If poolRef is provided and not empty, create workload from pool\n        if extensions.get(\"poolRef\"):\n            # Pool mode does not support volumes\n            if volumes:\n                raise ValueError(\n                    \"Pool mode does not support volumes. \"\n                    \"Remove 'volumes' from request or use template mode.\"\n                )\n            # When using pool, only entrypoint and env can be customized\n            return self._create_workload_from_pool(\n                batchsandbox_name=sandbox_id,\n                namespace=namespace,\n                labels=labels,\n                pool_ref=extensions[\"poolRef\"],\n                expires_at=expires_at,\n                entrypoint=entrypoint,\n                env=env,\n            )\n        \n        # Extract extra pod spec fragments from template (volumes/volumeMounts only).\n        extra_volumes, extra_mounts = self._extract_template_pod_extras()\n\n        # Build init container for execd installation\n        disable_ipv6_for_egress = network_policy is not None and egress_image is not None\n        init_container = self._build_execd_init_container(\n            execd_image, disable_ipv6_for_egress=disable_ipv6_for_egress\n        )\n        \n        # Build main container with execd support\n        main_container = self._build_main_container(\n            image_spec=image_spec,\n            entrypoint=entrypoint,\n            env=env,\n            resource_limits=resource_limits,\n            has_network_policy=network_policy is not None,\n        )\n        \n        # Build containers list\n        containers = [self._container_to_dict(main_container)]\n        \n        # Build base pod spec\n        pod_spec: Dict[str, Any] = {\n            \"initContainers\": [self._container_to_dict(init_container)],\n            \"containers\": containers,\n            \"volumes\": [\n                {\n                    \"name\": \"opensandbox-bin\",\n                    \"emptyDir\": {}\n                }\n            ],\n        }\n\n        # Inject runtimeClassName if secure runtime is configured\n        if self.runtime_class:\n            pod_spec[\"runtimeClassName\"] = self.runtime_class\n\n        # Inject imagePullSecrets if image auth is provided\n        # secret_name is deterministic so it can be embedded before the Secret is created\n        if image_spec.auth:\n            secret_name = build_image_pull_secret_name(sandbox_id)\n            pod_spec[\"imagePullSecrets\"] = [{\"name\": secret_name}]\n\n        # Add egress sidecar if network policy is provided\n        apply_egress_to_spec(\n            containers=containers,\n            network_policy=network_policy,\n            egress_image=egress_image,\n            egress_auth_token=egress_auth_token,\n            egress_mode=egress_mode,\n        )\n\n        # Add user-specified volumes if provided\n        if volumes:\n            apply_volumes_to_pod_spec(pod_spec, volumes)\n\n        spec: Dict[str, Any] = {\n            \"replicas\": 1,\n            \"template\": {\n                \"spec\": pod_spec,\n            },\n        }\n        runtime_manifest = {\n            \"apiVersion\": f\"{self.group}/{self.version}\",\n            \"kind\": \"BatchSandbox\",\n            \"metadata\": {\n                \"name\": sandbox_id,\n                \"namespace\": namespace,\n                \"labels\": labels,\n            },\n            \"spec\": spec,\n        }\n        if annotations:\n            runtime_manifest[\"metadata\"][\"annotations\"] = annotations\n        \n        # Merge with template to get final manifest\n        batchsandbox = self.template_manager.merge_with_runtime_values(runtime_manifest)\n        # Set or strip expireTime after merge so we override any template value\n        if expires_at is None:\n            batchsandbox[\"spec\"].pop(\"expireTime\", None)\n        else:\n            batchsandbox[\"spec\"][\"expireTime\"] = expires_at.isoformat()\n        self._merge_pod_spec_extras(batchsandbox, extra_volumes, extra_mounts)\n        \n        # Create BatchSandbox\n        created = self.k8s_client.create_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            body=batchsandbox,\n        )\n\n        # Create imagePullSecret with ownerReference pointing to the BatchSandbox\n        if image_spec.auth:\n            secret = build_image_pull_secret(\n                sandbox_id=sandbox_id,\n                image_uri=image_spec.uri,\n                auth=image_spec.auth,\n                owner_uid=created[\"metadata\"][\"uid\"],\n                owner_api_version=f\"{self.group}/{self.version}\",\n                owner_kind=\"BatchSandbox\",\n            )\n            try:\n                self.k8s_client.create_secret(namespace=namespace, body=secret)\n                logger.info(\"Created imagePullSecret for sandbox %s\", sandbox_id)\n            except Exception:\n                logger.warning(\"Failed to create imagePullSecret for sandbox %s, rolling back BatchSandbox\", sandbox_id)\n                try:\n                    self.k8s_client.delete_custom_object(\n                        group=self.group,\n                        version=self.version,\n                        namespace=namespace,\n                        plural=self.plural,\n                        name=sandbox_id,\n                        grace_period_seconds=0,\n                    )\n                except Exception as del_exc:\n                    logger.warning(\"Failed to rollback BatchSandbox %s: %s\", sandbox_id, del_exc)\n                raise\n\n        return {\n            \"name\": created[\"metadata\"][\"name\"],\n            \"uid\": created[\"metadata\"][\"uid\"],\n        }\n    \n    def _create_workload_from_pool(\n        self,\n        batchsandbox_name: str,\n        namespace: str,\n        labels: Dict[str, str],\n        pool_ref: str,\n        expires_at: Optional[datetime],\n        entrypoint: List[str],\n        env: Dict[str, str],\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Create BatchSandbox workload from a pre-warmed resource pool.\n        \n        Pool-based creation uses poolRef to reference an existing pool.\n        The pool already defines the pod template, so no additional template is needed.\n        Only entrypoint and env can be customized.\n        \n        Args:\n            batchsandbox_name: Name of the BatchSandbox resource\n            namespace: Kubernetes namespace\n            labels: Labels to apply\n            pool_ref: Reference to the resource pool\n            expires_at: Expiration time\n            entrypoint: Container entrypoint command (can be customized)\n            env: Environment variables (can be customized)\n            \n        Returns:\n            Dict with 'name' and 'uid' of created BatchSandbox\n            \n        Raises:\n            SandboxError: If required parameters are invalid\n        \"\"\"\n        spec: Dict[str, Any] = {\n            \"replicas\": 1,\n            \"poolRef\": pool_ref,\n            \"taskTemplate\": self._build_task_template(entrypoint, env),\n        }\n        if expires_at is not None:\n            spec[\"expireTime\"] = expires_at.isoformat()\n        runtime_manifest = {\n            \"apiVersion\": f\"{self.group}/{self.version}\",\n            \"kind\": \"BatchSandbox\",\n            \"metadata\": {\n                \"name\": batchsandbox_name,\n                \"namespace\": namespace,\n                \"labels\": labels,\n            },\n            \"spec\": spec,\n        }\n        \n        # Pool-based creation does not need template merging\n        # Create BatchSandbox directly\n        created = self.k8s_client.create_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            body=runtime_manifest,\n        )\n        \n        return {\n            \"name\": created[\"metadata\"][\"name\"],\n            \"uid\": created[\"metadata\"][\"uid\"],\n        }\n\n    def _extract_template_pod_extras(self) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:\n        \"\"\"\n        Extract extra volumes and volume mounts from the BatchSandbox template.\n\n        Only these fields are supported here because runtime manifests must\n        always inject execd init container, main container, and volumes.\n        \"\"\"\n        template = self.template_manager.get_base_template()\n        spec = template.get(\"spec\", {}) if isinstance(template, dict) else {}\n        template_spec = spec.get(\"template\", {}).get(\"spec\", {})\n        extra_volumes = template_spec.get(\"volumes\", []) or []\n\n        extra_mounts: list[Dict[str, Any]] = []\n        containers = template_spec.get(\"containers\", []) or []\n        if containers:\n            # Prefer container named \"sandbox\" if present, otherwise first container.\n            target = None\n            for container in containers:\n                if container.get(\"name\") == \"sandbox\":\n                    target = container\n                    break\n            if target is None:\n                target = containers[0]\n            extra_mounts = target.get(\"volumeMounts\", []) or []\n\n        if not isinstance(extra_volumes, list):\n            extra_volumes = []\n        if not isinstance(extra_mounts, list):\n            extra_mounts = []\n        return extra_volumes, extra_mounts\n\n    def _merge_pod_spec_extras(\n        self,\n        batchsandbox: Dict[str, Any],\n        extra_volumes: list[Dict[str, Any]],\n        extra_mounts: list[Dict[str, Any]],\n    ) -> None:\n        \"\"\"\n        Merge extra volumes/volumeMounts into the runtime-generated pod spec.\n\n        This keeps execd injections intact while allowing user templates to\n        provide additional read-only mounts (e.g., shared skills directory).\n        \"\"\"\n        try:\n            spec = batchsandbox[\"spec\"][\"template\"][\"spec\"]\n        except KeyError:\n            return\n\n        # Merge volumes by name (do not overwrite existing runtime volumes).\n        volumes = spec.get(\"volumes\", []) or []\n        if isinstance(volumes, list) and extra_volumes:\n            existing = {v.get(\"name\") for v in volumes if isinstance(v, dict)}\n            for vol in extra_volumes:\n                if not isinstance(vol, dict):\n                    continue\n                name = vol.get(\"name\")\n                if not name or name in existing:\n                    continue\n                volumes.append(vol)\n                existing.add(name)\n            spec[\"volumes\"] = volumes\n\n        # Merge volumeMounts into the main container (index 0).\n        containers = spec.get(\"containers\", []) or []\n        if not containers or not isinstance(containers, list):\n            return\n        main_container = containers[0]\n        mounts = main_container.get(\"volumeMounts\", []) or []\n        if isinstance(mounts, list) and extra_mounts:\n            existing = {m.get(\"name\") for m in mounts if isinstance(m, dict)}\n            for mnt in extra_mounts:\n                if not isinstance(mnt, dict):\n                    continue\n                name = mnt.get(\"name\")\n                if not name or name in existing:\n                    continue\n                mounts.append(mnt)\n                existing.add(name)\n            main_container[\"volumeMounts\"] = mounts\n\n    # TODO: support empty cmd or env\n    def _build_task_template(\n        self,\n        entrypoint: List[str],\n        env: Dict[str, str],\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build taskTemplate for pool-based BatchSandbox.\n        \n        In pool mode, task should use bootstrap.sh to start execd and business process.\n        \n        Generated command example:\n            /bin/sh -c \"/opt/opensandbox/bin/bootstrap.sh python app.py &\"\n        \n        Note: All entrypoint arguments are properly shell-escaped using shlex.quote\n        to prevent shell injection and preserve arguments with spaces or special characters.\n        \n        Args:\n            entrypoint: Container entrypoint command\n            env: Environment variables\n            \n        Returns:\n            Dict: taskTemplate specification with TaskSpec structure\n        \"\"\"\n        # Build command: execute bootstrap.sh with entrypoint in background\n        # Use shlex.quote to safely escape each entrypoint argument to prevent shell injection\n        escaped_entrypoint = ' '.join(shlex.quote(arg) for arg in entrypoint)\n        user_process_cmd = f\"/opt/opensandbox/bin/bootstrap.sh {escaped_entrypoint} &\"\n        \n        wrapped_command = [\"/bin/sh\", \"-c\", user_process_cmd]\n        \n        # Convert env dict to k8s EnvVar format\n        env_list = [{\"name\": k, \"value\": v} for k, v in env.items()] if env else []\n        \n        # Return TaskTemplateSpec structure\n        return {\n            \"spec\": {\n                \"process\": {\n                    \"command\": wrapped_command,\n                    \"env\": env_list,\n                }\n            }\n        }\n    \n    def _build_execd_init_container(\n        self,\n        execd_image: str,\n        *,\n        disable_ipv6_for_egress: bool = False,\n    ) -> V1Container:\n        \"\"\"\n        Build init container for execd installation.\n        \n        This init container copies execd binary and bootstrap.sh script from\n        execd image to shared volume, making them available to the main container.\n        \n        The bootstrap.sh script (from execd image) will:\n        - Start execd in background (redirects logs to /tmp/execd.log)\n        - Use exec to replace current process with user's command\n        \n        Args:\n            execd_image: execd container image\n            disable_ipv6_for_egress: When True, disable IPv6 in the Pod netns first\n                (privileged) then install binaries; used with egress sidecar.\n            \n        Returns:\n            V1Container: Init container spec\n        \"\"\"\n        # Copy execd binary and bootstrap.sh from image to shared volume\n        script = (\n            \"cp ./execd /opt/opensandbox/bin/execd && \"\n            \"cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && \"\n            \"chmod +x /opt/opensandbox/bin/execd && \"\n            \"chmod +x /opt/opensandbox/bin/bootstrap.sh\"\n        )\n        security_context = None\n        if disable_ipv6_for_egress:\n            script, sc_dict = prep_execd_init_for_egress(script)\n            security_context = build_security_context_from_dict(sc_dict)\n\n        resources = None\n        if self.execd_init_resources:\n            resources = V1ResourceRequirements(\n                limits=self.execd_init_resources.limits,\n                requests=self.execd_init_resources.requests,\n            )\n\n        return V1Container(\n            name=\"execd-installer\",\n            image=execd_image,\n            command=[\"/bin/sh\", \"-c\"],\n            args=[script],\n            volume_mounts=[\n                V1VolumeMount(\n                    name=\"opensandbox-bin\",\n                    mount_path=\"/opt/opensandbox/bin\"\n                )\n            ],\n            resources=resources,\n            security_context=security_context,\n        )\n    \n    def _build_main_container(\n        self,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        has_network_policy: bool = False,\n    ) -> V1Container:\n        \"\"\"\n        Build main container spec with execd support.\n        \n        The container will use bootstrap script to start execd in background,\n        then execute user's command.\n        \n        Args:\n            image_spec: Container image specification\n            entrypoint: Container entrypoint command\n            env: Environment variables\n            resource_limits: Resource limits\n            has_network_policy: Whether network policy is enabled for this sandbox\n            \n        Returns:\n            V1Container: Main container spec\n        \"\"\"\n        # Convert env dict to V1EnvVar list and inject EXECD path\n        env_vars = [V1EnvVar(name=k, value=v) for k, v in env.items()]\n        # Add EXECD environment variable to specify execd binary path\n        env_vars.append(V1EnvVar(name=\"EXECD\", value=\"/opt/opensandbox/bin/execd\"))\n        \n        # Build resource requirements\n        resources = None\n        if resource_limits:\n            resources = V1ResourceRequirements(\n                limits=resource_limits,\n                requests=resource_limits,  # Set requests = limits for guaranteed QoS\n            )\n        \n        # Wrap entrypoint with bootstrap script to start execd\n        wrapped_command = [\"/opt/opensandbox/bin/bootstrap.sh\"] + entrypoint\n        \n        # Apply security context when network policy is enabled\n        security_context = None\n        if has_network_policy:\n            security_context_dict = build_security_context_for_sandbox_container(True)\n            security_context = build_security_context_from_dict(security_context_dict)\n        \n        return V1Container(\n            name=\"sandbox\",\n            image=image_spec.uri,\n            command=wrapped_command,\n            env=env_vars if env_vars else None,\n            resources=resources,\n            volume_mounts=[\n                V1VolumeMount(\n                    name=\"opensandbox-bin\",\n                    mount_path=\"/opt/opensandbox/bin\"\n                )\n            ],\n            security_context=security_context,\n        )\n    \n    def _container_to_dict(self, container: V1Container) -> Dict[str, Any]:\n        \"\"\"\n        Convert V1Container to dict for CRD.\n        \n        Args:\n            container: V1Container object\n            \n        Returns:\n            Dict representation of container\n        \"\"\"\n        result = {\n            \"name\": container.name,\n            \"image\": container.image,\n        }\n        \n        if container.command:\n            result[\"command\"] = container.command\n        \n        if container.args:\n            result[\"args\"] = container.args\n        \n        if container.env:\n            result[\"env\"] = [\n                {\"name\": e.name, \"value\": e.value}\n                for e in container.env\n            ]\n        \n        if container.resources:\n            result[\"resources\"] = {}\n            if container.resources.limits:\n                result[\"resources\"][\"limits\"] = container.resources.limits\n            if container.resources.requests:\n                result[\"resources\"][\"requests\"] = container.resources.requests\n        \n        if container.volume_mounts:\n            result[\"volumeMounts\"] = [\n                {\"name\": vm.name, \"mountPath\": vm.mount_path}\n                for vm in container.volume_mounts\n            ]\n        \n        if container.security_context:\n            security_context_dict = serialize_security_context_to_dict(container.security_context)\n            if security_context_dict:\n                result[\"securityContext\"] = security_context_dict\n        \n        return result\n\n    def get_workload(self, sandbox_id: str, namespace: str) -> Optional[Dict[str, Any]]:\n        \"\"\"Get BatchSandbox by sandbox ID.\"\"\"\n        workload = self.k8s_client.get_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            name=sandbox_id,\n        )\n        if workload:\n            return workload\n\n        # Fallback for pre-upgrade sandboxes that used \"sandbox-<id>\" naming\n        legacy_name = self.legacy_resource_name(sandbox_id)\n        if legacy_name != sandbox_id:\n            return self.k8s_client.get_custom_object(\n                group=self.group,\n                version=self.version,\n                namespace=namespace,\n                plural=self.plural,\n                name=legacy_name,\n            )\n\n        return None\n    \n    def delete_workload(self, sandbox_id: str, namespace: str) -> None:\n        \"\"\"Delete BatchSandbox workload.\"\"\"\n        batchsandbox = self.get_workload(sandbox_id, namespace)\n        if not batchsandbox:\n            raise Exception(f\"BatchSandbox for sandbox {sandbox_id} not found\")\n        \n        self.k8s_client.delete_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            name=batchsandbox[\"metadata\"][\"name\"],\n            grace_period_seconds=0,\n        )\n    \n    def list_workloads(self, namespace: str, label_selector: str) -> List[Dict[str, Any]]:\n        \"\"\"List BatchSandboxes matching label selector.\"\"\"\n        return self.k8s_client.list_custom_objects(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            label_selector=label_selector,\n        )\n    \n    def update_expiration(self, sandbox_id: str, namespace: str, expires_at: datetime) -> None:\n        \"\"\"Update BatchSandbox expiration time.\n        \n        Args:\n            sandbox_id: Sandbox ID\n            namespace: Kubernetes namespace\n            expires_at: New expiration time\n            \n        Raises:\n            Exception: If BatchSandbox not found or update fails\n        \"\"\"\n        batchsandbox = self.get_workload(sandbox_id, namespace)\n        if not batchsandbox:\n            raise Exception(f\"BatchSandbox for sandbox {sandbox_id} not found\")\n        \n        # Patch BatchSandbox spec.expireTime\n        body = {\n            \"spec\": {\n                \"expireTime\": expires_at.isoformat()\n            }\n        }\n        \n        self.k8s_client.patch_custom_object(\n            group=self.group,\n            version=self.version,\n            namespace=namespace,\n            plural=self.plural,\n            name=batchsandbox[\"metadata\"][\"name\"],\n            body=body,\n        )\n    \n    def get_expiration(self, workload: Dict[str, Any]) -> Optional[datetime]:\n        \"\"\"Get expiration time from BatchSandbox.\n        \n        Args:\n            workload: BatchSandbox dict\n            \n        Returns:\n            Expiration datetime or None if not set or invalid\n        \"\"\"\n        spec = workload.get(\"spec\", {})\n        expire_time_str = spec.get(\"expireTime\")\n        \n        if not expire_time_str:\n            return None\n        \n        try:\n            # Parse ISO format datetime\n            return datetime.fromisoformat(expire_time_str.replace('Z', '+00:00'))\n        except (ValueError, TypeError) as e:\n            logger.warning(\"Invalid expireTime format: %s, error: %s\", expire_time_str, e)\n            return None\n\n    def _parse_pod_ip(self, workload: Dict[str, Any]) -> Optional[str]:\n        \"\"\"Parse the first Pod IP from the endpoints annotation.\n\n        Returns the IP string if the annotation exists and contains a non-empty\n        JSON array, otherwise returns None.\n        \"\"\"\n        annotations = workload.get(\"metadata\", {}).get(\"annotations\", {})\n        endpoints_str = annotations.get(\"sandbox.opensandbox.io/endpoints\")\n        if not endpoints_str:\n            return None\n        try:\n            endpoints = json.loads(endpoints_str)\n            if endpoints and len(endpoints) > 0:\n                return endpoints[0]\n        except (json.JSONDecodeError, IndexError, TypeError):\n            pass\n        return None\n\n    def get_status(self, workload: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"\n        Get status from BatchSandbox.\n        \n        The status is derived from the BatchSandbox status fields:\n        - replicas: total number of pods\n        - allocated: number of scheduled pods\n        - ready: number of ready pods\n        \"\"\"\n        status = workload.get(\"status\", {})\n        \n        replicas = status.get(\"replicas\", 0)\n        ready = status.get(\"ready\", 0)\n        allocated = status.get(\"allocated\", 0)\n\n        pod_ip = self._parse_pod_ip(workload)\n\n        # Determine state: Pending -> Allocated (IP assigned) -> Running (Pod ready)\n        if ready == 1 and pod_ip:\n            # Pod is ready and has IP\n            state = \"Running\"\n            reason = \"POD_READY_WITH_IP\"\n            message = f\"Pod is ready with IP ({ready}/{replicas} ready)\"\n        elif pod_ip:\n            # Pod has IP assigned but not ready yet\n            state = \"Allocated\"\n            reason = \"IP_ASSIGNED\"\n            message = f\"Pod has IP assigned but not ready ({allocated}/{replicas} allocated, {ready} ready)\"\n        else:\n            # Pod is not allocated yet or allocated but no IP\n            state = \"Pending\"\n            reason = \"POD_SCHEDULED\" if allocated > 0 else \"BATCHSANDBOX_PENDING\"\n            message = (\n                f\"Pod is scheduled but waiting for IP ({allocated}/{replicas} allocated, {ready} ready)\"\n                if allocated > 0\n                else \"BatchSandbox is pending allocation\"\n            )\n        \n        # Get creation timestamp\n        creation_timestamp = workload.get(\"metadata\", {}).get(\"creationTimestamp\")\n        \n        return {\n            \"state\": state,\n            \"reason\": reason,\n            \"message\": message,\n            \"last_transition_at\": creation_timestamp,\n        }\n    \n    def get_endpoint_info(self, workload: Dict[str, Any], port: int, sandbox_id: str) -> Optional[Endpoint]:\n        \"\"\"\n        Get endpoint information from BatchSandbox.\n        - gateway mode: use ingress config to format endpoint\n        - direct/default: resolve Pod IP from annotation\n        \"\"\"\n        if self.ingress_config and self.ingress_config.mode == INGRESS_MODE_GATEWAY:\n            return format_ingress_endpoint(self.ingress_config, sandbox_id, port)\n\n        pod_ip = self._parse_pod_ip(workload)\n        if not pod_ip:\n            return None\n        return Endpoint(endpoint=f\"{pod_ip}:{port}\")\n"
  },
  {
    "path": "server/src/services/k8s/batchsandbox_template.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nBatchSandbox template loader and merger.\n\"\"\"\n\nfrom typing import Optional\n\nfrom src.services.k8s.template_manager import BaseSandboxTemplateManager\n\n\nclass BatchSandboxTemplateManager(BaseSandboxTemplateManager):\n    \"\"\"\n    Manager for BatchSandbox CR templates.\n    \"\"\"\n\n    def __init__(self, template_file_path: Optional[str] = None):\n        super().__init__(template_file_path, template_kind=\"BatchSandbox\")\n"
  },
  {
    "path": "server/src/services/k8s/client.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nKubernetes client wrapper that provides a unified interface for all K8s resource\noperations. All API access goes through this class.\n\"\"\"\n\nimport logging\nimport threading\nfrom functools import partial\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom kubernetes import client, config\nfrom kubernetes.client import ApiException, CoreV1Api, CustomObjectsApi, NodeV1Api\n\nfrom src.config import KubernetesRuntimeConfig\nfrom src.services.k8s.informer import WorkloadInformer\nfrom src.services.k8s.rate_limiter import TokenBucketRateLimiter\n\nlogger = logging.getLogger(__name__)\n\n# Type alias for informer cache key\n_InformerKey = Tuple[str, str, str, str]  # (group, version, plural, namespace)\n\n\nclass K8sClient:\n    \"\"\"\n    Unified Kubernetes API client.\n\n    Encapsulates all cluster resource operations (CustomObject, Secret, Pod,\n    RuntimeClass). Callers never hold raw API handles directly.\n    \"\"\"\n\n    def __init__(self, k8s_config: KubernetesRuntimeConfig):\n        self.config = k8s_config\n        self._load_config()\n        self._core_v1_api: Optional[CoreV1Api] = None\n        self._custom_objects_api: Optional[CustomObjectsApi] = None\n        self._node_v1_api: Optional[NodeV1Api] = None\n        # Informer pool: key -> WorkloadInformer\n        self._informers: Dict[_InformerKey, WorkloadInformer] = {}\n        self._informers_lock = threading.Lock()\n        # Rate limiters (None = unlimited)\n        self._read_limiter: Optional[TokenBucketRateLimiter] = (\n            TokenBucketRateLimiter(qps=k8s_config.read_qps, burst=k8s_config.read_burst)\n            if k8s_config.read_qps > 0\n            else None\n        )\n        self._write_limiter: Optional[TokenBucketRateLimiter] = (\n            TokenBucketRateLimiter(qps=k8s_config.write_qps, burst=k8s_config.write_burst)\n            if k8s_config.write_qps > 0\n            else None\n        )\n\n    # ------------------------------------------------------------------\n    # Internal API handle accessors (lazy singletons)\n    # ------------------------------------------------------------------\n\n    def _load_config(self) -> None:\n        \"\"\"Load kubeconfig from file path or in-cluster service account.\"\"\"\n        try:\n            if self.config.kubeconfig_path:\n                config.load_kube_config(config_file=self.config.kubeconfig_path)\n            else:\n                config.load_incluster_config()\n        except Exception as e:\n            raise Exception(f\"Failed to load Kubernetes configuration: {e}\") from e\n\n    def get_core_v1_api(self) -> CoreV1Api:\n        if self._core_v1_api is None:\n            self._core_v1_api = client.CoreV1Api()\n        return self._core_v1_api\n\n    def get_custom_objects_api(self) -> CustomObjectsApi:\n        if self._custom_objects_api is None:\n            self._custom_objects_api = client.CustomObjectsApi()\n        return self._custom_objects_api\n\n    def get_node_v1_api(self) -> NodeV1Api:\n        if self._node_v1_api is None:\n            self._node_v1_api = client.NodeV1Api()\n        return self._node_v1_api\n\n    # ------------------------------------------------------------------\n    # Internal informer pool management\n    # ------------------------------------------------------------------\n\n    def _get_informer(self, group: str, version: str, plural: str, namespace: str) -> Optional[WorkloadInformer]:\n        \"\"\"Return the informer for this resource+namespace, starting it lazily.\"\"\"\n        if not self.config.informer_enabled:\n            return None\n\n        key: _InformerKey = (group, version, plural, namespace)\n        with self._informers_lock:\n            informer = self._informers.get(key)\n            if informer is None:\n                list_fn = partial(\n                    self.get_custom_objects_api().list_namespaced_custom_object,\n                    group=group,\n                    version=version,\n                    namespace=namespace,\n                    plural=plural,\n                )\n                informer = WorkloadInformer(\n                    list_fn=list_fn,\n                    resync_period_seconds=self.config.informer_resync_seconds,\n                    watch_timeout_seconds=self.config.informer_watch_timeout_seconds,\n                    thread_name=f\"workload-informer-{plural}-{namespace}\",\n                )\n                self._informers[key] = informer\n                try:\n                    informer.start()\n                except Exception as exc:  # pragma: no cover - defensive\n                    logger.warning(\"Failed to start informer for %s/%s: %s\", plural, namespace, exc)\n                    self._informers.pop(key, None)\n                    return None\n        return informer\n\n    # ------------------------------------------------------------------\n    # CustomObject operations\n    # ------------------------------------------------------------------\n\n    def create_custom_object(\n        self,\n        group: str,\n        version: str,\n        namespace: str,\n        plural: str,\n        body: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        \"\"\"Create a namespaced custom resource.\"\"\"\n        if self._write_limiter:\n            self._write_limiter.acquire()\n        return self.get_custom_objects_api().create_namespaced_custom_object(\n            group=group,\n            version=version,\n            namespace=namespace,\n            plural=plural,\n            body=body,\n        )\n\n    def get_custom_object(\n        self,\n        group: str,\n        version: str,\n        namespace: str,\n        plural: str,\n        name: str,\n    ) -> Optional[Dict[str, Any]]:\n        \"\"\"Get a namespaced custom resource by name.\n\n        Tries the informer cache first when available and synced.\n        Returns None on 404.\n        \"\"\"\n        informer = self._get_informer(group, version, plural, namespace)\n        if informer and informer.has_synced:\n            cached = informer.get(name)\n            if cached is not None:\n                return cached\n\n        if self._read_limiter:\n            self._read_limiter.acquire()\n        try:\n            obj = self.get_custom_objects_api().get_namespaced_custom_object(\n                group=group,\n                version=version,\n                namespace=namespace,\n                plural=plural,\n                name=name,\n            )\n            if informer:\n                informer.update_cache(obj)\n            return obj\n        except ApiException as e:\n            if e.status == 404:\n                return None\n            raise\n\n    def list_custom_objects(\n        self,\n        group: str,\n        version: str,\n        namespace: str,\n        plural: str,\n        label_selector: str = \"\",\n    ) -> List[Dict[str, Any]]:\n        \"\"\"List namespaced custom resources, returning the items list.\"\"\"\n        if self._read_limiter:\n            self._read_limiter.acquire()\n        try:\n            resp = self.get_custom_objects_api().list_namespaced_custom_object(\n                group=group,\n                version=version,\n                namespace=namespace,\n                plural=plural,\n                label_selector=label_selector,\n            )\n            return resp.get(\"items\", [])\n        except ApiException as e:\n            if e.status == 404:\n                return []\n            raise\n\n    def delete_custom_object(\n        self,\n        group: str,\n        version: str,\n        namespace: str,\n        plural: str,\n        name: str,\n        grace_period_seconds: int = 0,\n    ) -> None:\n        \"\"\"Delete a namespaced custom resource.\"\"\"\n        if self._write_limiter:\n            self._write_limiter.acquire()\n        self.get_custom_objects_api().delete_namespaced_custom_object(\n            group=group,\n            version=version,\n            namespace=namespace,\n            plural=plural,\n            name=name,\n            grace_period_seconds=grace_period_seconds,\n        )\n\n    def patch_custom_object(\n        self,\n        group: str,\n        version: str,\n        namespace: str,\n        plural: str,\n        name: str,\n        body: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        \"\"\"Patch a namespaced custom resource.\"\"\"\n        if self._write_limiter:\n            self._write_limiter.acquire()\n        return self.get_custom_objects_api().patch_namespaced_custom_object(\n            group=group,\n            version=version,\n            namespace=namespace,\n            plural=plural,\n            name=name,\n            body=body,\n        )\n\n    # ------------------------------------------------------------------\n    # Secret operations\n    # ------------------------------------------------------------------\n\n    def create_secret(self, namespace: str, body: Any) -> Any:\n        \"\"\"Create a namespaced Secret.\"\"\"\n        if self._write_limiter:\n            self._write_limiter.acquire()\n        return self.get_core_v1_api().create_namespaced_secret(\n            namespace=namespace,\n            body=body,\n        )\n\n    # ------------------------------------------------------------------\n    # Pod operations\n    # ------------------------------------------------------------------\n\n    def list_pods(\n        self,\n        namespace: str,\n        label_selector: str = \"\",\n    ) -> List[Any]:\n        \"\"\"List pods in a namespace, returning the items list.\"\"\"\n        if self._read_limiter:\n            self._read_limiter.acquire()\n        resp = self.get_core_v1_api().list_namespaced_pod(\n            namespace=namespace,\n            label_selector=label_selector,\n        )\n        return resp.items\n\n    # ------------------------------------------------------------------\n    # RuntimeClass operations\n    # ------------------------------------------------------------------\n\n    def read_runtime_class(self, name: str) -> Any:\n        \"\"\"Read a RuntimeClass from the cluster.\"\"\"\n        if self._read_limiter:\n            self._read_limiter.acquire()\n        return self.get_node_v1_api().read_runtime_class(name)\n"
  },
  {
    "path": "server/src/services/k8s/egress_helper.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nEgress sidecar helpers for Kubernetes pod specs.\n\nPublic entry points: ``prep_execd_init_for_egress``, ``build_security_context_for_sandbox_container``,\n``apply_egress_to_spec``. SecurityContext dict ↔ V1 conversion lives in ``security_context``.\n\"\"\"\n\nimport json\nfrom typing import Any, Dict, List, Optional\n\nfrom src.api.schema import NetworkPolicy\nfrom src.config import EGRESS_MODE_DNS\nfrom src.services.constants import (\n    EGRESS_MODE_ENV,\n    EGRESS_RULES_ENV,\n    OPENSANDBOX_EGRESS_TOKEN,\n)\n\n\ndef prep_execd_init_for_egress(exec_install_script: str) -> tuple[str, Dict[str, Any]]:\n    \"\"\"\n    Prepare execd init when an egress sidecar is used: disable IPv6 in the Pod netns, then install.\n\n    Writes ``/proc/sys/.../disable_ipv6`` (no ``sysctl`` binary required). The returned\n    security context dict must be applied to the execd init container (typically via\n    ``build_security_context_from_dict`` in ``security_context``).\n\n    Returns:\n        ``(prefixed_shell_script, {\"privileged\": True})``\n    \"\"\"\n    script = (\n        \"set -e; \"\n        \"echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6 && \"\n        f\"{exec_install_script}\"\n    )\n    return script, {\"privileged\": True}\n\n\ndef build_security_context_for_sandbox_container(\n    has_network_policy: bool,\n) -> Dict[str, Any]:\n    \"\"\"\n    Security context dict for the main sandbox container.\n\n    When network policy is enabled, drops ``NET_ADMIN`` so only the egress sidecar can\n    mutate network stack state.\n    \"\"\"\n    if not has_network_policy:\n        return {}\n\n    return {\n        \"capabilities\": {\n            \"drop\": [\"NET_ADMIN\"],\n        },\n    }\n\n\ndef apply_egress_to_spec(\n    containers: List[Dict[str, Any]],\n    network_policy: Optional[NetworkPolicy],\n    egress_image: Optional[str],\n    egress_auth_token: Optional[str] = None,\n    egress_mode: str = EGRESS_MODE_DNS,\n) -> None:\n    \"\"\"\n    Append the egress sidecar to ``containers``. IPv6 is handled in execd init\n    (``prep_execd_init_for_egress``); Pod-level sysctls are not modified.\n    \"\"\"\n    if not network_policy or not egress_image:\n        return\n\n    policy_payload = json.dumps(\n        network_policy.model_dump(by_alias=True, exclude_none=True)\n    )\n\n    env: List[Dict[str, str]] = [\n        {\"name\": EGRESS_RULES_ENV, \"value\": policy_payload},\n        {\"name\": EGRESS_MODE_ENV, \"value\": egress_mode},\n    ]\n    if egress_auth_token:\n        env.append({\"name\": OPENSANDBOX_EGRESS_TOKEN, \"value\": egress_auth_token})\n\n    containers.append(\n        {\n            \"name\": \"egress\",\n            \"image\": egress_image,\n            \"env\": env,\n            \"securityContext\": {\n                \"capabilities\": {\"add\": [\"NET_ADMIN\"]},\n            },\n        }\n    )\n"
  },
  {
    "path": "server/src/services/k8s/image_pull_secret_helper.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nHelpers for creating Kubernetes imagePullSecrets.\n\"\"\"\n\nimport base64\nimport json\n\nfrom kubernetes.client import V1ObjectMeta, V1OwnerReference, V1Secret\n\nfrom src.api.schema import ImageAuth\n\nIMAGE_AUTH_SECRET_PREFIX = \"opensandbox-image-auth\"\n\n\ndef build_image_pull_secret_name(sandbox_id: str) -> str:\n    \"\"\"Derive a deterministic imagePullSecret name from sandbox_id.\"\"\"\n    return f\"{IMAGE_AUTH_SECRET_PREFIX}-{sandbox_id}\"\n\n\ndef build_image_pull_secret(\n    sandbox_id: str,\n    image_uri: str,\n    auth: ImageAuth,\n    owner_uid: str,\n    owner_api_version: str,\n    owner_kind: str,\n) -> V1Secret:\n    \"\"\"\n    Build a kubernetes.io/dockerconfigjson Secret for image pull auth.\n\n    The Secret's ownerReference points to the owning CR so it is\n    garbage-collected automatically when the owner is deleted.\n\n    Args:\n        sandbox_id: Sandbox identifier (used to derive Secret name)\n        image_uri: Container image URI (used to determine registry hostname)\n        auth: ImageAuth credentials\n        owner_uid: UID of the owning CR\n        owner_api_version: apiVersion of the owning CR (e.g. \"sandbox.opensandbox.io/v1alpha1\")\n        owner_kind: Kind of the owning CR (e.g. \"BatchSandbox\")\n\n    Returns:\n        V1Secret ready to be created via CoreV1Api\n    \"\"\"\n    secret_name = build_image_pull_secret_name(sandbox_id)\n\n    # Derive registry hostname from image URI\n    # e.g. \"registry.example.com/ns/image:tag\" -> \"registry.example.com\"\n    # e.g. \"python:3.11\" -> \"https://index.docker.io/v1/\"\n    parts = image_uri.split(\"/\")\n    if len(parts) >= 2 and (\".\" in parts[0] or \":\" in parts[0]):\n        registry = parts[0]\n    else:\n        registry = \"https://index.docker.io/v1/\"\n\n    auth_str = base64.b64encode(\n        f\"{auth.username}:{auth.password}\".encode()\n    ).decode()\n    docker_config = {\n        \"auths\": {\n            registry: {\n                \"username\": auth.username,\n                \"password\": auth.password,\n                \"auth\": auth_str,\n            }\n        }\n    }\n    docker_config_b64 = base64.b64encode(\n        json.dumps(docker_config).encode()\n    ).decode()\n\n    return V1Secret(\n        api_version=\"v1\",\n        kind=\"Secret\",\n        metadata=V1ObjectMeta(\n            name=secret_name,\n            owner_references=[\n                V1OwnerReference(\n                    api_version=owner_api_version,\n                    kind=owner_kind,\n                    name=sandbox_id,\n                    uid=owner_uid,\n                    controller=False,\n                )\n            ],\n        ),\n        type=\"kubernetes.io/dockerconfigjson\",\n        data={\".dockerconfigjson\": docker_config_b64},\n    )\n"
  },
  {
    "path": "server/src/services/k8s/informer.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Lightweight informer-style cache for namespaced custom resources.\"\"\"\n\nimport logging\nimport threading\nfrom typing import Any, Callable, Dict, Optional\n\nfrom kubernetes import watch\nfrom kubernetes.client import ApiException\n\nlogger = logging.getLogger(__name__)\n\n\nclass WorkloadInformer:\n    \"\"\"Maintain an in-memory cache of a namespaced custom resource via watch.\"\"\"\n\n    def __init__(\n        self,\n        list_fn: Callable[..., Any],\n        resync_period_seconds: int = 300,\n        watch_timeout_seconds: int = 60,\n        enable_watch: bool = True,\n        thread_name: str = \"workload-informer\",\n    ):\n        \"\"\"\n        Args:\n            list_fn: Callable that lists the custom resource, with signature\n                     ``list_fn(**kwargs) -> dict``.  Typically a bound method\n                     like ``custom_api.list_namespaced_custom_object``.\n            resync_period_seconds: Full-resync interval when watch is disabled.\n            watch_timeout_seconds: Per-stream watch timeout before restart.\n            enable_watch: When False only the initial list is performed.\n            thread_name: Name for the background thread, used in stack traces\n                         and debuggers.  Should be unique per informer instance.\n        \"\"\"\n        self.list_fn = list_fn\n        self.resync_period_seconds = resync_period_seconds\n        self.watch_timeout_seconds = watch_timeout_seconds\n        self.enable_watch = enable_watch\n        self._thread_name = thread_name\n\n        self._cache: Dict[str, Dict[str, Any]] = {}\n        self._lock = threading.RLock()\n        self._resource_version: Optional[str] = None\n        self._has_synced = False\n        self._stop_event = threading.Event()\n        self._thread: Optional[threading.Thread] = None\n\n    @property\n    def has_synced(self) -> bool:\n        \"\"\"Return True once an initial list has completed.\"\"\"\n        return self._has_synced\n\n    def start(self) -> None:\n        \"\"\"Start the background watch thread if not already running.\"\"\"\n        if self._thread and self._thread.is_alive():\n            return\n\n        self._thread = threading.Thread(\n            target=self._run,\n            name=self._thread_name,\n            daemon=True,\n        )\n        self._thread.start()\n\n    def stop(self) -> None:\n        \"\"\"Stop the background watch thread.\"\"\"\n        self._stop_event.set()\n\n    def get(self, name: str) -> Optional[Dict[str, Any]]:\n        \"\"\"Return cached object by name, if present.\"\"\"\n        with self._lock:\n            return self._cache.get(name)\n\n    def update_cache(self, obj: Dict[str, Any]) -> None:\n        \"\"\"Upsert a single object into the cache.\n\n        Only advances ``_resource_version`` if the incoming version is strictly\n        newer, preventing a stale API response from rolling back the watch cursor.\n        \"\"\"\n        metadata = obj.get(\"metadata\", {})\n        name = metadata.get(\"name\")\n        if not name:\n            return\n\n        with self._lock:\n            self._cache[name] = obj\n            self._advance_resource_version(metadata.get(\"resourceVersion\"))\n\n    def _advance_resource_version(self, rv: Optional[str]) -> None:\n        \"\"\"Advance ``_resource_version`` only when *rv* is strictly newer.\n\n        K8s resourceVersions are opaque strings but etcd encodes them as\n        monotonically increasing integers.  If the conversion fails we skip the\n        update (conservative: keep the current, newer cursor).\n\n        Must be called with ``self._lock`` already held.\n        \"\"\"\n        if not rv:\n            return\n        if self._resource_version is None:\n            self._resource_version = rv\n            return\n        try:\n            if int(rv) > int(self._resource_version):\n                self._resource_version = rv\n        except ValueError:\n            # Non-integer resourceVersion — skip to avoid downgrade.\n            pass\n\n    def _run(self) -> None:\n        backoff = 1.0\n        while not self._stop_event.is_set():\n            try:\n                if not self._has_synced:\n                    self._full_resync()\n                    backoff = 1.0\n\n                if not self.enable_watch:\n                    self._stop_event.wait(self.resync_period_seconds)\n                    self._has_synced = False  # trigger a fresh list on next loop\n                    continue\n\n                self._run_watch_loop()\n                backoff = 1.0\n            except ApiException as exc:\n                if exc.status == 410:\n                    # Resource version too old; force a fresh list on next loop.\n                    self._resource_version = None\n                    self._has_synced = False\n                else:\n                    logger.warning(\"Informer watch error: %s\", exc, exc_info=True)\n                    self._has_synced = False\n                    self._stop_event.wait(min(backoff, 30.0))\n                    backoff = min(backoff * 2, 30.0)\n            except Exception as exc:  # pragma: no cover - defensive\n                logger.warning(\"Unexpected informer error: %s\", exc, exc_info=True)\n                self._has_synced = False\n                self._stop_event.wait(min(backoff, 30.0))\n                backoff = min(backoff * 2, 30.0)\n\n    def _full_resync(self) -> None:\n        \"\"\"Perform a full list to refresh the cache.\"\"\"\n        resp = self.list_fn()\n\n        # list response is a dict for CustomObjectsApi\n        items = resp.get(\"items\", []) if isinstance(resp, dict) else []\n        metadata = resp.get(\"metadata\", {}) if isinstance(resp, dict) else {}\n        resource_version = metadata.get(\"resourceVersion\")\n\n        # Build new cache outside the lock to avoid blocking readers\n        new_cache: Dict[str, Dict[str, Any]] = {}\n        for item in items:\n            name = item.get(\"metadata\", {}).get(\"name\")\n            if name:\n                new_cache[name] = item\n\n        with self._lock:\n            self._cache = new_cache\n            self._advance_resource_version(resource_version)\n            self._has_synced = True\n\n    def _run_watch_loop(self) -> None:\n        \"\"\"Stream watch events to keep the cache fresh.\"\"\"\n        w = watch.Watch()\n        try:\n            for event in w.stream(\n                self.list_fn,\n                resource_version=self._resource_version,\n                timeout_seconds=self.watch_timeout_seconds,\n            ):\n                if self._stop_event.is_set():\n                    break\n                self._handle_event(event)\n        finally:\n            w.stop()\n\n    def _handle_event(self, event: Dict[str, Any]) -> None:\n        obj = event.get(\"object\")\n        if obj is None:\n            return\n\n        if not isinstance(obj, dict):\n            try:\n                obj = obj.to_dict()\n            except Exception:\n                return\n\n        metadata = obj.get(\"metadata\", {})\n        name = metadata.get(\"name\")\n        if not name:\n            return\n\n        event_type = event.get(\"type\")\n        with self._lock:\n            if event_type == \"DELETED\":\n                self._cache.pop(name, None)\n            else:\n                self._cache[name] = obj\n            self._advance_resource_version(metadata.get(\"resourceVersion\"))\n"
  },
  {
    "path": "server/src/services/k8s/kubernetes_service.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nKubernetes-based implementation of SandboxService.\n\nThis module provides a Kubernetes implementation of the sandbox service interface,\nusing Kubernetes resources for sandbox lifecycle management.\n\"\"\"\n\nimport asyncio\nimport logging\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Optional, Dict, Any\n\nfrom fastapi import HTTPException, status\n\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    CreateSandboxResponse,\n    Endpoint,\n    ImageSpec,\n    ListSandboxesRequest,\n    ListSandboxesResponse,\n    PaginationInfo,\n    RenewSandboxExpirationRequest,\n    RenewSandboxExpirationResponse,\n    Sandbox,\n    SandboxStatus,\n)\nfrom src.config import AppConfig, EGRESS_MODE_DNS, get_config\nfrom src.services.constants import (\n    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,\n    SANDBOX_ID_LABEL,\n    SANDBOX_MANUAL_CLEANUP_LABEL,\n    SandboxErrorCodes,\n)\nfrom src.services.endpoint_auth import generate_egress_token\nfrom src.services.endpoint_auth import build_egress_auth_headers, merge_endpoint_headers\nfrom src.services.helpers import matches_filter\nfrom src.services.sandbox_service import SandboxService\nfrom src.services.validators import (\n    calculate_expiration_or_raise,\n    ensure_entrypoint,\n    ensure_egress_configured,\n    ensure_future_expiration,\n    ensure_metadata_labels,\n    ensure_timeout_within_limit,\n    ensure_volumes_valid,\n)\nfrom src.services.k8s.client import K8sClient\nfrom src.services.k8s.provider_factory import create_workload_provider\n\nlogger = logging.getLogger(__name__)\n\n\nclass KubernetesSandboxService(SandboxService):\n    \"\"\"\n    Kubernetes-based implementation of SandboxService.\n    \n    This class implements sandbox lifecycle operations using Kubernetes resources.\n    \"\"\"\n    \n    def __init__(self, config: Optional[AppConfig] = None):\n        \"\"\"\n        Initialize Kubernetes sandbox service.\n        \n        Args:\n            config: Application configuration\n            \n        Raises:\n            HTTPException: If initialization fails\n        \"\"\"\n        self.app_config = config or get_config()\n        runtime_config = self.app_config.runtime\n        \n        if runtime_config.type != \"kubernetes\":\n            raise ValueError(\"KubernetesSandboxService requires runtime.type = 'kubernetes'\")\n        \n        if not self.app_config.kubernetes:\n            raise ValueError(\"Kubernetes configuration is required\")\n        \n        # Ingress configuration (direct/gateway) if provided\n        self.ingress_config = self.app_config.ingress\n\n        self.namespace = self.app_config.kubernetes.namespace\n        self.execd_image = runtime_config.execd_image\n        \n        # Initialize Kubernetes client\n        try:\n            self.k8s_client = K8sClient(self.app_config.kubernetes)\n            logger.info(\"Kubernetes client initialized successfully\")\n        except Exception as e:\n            logger.error(f\"Failed to initialize Kubernetes client: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_INITIALIZATION_ERROR,\n                    \"message\": f\"Failed to initialize Kubernetes client: {str(e)}\",\n                },\n            ) from e\n        \n        # Initialize workload provider\n        provider_type = self.app_config.kubernetes.workload_provider\n        try:\n            self.workload_provider = create_workload_provider(\n                provider_type=provider_type,\n                k8s_client=self.k8s_client,\n                app_config=self.app_config,\n            )\n            logger.info(\n                f\"Initialized workload provider: {self.workload_provider.__class__.__name__}\"\n            )\n        except ValueError as e:\n            logger.error(f\"Failed to create workload provider: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_INITIALIZATION_ERROR,\n                    \"message\": f\"Invalid workload provider configuration: {str(e)}\",\n                },\n            ) from e\n        \n        logger.info(\n            \"KubernetesSandboxService initialized: namespace=%s, execd_image=%s\",\n            self.namespace,\n            self.execd_image,\n        )\n    \n    async def _wait_for_sandbox_ready(\n        self,\n        sandbox_id: str,\n        timeout_seconds: int = 60,\n        poll_interval_seconds: float = 1.0,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Wait for Pod to be Running and have an IP address.\n        \n        Args:\n            sandbox_id: Sandbox ID\n            timeout_seconds: Maximum time to wait in seconds\n            poll_interval_seconds: Time between polling attempts\n            \n        Returns:\n            Workload dict when Pod is Running with IP\n            \n        Raises:\n            HTTPException: If timeout or Pod fails\n        \"\"\"\n        logger.info(\n            f\"Waiting for sandbox {sandbox_id} to be Running with IP (timeout: {timeout_seconds}s)\"\n        )\n        \n        start_time = time.time()\n        last_state = None\n        last_message = None\n        \n        while time.time() - start_time < timeout_seconds:\n            try:\n                # Get current workload status\n                workload = self.workload_provider.get_workload(\n                    sandbox_id=sandbox_id,\n                    namespace=self.namespace,\n                )\n                \n                if not workload:\n                    logger.debug(f\"Workload not found yet for sandbox {sandbox_id}\")\n                    time.sleep(poll_interval_seconds)\n                    continue\n                \n                # Get status\n                status_info = self.workload_provider.get_status(workload)\n                current_state = status_info[\"state\"]\n                current_message = status_info[\"message\"]\n                \n                # Log state changes\n                if current_state != last_state or current_message != last_message:\n                    logger.info(\n                        f\"Sandbox {sandbox_id} state: {current_state} - {current_message}\"\n                    )\n                    last_state = current_state\n                    last_message = current_message\n                \n                # Check if Running or Allocated (IP assigned)\n                if current_state in (\"Running\", \"Allocated\"):\n                    return workload\n                \n            except HTTPException:\n                raise\n            except Exception as e:\n                logger.warning(\n                    f\"Error checking sandbox {sandbox_id} status: {e}\",\n                    exc_info=True\n                )\n            \n            # Wait before next poll\n            await asyncio.sleep(poll_interval_seconds)\n        \n        # Timeout\n        elapsed = time.time() - start_time\n        raise HTTPException(\n            status_code=status.HTTP_504_GATEWAY_TIMEOUT,\n            detail={\n                \"code\": SandboxErrorCodes.K8S_POD_READY_TIMEOUT,\n                \"message\": (\n                    f\"Timeout waiting for sandbox {sandbox_id} to be Running with IP. \"\n                    f\"Elapsed: {elapsed:.1f}s, Last state: {last_state}\"\n                ),\n            },\n        )\n    \n    def _ensure_network_policy_support(self, request: CreateSandboxRequest) -> None:\n        \"\"\"\n        Validate that network policy can be honored under the current runtime config.\n        \n        This validates that egress.image is configured when network_policy is provided.\n        \"\"\"\n        # Common validation: egress.image must be configured\n        ensure_egress_configured(request.network_policy, self.app_config.egress)\n\n    def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None:\n        \"\"\"\n        Validate image auth support for the current workload provider.\n\n        Raises HTTP 400 if the provider does not support per-request image auth.\n        \"\"\"\n        if request.image.auth is None:\n            return\n        if self.workload_provider.supports_image_auth():\n            return\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                \"message\": (\n                    \"image.auth is not supported by the current workload provider. \"\n                    \"Use imagePullSecrets via Kubernetes ServiceAccount or sandbox template.\"\n                ),\n            },\n        )\n\n    async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n        \"\"\"\n        Create a new sandbox using Kubernetes Pod.\n        \n        Wait for the Pod to be Running and have an IP address before returning.\n        \n        Args:\n            request: Sandbox creation request.\n            \n        Returns:\n            CreateSandboxResponse: Created sandbox information with Running state\n            \n        Raises:\n            HTTPException: If creation fails, timeout, or invalid parameters\n        \"\"\"\n        # Validate request\n        ensure_entrypoint(request.entrypoint)\n        ensure_metadata_labels(request.metadata)\n        ensure_timeout_within_limit(\n            request.timeout,\n            self.app_config.server.max_sandbox_timeout_seconds,\n        )\n        self._ensure_network_policy_support(request)\n        self._ensure_image_auth_support(request)\n        \n        # Generate sandbox ID\n        sandbox_id = self.generate_sandbox_id()\n        \n        # Calculate expiration time (None = no TTL, manual cleanup only; same as Docker)\n        created_at = datetime.now(timezone.utc)\n        expires_at = None\n        if request.timeout is not None:\n            expires_at = calculate_expiration_or_raise(created_at, request.timeout)\n\n        # Build labels\n        labels = {\n            SANDBOX_ID_LABEL: sandbox_id,\n        }\n        annotations: Dict[str, str] = {}\n        if expires_at is None:\n            labels[SANDBOX_MANUAL_CLEANUP_LABEL] = \"true\"\n        \n        # Add user metadata as labels\n        if request.metadata:\n            labels.update(request.metadata)\n        \n        # Extract resource limits\n        resource_limits = {}\n        if request.resource_limits and request.resource_limits.root:\n            resource_limits = request.resource_limits.root\n        \n        try:\n            egress_mode = (\n                self.app_config.egress.mode\n                if self.app_config.egress\n                else EGRESS_MODE_DNS\n            )\n            # Get egress image if network policy is provided\n            egress_image = None\n            egress_auth_token = None\n            if request.network_policy:\n                egress_image = self.app_config.egress.image if self.app_config.egress else None\n                egress_auth_token = generate_egress_token()\n                annotations[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_auth_token\n            \n            # Validate volumes before creating workload\n            ensure_volumes_valid(\n                request.volumes,\n                self.app_config.storage.allowed_host_paths or None,\n            )\n            \n            # Create workload\n            workload_info = self.workload_provider.create_workload(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n                image_spec=request.image,\n                entrypoint=request.entrypoint,\n                env=request.env or {},\n                resource_limits=resource_limits,\n                labels=labels,\n                annotations=annotations or None,\n                expires_at=expires_at,\n                execd_image=self.execd_image,\n                extensions=request.extensions,\n                network_policy=request.network_policy,\n                egress_image=egress_image,\n                egress_auth_token=egress_auth_token,\n                egress_mode=egress_mode,\n                volumes=request.volumes,\n            )\n            \n            logger.info(\n                \"Created sandbox: id=%s, workload=%s\",\n                sandbox_id,\n                workload_info.get(\"name\"),\n            )\n            \n            # Wait for Pod to be Running with IP\n            try:\n                workload = await self._wait_for_sandbox_ready(\n                    sandbox_id=sandbox_id,\n                    timeout_seconds=self.app_config.kubernetes.sandbox_create_timeout_seconds,\n                    poll_interval_seconds=self.app_config.kubernetes.sandbox_create_poll_interval_seconds,\n                )\n                \n                # Get final status\n                status_info = self.workload_provider.get_status(workload)\n                \n                # Build and return response with Running state\n                return CreateSandboxResponse(\n                    id=sandbox_id,\n                    status=SandboxStatus(\n                        state=status_info[\"state\"],\n                        reason=status_info[\"reason\"],\n                        message=status_info[\"message\"],\n                        last_transition_at=status_info[\"last_transition_at\"],\n                    ),\n                    created_at=created_at,\n                    expires_at=expires_at,\n                    metadata=request.metadata,\n                    image=request.image,\n                    entrypoint=request.entrypoint,\n                )\n                \n            except HTTPException:\n                # Clean up on failure\n                try:\n                    logger.warning(f\"Creation failed, cleaning up sandbox: {sandbox_id}\")\n                    self.workload_provider.delete_workload(sandbox_id, self.namespace)\n                except Exception as cleanup_ex:\n                    logger.error(f\"Failed to cleanup sandbox {sandbox_id}\", exc_info=cleanup_ex)\n                raise\n            \n        except HTTPException:\n            raise\n        except ValueError as e:\n            # Handle parameter validation errors from provider\n            logger.error(f\"Invalid parameters for sandbox creation: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": str(e),\n                },\n            ) from e\n        except Exception as e:\n            logger.error(f\"Error creating sandbox: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to create sandbox: {str(e)}\",\n                },\n            ) from e\n    \n    def get_sandbox(self, sandbox_id: str) -> Sandbox:\n        \"\"\"\n        Get sandbox by ID.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            \n        Returns:\n            Sandbox: Sandbox information\n            \n        Raises:\n            HTTPException: If sandbox not found\n        \"\"\"\n        try:\n            workload = self.workload_provider.get_workload(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n            )\n            \n            if not workload:\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail={\n                        \"code\": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,\n                        \"message\": f\"Sandbox '{sandbox_id}' not found\",\n                    },\n                )\n            \n            return self._build_sandbox_from_workload(workload)\n            \n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Error getting sandbox {sandbox_id}: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to get sandbox: {str(e)}\",\n                },\n            ) from e\n    \n    def list_sandboxes(self, request: ListSandboxesRequest) -> ListSandboxesResponse:\n        \"\"\"\n        List sandboxes with filtering and pagination.\n        \n        Args:\n            request: List request with filters and pagination\n            \n        Returns:\n            ListSandboxesResponse: Paginated list of sandboxes\n        \"\"\"\n        try:\n            # Build label selector\n            label_selector = SANDBOX_ID_LABEL\n            \n            # List all workloads\n            workloads = self.workload_provider.list_workloads(\n                namespace=self.namespace,\n                label_selector=label_selector,\n            )\n            \n            # Convert to Sandbox objects\n            sandboxes = [\n                self._build_sandbox_from_workload(w) for w in workloads\n            ]\n            \n            # Apply filters\n            filtered = self._apply_filters(sandboxes, request.filter)\n            \n            # Sort by creation time (newest first)\n            filtered.sort(key=lambda s: s.created_at or datetime.min, reverse=True)\n            \n            # Apply pagination\n            total_items = len(filtered)\n            page = request.pagination.page\n            page_size = request.pagination.page_size\n            \n            start_idx = (page - 1) * page_size\n            end_idx = start_idx + page_size\n            paginated_items = filtered[start_idx:end_idx]\n            \n            total_pages = (total_items + page_size - 1) // page_size\n            has_next = page < total_pages\n            \n            return ListSandboxesResponse(\n                items=paginated_items,\n                pagination=PaginationInfo(\n                    page=page,\n                    page_size=page_size,\n                    total_items=total_items,\n                    total_pages=total_pages,\n                    has_next_page=has_next,\n                ),\n            )\n            \n        except Exception as e:\n            logger.error(f\"Error listing sandboxes: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to list sandboxes: {str(e)}\",\n                },\n            ) from e\n    \n    def delete_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Delete a sandbox.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            \n        Raises:\n            HTTPException: If deletion fails\n        \"\"\"\n        try:\n            self.workload_provider.delete_workload(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n            )\n            \n            logger.info(f\"Deleted sandbox: {sandbox_id}\")\n            \n        except Exception as e:\n            if \"not found\" in str(e).lower():\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail={\n                        \"code\": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,\n                        \"message\": f\"Sandbox '{sandbox_id}' not found\",\n                    },\n                ) from e\n            \n            logger.error(f\"Error deleting sandbox {sandbox_id}: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to delete sandbox: {str(e)}\",\n                },\n            ) from e\n    \n    def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause sandbox (not supported in Kubernetes).\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            \n        Raises:\n            HTTPException: Always raises 501 Not Implemented\n        \"\"\"\n        raise HTTPException(\n            status_code=status.HTTP_501_NOT_IMPLEMENTED,\n            detail={\n                \"code\": SandboxErrorCodes.API_NOT_SUPPORTED,\n                \"message\": \"Pause operation is not supported in Kubernetes runtime\",\n            },\n        )\n    \n    def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume sandbox (not supported in Kubernetes).\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            \n        Raises:\n            HTTPException: Always raises 501 Not Implemented\n        \"\"\"\n        raise HTTPException(\n            status_code=status.HTTP_501_NOT_IMPLEMENTED,\n            detail={\n                \"code\": SandboxErrorCodes.API_NOT_SUPPORTED,\n                \"message\": \"Resume operation is not supported in Kubernetes runtime\",\n            },\n        )\n    \n    def renew_expiration(\n        self,\n        sandbox_id: str,\n        request: RenewSandboxExpirationRequest,\n    ) -> RenewSandboxExpirationResponse:\n        \"\"\"\n        Renew sandbox expiration time.\n        \n        Updates both the BatchSandbox spec.expireTime and label for consistency.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            request: Renewal request with new expiration time\n            \n        Returns:\n            RenewSandboxExpirationResponse: Updated expiration time\n            \n        Raises:\n            HTTPException: If renewal fails\n        \"\"\"\n        # Validate future expiration\n        new_expiration = ensure_future_expiration(request.expires_at)\n        \n        try:\n            # Verify sandbox exists\n            workload = self.workload_provider.get_workload(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n            )\n            \n            if not workload:\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail={\n                        \"code\": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,\n                        \"message\": f\"Sandbox '{sandbox_id}' not found\",\n                    },\n                )\n\n            current_expiration = self.workload_provider.get_expiration(workload)\n            if current_expiration is None:\n                raise HTTPException(\n                    status_code=status.HTTP_409_CONFLICT,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_EXPIRATION,\n                        \"message\": f\"Sandbox {sandbox_id} does not have automatic expiration enabled.\",\n                    },\n                )\n\n            # Update BatchSandbox spec.expireTime field\n            self.workload_provider.update_expiration(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n                expires_at=new_expiration,\n            )\n            \n            logger.info(\n                f\"Renewed sandbox {sandbox_id} expiration to {new_expiration}\"\n            )\n            \n            return RenewSandboxExpirationResponse(\n                expires_at=new_expiration\n            )\n            \n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Error renewing expiration for {sandbox_id}: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to renew expiration: {str(e)}\",\n                },\n            ) from e\n    \n    def get_endpoint(\n        self,\n        sandbox_id: str,\n        port: int,\n        resolve_internal: bool = False,\n    ) -> Endpoint:\n        \"\"\"\n        Get sandbox access endpoint.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            port: Port number\n            resolve_internal: Ignored for Kubernetes (always returns Pod IP)\n            \n        Returns:\n            Endpoint: Endpoint information\n            \n        Raises:\n            HTTPException: If endpoint not available\n        \"\"\"\n        self.validate_port(port)\n        \n        try:\n            workload = self.workload_provider.get_workload(\n                sandbox_id=sandbox_id,\n                namespace=self.namespace,\n            )\n            \n            if not workload:\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail={\n                        \"code\": SandboxErrorCodes.K8S_SANDBOX_NOT_FOUND,\n                        \"message\": f\"Sandbox '{sandbox_id}' not found\",\n                    },\n                )\n            \n            endpoint = self.workload_provider.get_endpoint_info(workload, port, sandbox_id)\n            if not endpoint:\n                raise HTTPException(\n                    status_code=status.HTTP_404_NOT_FOUND,\n                    detail={\n                        \"code\": SandboxErrorCodes.K8S_POD_IP_NOT_AVAILABLE,\n                        \"message\": \"Pod IP is not yet available. The Pod may still be starting.\",\n                    },\n                )\n            self._attach_egress_auth_headers(endpoint, workload)\n            return endpoint\n            \n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Error getting endpoint for {sandbox_id}:{port}: {e}\")\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.K8S_API_ERROR,\n                    \"message\": f\"Failed to get endpoint: {str(e)}\",\n                },\n            ) from e\n\n    def _attach_egress_auth_headers(self, endpoint: Endpoint, workload: Any) -> None:\n        token = self._get_egress_auth_token(workload)\n        if not token:\n            return\n\n        endpoint.headers = merge_endpoint_headers(\n            endpoint.headers,\n            build_egress_auth_headers(token),\n        )\n\n    def _get_egress_auth_token(self, workload: Any) -> Optional[str]:\n        if isinstance(workload, dict):\n            metadata = workload.get(\"metadata\", {})\n            annotations = metadata.get(\"annotations\", {}) or {}\n            return annotations.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY)\n\n        metadata = getattr(workload, \"metadata\", None)\n        annotations = getattr(metadata, \"annotations\", None) or {}\n        if isinstance(annotations, dict):\n            return annotations.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY)\n        return None\n\n    def _build_sandbox_from_workload(self, workload: Any) -> Sandbox:\n        \"\"\"\n        Build Sandbox object from Kubernetes workload.\n        \n        Args:\n            workload: Kubernetes workload object (V1Pod or dict for CRD)\n            \n        Returns:\n            Sandbox: Sandbox object\n        \"\"\"\n        # Handle both dict (CRD) and object (Pod) formats\n        if isinstance(workload, dict):\n            metadata = workload.get(\"metadata\", {})\n            spec = workload.get(\"spec\", {})\n            labels = metadata.get(\"labels\", {})\n            creation_timestamp = metadata.get(\"creationTimestamp\")\n        else:\n            metadata = workload.metadata\n            spec = workload.spec\n            labels = metadata.labels or {}\n            creation_timestamp = metadata.creation_timestamp\n        \n        sandbox_id = labels.get(SANDBOX_ID_LABEL, \"\")\n        \n        # Get expiration from provider\n        expires_at = self.workload_provider.get_expiration(workload)\n        \n        # Get status\n        status_info = self.workload_provider.get_status(workload)\n        \n        # Extract metadata (filter out system labels)\n        user_metadata = {\n            k: v for k, v in labels.items()\n            if not k.startswith(\"opensandbox.io/\")\n        }\n        \n        # Get image and entrypoint from spec\n        image_uri = \"\"\n        entrypoint = []\n        \n        if isinstance(workload, dict):\n            # For CRD, extract from template\n            template = spec.get(\"template\") or spec.get(\"podTemplate\") or {}\n            pod_spec = template.get(\"spec\", {})\n            containers = pod_spec.get(\"containers\", [])\n            if containers:\n                container = containers[0]\n                image_uri = container.get(\"image\", \"\")\n                entrypoint = container.get(\"command\", [])\n        else:\n            # For Pod object\n            if hasattr(spec, 'containers') and spec.containers:\n                container = spec.containers[0]\n                image_uri = container.image or \"\"\n                entrypoint = container.command or []\n        \n        image_spec = ImageSpec(uri=image_uri) if image_uri else ImageSpec(uri=\"unknown\")\n        \n        return Sandbox(\n            id=sandbox_id,\n            status=SandboxStatus(\n                state=status_info[\"state\"],\n                reason=status_info[\"reason\"],\n                message=status_info[\"message\"],\n                last_transition_at=status_info[\"last_transition_at\"],\n            ),\n            created_at=creation_timestamp,\n            expires_at=expires_at,\n            metadata=user_metadata if user_metadata else None,\n            image=image_spec,\n            entrypoint=entrypoint,\n        )\n    \n    def _apply_filters(self, sandboxes: list[Sandbox], filter_spec: Any) -> list[Sandbox]:\n        \"\"\"\n        Apply filters to sandbox list.\n        \n        Args:\n            sandboxes: List of sandboxes\n            filter_spec: Filter specification\n            \n        Returns:\n            Filtered list of sandboxes\n        \"\"\"\n        if not filter_spec:\n            return sandboxes\n        \n        filtered = []\n        for sandbox in sandboxes:\n            if matches_filter(sandbox, filter_spec):\n                filtered.append(sandbox)\n        \n        return filtered\n"
  },
  {
    "path": "server/src/services/k8s/provider_factory.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nFactory for creating WorkloadProvider instances.\n\"\"\"\n\nimport logging\nfrom typing import Dict, Type, Optional\n\nfrom src.config import AppConfig\nfrom src.services.k8s.workload_provider import WorkloadProvider\nfrom src.services.k8s.batchsandbox_provider import BatchSandboxProvider\nfrom src.services.k8s.agent_sandbox_provider import AgentSandboxProvider\nfrom src.services.k8s.client import K8sClient\n\nlogger = logging.getLogger(__name__)\n\n# Provider type constants\nPROVIDER_TYPE_BATCHSANDBOX = \"batchsandbox\"\nPROVIDER_TYPE_AGENT_SANDBOX = \"agent-sandbox\"\n\n# Registry of available workload providers\n_PROVIDER_REGISTRY: Dict[str, Type[WorkloadProvider]] = {\n    PROVIDER_TYPE_BATCHSANDBOX: BatchSandboxProvider,\n    PROVIDER_TYPE_AGENT_SANDBOX: AgentSandboxProvider,\n    # Future providers can be registered here:\n    # \"pod\": PodProvider\n}\n\n\ndef create_workload_provider(\n    provider_type: str | None,\n    k8s_client: K8sClient,\n    app_config: Optional[AppConfig] = None,\n) -> WorkloadProvider:\n    \"\"\"\n    Create a WorkloadProvider instance based on the provider type.\n\n    Args:\n        provider_type: Type of provider (e.g., 'batchsandbox', 'pod', 'job').\n                      If None, uses the first registered provider.\n        k8s_client: Kubernetes client instance\n        app_config: Application config; kubernetes/agent_sandbox/ingress sub-configs\n                    are read from it directly.\n\n    Returns:\n        WorkloadProvider instance\n\n    Raises:\n        ValueError: If provider_type is not supported or no providers are registered\n    \"\"\"\n    # Use first registered provider if not specified\n    if provider_type is None:\n        if not _PROVIDER_REGISTRY:\n            raise ValueError(\n                \"No workload providers are registered. \"\n                \"Cannot create a default provider.\"\n            )\n        provider_type = next(iter(_PROVIDER_REGISTRY.keys()))\n        logger.info(f\"No provider specified, using default: {provider_type}\")\n\n    provider_type_lower = provider_type.lower()\n\n    if provider_type_lower not in _PROVIDER_REGISTRY:\n        available = \", \".join(_PROVIDER_REGISTRY.keys())\n        raise ValueError(\n            f\"Unsupported workload provider type '{provider_type}'. \"\n            f\"Available providers: {available}\"\n        )\n\n    provider_class = _PROVIDER_REGISTRY[provider_type_lower]\n    logger.info(f\"Creating workload provider: {provider_class.__name__}\")\n\n    # BatchSandboxProvider and AgentSandboxProvider read all sub-configs from app_config.\n    if provider_type_lower in (PROVIDER_TYPE_BATCHSANDBOX, PROVIDER_TYPE_AGENT_SANDBOX):\n        return provider_class(k8s_client, app_config=app_config)\n\n    # Providers that do not accept app_config\n    return provider_class(k8s_client)\n\n\ndef register_provider(name: str, provider_class: Type[WorkloadProvider]) -> None:\n    \"\"\"\n    Register a custom WorkloadProvider implementation.\n    \n    This allows extending the system with custom provider implementations\n    without modifying core code.\n    \n    Args:\n        name: Provider name (used in configuration)\n        provider_class: Provider class that implements WorkloadProvider\n        \n    Example:\n        from my_module import CustomProvider\n        register_provider(\"custom\", CustomProvider)\n    \"\"\"\n    if not issubclass(provider_class, WorkloadProvider):\n        raise TypeError(\n            f\"Provider class must inherit from WorkloadProvider, \"\n            f\"got {provider_class.__name__}\"\n        )\n    \n    name_lower = name.lower()\n    if name_lower in _PROVIDER_REGISTRY:\n        logger.warning(\n            f\"Overwriting existing provider registration: {name_lower}\"\n        )\n    \n    _PROVIDER_REGISTRY[name_lower] = provider_class\n    logger.info(f\"Registered workload provider: {name_lower} -> {provider_class.__name__}\")\n\n\ndef list_available_providers() -> list[str]:\n    \"\"\"\n    List all registered provider types.\n    \n    Returns:\n        List of provider type names\n    \"\"\"\n    return sorted(_PROVIDER_REGISTRY.keys())\n"
  },
  {
    "path": "server/src/services/k8s/rate_limiter.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nGeneric token-bucket rate limiter.\n\nUsage example::\n\n    limiter = TokenBucketRateLimiter(qps=10.0, burst=20)\n    limiter.acquire()   # blocks until a token is available\n    do_something()\n\"\"\"\n\nimport threading\nimport time\n\n\nclass TokenBucketRateLimiter:\n    \"\"\"Thread-safe token-bucket rate limiter.\n\n    Tokens refill at ``qps`` tokens per second up to a maximum of ``burst``.\n    Calling :meth:`acquire` consumes one token, blocking if the bucket is empty.\n\n    Args:\n        qps: Sustained request rate in requests per second.\n        burst: Maximum burst size (bucket capacity). Defaults to ``qps``,\n               with a minimum of 1 to ensure at least one token is always\n               available regardless of qps.\n    \"\"\"\n\n    def __init__(self, qps: float, burst: float = 0.0) -> None:\n        if qps <= 0:\n            raise ValueError(f\"qps must be > 0, got {qps}\")\n        self._qps = qps\n        self._burst = max(burst if burst > 0 else qps, 1.0)\n        self._tokens = self._burst\n        self._last_refill = time.monotonic()\n        self._lock = threading.Lock()\n\n    # ------------------------------------------------------------------\n    # Public API\n    # ------------------------------------------------------------------\n\n    def acquire(self) -> None:\n        \"\"\"Acquire one token, blocking until one is available.\"\"\"\n        while True:\n            wait = self._try_acquire()\n            if wait <= 0.0:\n                return\n            # Clamp to a minimum of 1 ms to avoid a busy-loop caused by\n            # floating-point imprecision when the deficit is near-zero.\n            time.sleep(max(wait, 0.001))\n\n    def try_acquire(self) -> bool:\n        \"\"\"Try to acquire one token without blocking.\n\n        Returns:\n            ``True`` if a token was consumed, ``False`` if the bucket is empty.\n        \"\"\"\n        return self._try_acquire() <= 0.0\n\n    # ------------------------------------------------------------------\n    # Internal helpers\n    # ------------------------------------------------------------------\n\n    def _try_acquire(self) -> float:\n        \"\"\"Attempt to take a token.\n\n        Returns:\n            0.0 if a token was consumed successfully, otherwise the approximate\n            number of seconds to wait before retrying.\n        \"\"\"\n        with self._lock:\n            self._refill()\n            if self._tokens >= 1.0:\n                self._tokens -= 1.0\n                return 0.0\n            # Time until one token is available\n            return (1.0 - self._tokens) / self._qps\n\n    def _refill(self) -> None:\n        \"\"\"Add tokens proportional to elapsed time (call with lock held).\"\"\"\n        now = time.monotonic()\n        elapsed = now - self._last_refill\n        self._tokens = min(self._burst, self._tokens + elapsed * self._qps)\n        self._last_refill = now\n"
  },
  {
    "path": "server/src/services/k8s/security_context.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Kubernetes V1SecurityContext ↔ plain dict helpers for CRD pod specs.\"\"\"\n\nfrom typing import Any, Dict, Optional\n\n\ndef build_security_context_from_dict(\n    security_context_dict: Dict[str, Any],\n) -> Optional[Any]:\n    \"\"\"\n    Convert a security context dict to ``V1SecurityContext``.\n\n    Empty dict returns None.\n    \"\"\"\n    if not security_context_dict:\n        return None\n\n    from kubernetes.client import V1SecurityContext, V1Capabilities\n\n    capabilities = None\n    if \"capabilities\" in security_context_dict:\n        caps_dict = security_context_dict[\"capabilities\"]\n        add_caps = caps_dict.get(\"add\", [])\n        drop_caps = caps_dict.get(\"drop\", [])\n        capabilities = V1Capabilities(\n            add=add_caps if add_caps else None,\n            drop=drop_caps if drop_caps else None,\n        )\n\n    privileged = security_context_dict.get(\"privileged\")\n\n    if capabilities is None and privileged is None:\n        return None\n\n    return V1SecurityContext(\n        capabilities=capabilities,\n        privileged=privileged,\n    )\n\n\ndef serialize_security_context_to_dict(\n    security_context: Optional[Any],\n) -> Optional[Dict[str, Any]]:\n    \"\"\"Serialize ``V1SecurityContext`` to a CRD-friendly dict.\"\"\"\n    if not security_context:\n        return None\n\n    result: Dict[str, Any] = {}\n\n    if security_context.capabilities:\n        caps: Dict[str, Any] = {}\n        if security_context.capabilities.add:\n            caps[\"add\"] = security_context.capabilities.add\n        if security_context.capabilities.drop:\n            caps[\"drop\"] = security_context.capabilities.drop\n        if caps:\n            result[\"capabilities\"] = caps\n\n    if security_context.privileged is not None:\n        result[\"privileged\"] = security_context.privileged\n\n    return result if result else None\n"
  },
  {
    "path": "server/src/services/k8s/template_manager.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nShared template loader and merger for Kubernetes Sandbox CR manifests.\n\"\"\"\n\nimport logging\nfrom pathlib import Path\nfrom typing import Any, Dict, Optional\n\nimport yaml\n\nlogger = logging.getLogger(__name__)\n\n\nclass BaseSandboxTemplateManager:\n    \"\"\"\n    Shared manager for loading YAML templates and merging runtime manifests.\n    \"\"\"\n\n    def __init__(self, template_file_path: Optional[str], template_kind: str):\n        self.template_file_path = template_file_path\n        self._template_kind = template_kind\n        self._template: Optional[Dict[str, Any]] = None\n\n        if template_file_path:\n            self._load_template()\n\n    def _load_template(self) -> None:\n        if not self.template_file_path:\n            return\n\n        template_path = Path(self.template_file_path).expanduser()\n\n        if not template_path.exists():\n            raise FileNotFoundError(\n                f\"{self._template_kind} template file not found: {template_path}\"\n            )\n\n        try:\n            with template_path.open(\"r\") as f:\n                self._template = yaml.safe_load(f)\n\n            if not isinstance(self._template, dict):\n                raise ValueError(\n                    f\"Invalid template file {template_path}: must be a YAML object, \"\n                    f\"got {type(self._template).__name__}\"\n                )\n\n            logger.info(\"Loaded %s template from %s\", self._template_kind, template_path)\n        except (FileNotFoundError, ValueError):\n            raise\n        except Exception as e:\n            raise RuntimeError(\n                f\"Failed to load {self._template_kind} template from {template_path}: {e}\"\n            ) from e\n\n    def get_base_template(self) -> Dict[str, Any]:\n        if self._template:\n            return self._deep_copy(self._template)\n        return {}\n\n    def merge_with_runtime_values(self, runtime_manifest: Dict[str, Any]) -> Dict[str, Any]:\n        base = self.get_base_template()\n\n        if not base:\n            return runtime_manifest\n\n        return self._deep_merge(base, runtime_manifest)\n\n    @staticmethod\n    def _deep_copy(obj: Any) -> Any:\n        if isinstance(obj, dict):\n            return {k: BaseSandboxTemplateManager._deep_copy(v) for k, v in obj.items()}\n        if isinstance(obj, list):\n            return [BaseSandboxTemplateManager._deep_copy(item) for item in obj]\n        return obj\n\n    @staticmethod\n    def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:\n        result = base.copy()\n\n        for key, override_value in override.items():\n            if override_value is None:\n                continue\n\n            if key not in result:\n                result[key] = BaseSandboxTemplateManager._deep_copy(override_value)\n            elif isinstance(result[key], dict) and isinstance(override_value, dict):\n                result[key] = BaseSandboxTemplateManager._deep_merge(\n                    result[key], override_value\n                )\n            else:\n                result[key] = BaseSandboxTemplateManager._deep_copy(override_value)\n\n        return result\n"
  },
  {
    "path": "server/src/services/k8s/volume_helper.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nVolume helper utilities for Kubernetes pod specs.\n\"\"\"\n\nimport logging\nfrom typing import Any, Dict, List\n\nfrom src.api.schema import Volume\n\nlogger = logging.getLogger(__name__)\n\n\ndef apply_volumes_to_pod_spec(\n    pod_spec: Dict[str, Any],\n    volumes: List[Volume],\n) -> None:\n    \"\"\"\n    Apply user-specified volumes to a Kubernetes pod spec.\n\n    This function converts Volume API objects to Kubernetes volume and volumeMount\n    definitions and adds them to the pod spec in-place.\n\n    Currently supported backends:\n    - pvc: Maps to Kubernetes PersistentVolumeClaim\n    - host: Maps to Kubernetes hostPath volume\n\n    Args:\n        pod_spec: The pod spec dictionary to modify in-place\n        volumes: List of Volume API objects\n\n    Raises:\n        ValueError: If an unsupported volume backend is specified\n    \"\"\"\n    containers = pod_spec.get(\"containers\", [])\n    if not containers:\n        logger.warning(\"No containers in pod spec, skipping volume mounts\")\n        return\n\n    main_container = containers[0]\n    mounts = main_container.get(\"volumeMounts\", [])\n    pod_volumes = pod_spec.get(\"volumes\", [])\n\n    # Collect existing volume names to prevent collisions with internal volumes\n    existing_volume_names = {v.get(\"name\") for v in pod_volumes if isinstance(v, dict)}\n    # One Kubernetes volume per unique PVC; multiple volumeMounts can reference it\n    pvc_to_volume_name: Dict[str, str] = {}\n\n    for vol in volumes:\n        vol_name = vol.name\n\n        # Check for collision with internal volumes\n        if vol_name in existing_volume_names:\n            raise ValueError(\n                f\"Volume name '{vol_name}' conflicts with an internal volume. \"\n                \"Please use a different volume name.\"\n            )\n\n        if vol.pvc is not None:\n            # PVC backend: maps to Kubernetes PersistentVolumeClaim.\n            # Multiple Volume API objects sharing the same claim_name must produce\n            # a single Kubernetes volume and multiple volumeMounts (CSI drivers\n            # can fail when the same PVC is defined in multiple volume entries).\n            pvc_claim_name = vol.pvc.claim_name\n\n            if pvc_claim_name not in pvc_to_volume_name:\n                # First use of this PVC: create one volume, use current vol.name as volume name\n                pod_volumes.append({\n                    \"name\": vol_name,\n                    \"persistentVolumeClaim\": {\n                        \"claimName\": pvc_claim_name,\n                    },\n                })\n                pvc_to_volume_name[pvc_claim_name] = vol_name\n                existing_volume_names.add(vol_name)\n\n            mount = {\n                \"name\": pvc_to_volume_name[pvc_claim_name],\n                \"mountPath\": vol.mount_path,\n                \"readOnly\": vol.read_only,\n            }\n            if vol.sub_path:\n                mount[\"subPath\"] = vol.sub_path\n            mounts.append(mount)\n\n            logger.info(\n                f\"Added PVC volume '{vol_name}' (claim: {pvc_claim_name}) mounted at '{vol.mount_path}' for sandbox\"\n            )\n        elif vol.host is not None:\n            # Host backend: maps to hostPath volume\n            # Note: hostPath is node-local and not recommended for production\n            host_path = vol.host.path\n\n            pod_volumes.append({\n                \"name\": vol_name,\n                \"hostPath\": {\n                    \"path\": host_path,\n                    \"type\": \"DirectoryOrCreate\",\n                },\n            })\n\n            mount = {\n                \"name\": vol_name,\n                \"mountPath\": vol.mount_path,\n                \"readOnly\": vol.read_only,\n            }\n            if vol.sub_path:\n                mount[\"subPath\"] = vol.sub_path\n            mounts.append(mount)\n\n            logger.info(\n                f\"Added hostPath volume '{vol_name}' (path: {host_path}) mounted at '{vol.mount_path}' for sandbox\"\n            )\n        else:\n            raise ValueError(\n                f\"Volume '{vol_name}' has no supported backend specified. \"\n                \"Supported backends: pvc, host\"\n            )\n\n    # Update pod spec with modified volumes and mounts\n    pod_spec[\"volumes\"] = pod_volumes\n    main_container[\"volumeMounts\"] = mounts\n"
  },
  {
    "path": "server/src/services/k8s/workload_provider.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAbstract workload provider interface for Kubernetes resources.\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime\nfrom typing import Dict, List, Any, Optional\n\nfrom src.api.schema import Endpoint, ImageSpec, NetworkPolicy, Volume\nfrom src.config import EGRESS_MODE_DNS\n\n\nclass WorkloadProvider(ABC):\n    \"\"\"\n    Abstract interface for managing Kubernetes workload resources.\n    \n    This abstraction allows supporting different K8s resource types\n    (Pod, Job, StatefulSet, etc.) with a unified interface.\n    \"\"\"\n    \n    @abstractmethod\n    def create_workload(\n        self,\n        sandbox_id: str,\n        namespace: str,\n        image_spec: ImageSpec,\n        entrypoint: List[str],\n        env: Dict[str, str],\n        resource_limits: Dict[str, str],\n        labels: Dict[str, str],\n        expires_at: Optional[datetime],\n        execd_image: str,\n        extensions: Optional[Dict[str, str]] = None,\n        network_policy: Optional[NetworkPolicy] = None,\n        egress_image: Optional[str] = None,\n        volumes: Optional[List[Volume]] = None,\n        annotations: Optional[Dict[str, str]] = None,\n        egress_auth_token: Optional[str] = None,\n        egress_mode: str = EGRESS_MODE_DNS,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Create a new workload resource.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            namespace: Kubernetes namespace\n            image_spec: Container image specification\n            entrypoint: Container entrypoint command\n            env: Environment variables\n            resource_limits: Resource limits (cpu, memory)\n            labels: Labels to apply to the workload\n            expires_at: Expiration time, or None for manual cleanup (no TTL)\n            execd_image: execd daemon image\n            extensions: General extension field for passing additional configuration.\n                This is a flexible field for various use cases (e.g., ``poolRef`` for pool-based creation).\n            network_policy: Optional network policy for egress traffic control.\n                When provided, an egress sidecar container will be added to the Pod.\n            egress_image: Optional egress sidecar image. Required when network_policy is provided.\n            egress_mode: Sidecar ``OPENSANDBOX_EGRESS_MODE`` (from app ``[egress].mode`` when using network policy).\n            volumes: Optional list of volume mounts for the sandbox.\n\n        Returns:\n            Dict containing workload metadata (name, uid, etc.)\n\n        Raises:\n            ApiException: If creation fails\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def get_workload(self, sandbox_id: str, namespace: str) -> Optional[Any]:\n        \"\"\"\n        Get workload by sandbox ID.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            namespace: Kubernetes namespace\n            \n        Returns:\n            Workload object or None if not found\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def delete_workload(self, sandbox_id: str, namespace: str) -> None:\n        \"\"\"\n        Delete a workload resource.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            namespace: Kubernetes namespace\n            \n        Raises:\n            ApiException: If deletion fails\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def list_workloads(self, namespace: str, label_selector: str) -> List[Any]:\n        \"\"\"\n        List workloads matching label selector.\n        \n        Args:\n            namespace: Kubernetes namespace\n            label_selector: Label selector query\n            \n        Returns:\n            List of workload objects\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def update_expiration(self, sandbox_id: str, namespace: str, expires_at: datetime) -> None:\n        \"\"\"\n        Update workload expiration time.\n        \n        Args:\n            sandbox_id: Unique sandbox identifier\n            namespace: Kubernetes namespace\n            expires_at: New expiration time\n            \n        Raises:\n            Exception: If update fails\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def get_expiration(self, workload: Any) -> Optional[datetime]:\n        \"\"\"\n        Get expiration time from workload.\n        \n        Args:\n            workload: Workload object\n            \n        Returns:\n            Expiration datetime or None if not set\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def get_status(self, workload: Any) -> Dict[str, Any]:\n        \"\"\"\n        Get status from workload object.\n        \n        Args:\n            workload: Workload object\n            \n        Returns:\n            Dict with state, reason, message, last_transition_at\n        \"\"\"\n        pass\n    \n    @abstractmethod\n    def get_endpoint_info(self, workload: Any, port: int, sandbox_id: str) -> Optional[Endpoint]:\n        \"\"\"\n        Get endpoint information from workload.\n        \n        Args:\n            workload: Workload object\n            port: Port number\n            sandbox_id: Sandbox identifier for ingress-based endpoints\n            \n        Returns:\n            Endpoint object (including optional headers) or None if not available\n        \"\"\"\n        pass\n\n    def supports_image_auth(self) -> bool:\n        \"\"\"\n        Whether this provider supports per-request image pull authentication.\n\n        Providers that implement imagePullSecrets injection should override\n        this method to return True.\n        \"\"\"\n        return False\n\n    def legacy_resource_name(self, sandbox_id: str) -> str:\n        \"\"\"\n        Convert a sandbox_id to the legacy resource name with prefix.\n\n        Pre-upgrade sandboxes were named ``sandbox-<id>``. This helper\n        preserves access to those resources while allowing plain IDs\n        for new ones.\n        \"\"\"\n        if sandbox_id.startswith(\"sandbox-\"):\n            return sandbox_id\n        return f\"sandbox-{sandbox_id}\"\n"
  },
  {
    "path": "server/src/services/ossfs_mixin.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"OSSFS-specific Docker runtime behaviors.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport os\nimport posixpath\nimport re\nimport subprocess\nimport tempfile\nfrom typing import Any, Optional\nfrom uuid import uuid4\n\nfrom fastapi import HTTPException, status\n\nfrom src.services.constants import SandboxErrorCodes\nfrom src.services.helpers import normalize_external_endpoint_url\n\nlogger = logging.getLogger(__name__)\n\n\nclass OSSFSMixin:\n    @staticmethod\n    def _validate_bucket_name(bucket: str) -> None:\n        \"\"\"\n        Validate OSS bucket name to prevent command injection.\n        \n        Bucket names must follow OSS naming rules: lowercase letters, numbers, hyphens.\n        Length: 3-63 characters. Cannot start/end with hyphen.\n        \"\"\"\n        if not bucket or not isinstance(bucket, str):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": \"OSSFS bucket name cannot be empty.\",\n                },\n            )\n        \n        # OSS bucket naming: 3-63 chars, lowercase alphanumeric and hyphens only\n        # Must start and end with lowercase letter or digit\n        if not re.match(r'^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?$', bucket):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": (\n                        f\"Invalid bucket name '{bucket}'. Bucket names must be 3-63 characters, \"\n                        \"contain only lowercase letters, numbers, and hyphens, \"\n                        \"and start/end with a letter or number.\"\n                    ),\n                },\n            )\n\n    @staticmethod\n    def _validate_ossfs_option(option: str) -> None:\n        \"\"\"\n        Validate OSSFS option to prevent command injection.\n        \n        Options should not contain shell metacharacters or command separators.\n        \"\"\"\n        # Check for dangerous characters that could be used for command injection\n        dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\\n', '\\r']\n        for char in dangerous_chars:\n            if char in option:\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                        \"message\": (\n                            f\"Invalid OSSFS option: contains forbidden character '{char}'. \"\n                            \"Options must not contain shell metacharacters.\"\n                        ),\n                    },\n                )\n\n    @staticmethod\n    def _validate_mount_path(path: str) -> None:\n        \"\"\"\n        Validate mount path to prevent command injection in unmount operations.\n        \n        Path must be absolute and not contain dangerous characters.\n        \"\"\"\n        if not path or not isinstance(path, str):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": \"Mount path cannot be empty.\",\n                },\n            )\n        \n        # Path must be absolute\n        if not path.startswith('/'):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": f\"Mount path must be absolute: '{path}'\",\n                },\n            )\n        \n        # Check for dangerous characters that could be used for command injection\n        dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\\n', '\\r']\n        for char in dangerous_chars:\n            if char in path:\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                        \"message\": (\n                            f\"Invalid mount path: contains forbidden character '{char}'. \"\n                            \"Paths must not contain shell metacharacters.\"\n                        ),\n                    },\n                )\n\n    @staticmethod\n    def _validate_endpoint_url(endpoint_url: str) -> None:\n        \"\"\"\n        Validate endpoint URL to prevent command injection.\n        \n        URL should not contain dangerous shell metacharacters.\n        \"\"\"\n        if not endpoint_url or not isinstance(endpoint_url, str):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": \"Endpoint URL cannot be empty.\",\n                },\n            )\n        \n        # Check for dangerous characters\n        dangerous_chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\\n', '\\r', ' ']\n        for char in dangerous_chars:\n            if char in endpoint_url:\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                        \"message\": (\n                            f\"Invalid endpoint URL: contains forbidden character '{char}'. \"\n                            \"URLs must not contain shell metacharacters or spaces.\"\n                        ),\n                    },\n                )\n\n    @staticmethod\n    def _normalize_ossfs_option(raw_option: str) -> str:\n        option = str(raw_option).strip()\n        if not option:\n            return \"\"\n        return option\n\n    def _resolve_ossfs_paths(self, volume) -> tuple[str, str]:\n        \"\"\"\n        Resolve OSSFS base mount path and bind path.\n\n        For OSSFS, ``volume.subPath`` represents the bucket prefix.\n        The backend mount path and bind path are identical:\n        - path = ossfs_mount_root/<bucket>/<subPath?>\n        \"\"\"\n        mount_root = (self.app_config.storage.ossfs_mount_root or \"\").strip()\n        if not mount_root.startswith(\"/\"):\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_OSSFS_MOUNT_ROOT,\n                    \"message\": (\n                        \"storage.ossfs_mount_root must be configured as an absolute path.\"\n                    ),\n                },\n            )\n\n        mount_root = posixpath.normpath(mount_root)\n        bucket_root = posixpath.normpath(posixpath.join(mount_root, volume.ossfs.bucket))\n        prefix = (volume.sub_path or \"\").lstrip(\"/\")\n        backend_path = posixpath.normpath(posixpath.join(bucket_root, prefix))\n\n        bucket_prefix = bucket_root if bucket_root.endswith(\"/\") else bucket_root + \"/\"\n        if backend_path != bucket_root and not backend_path.startswith(bucket_prefix):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_SUB_PATH,\n                    \"message\": (\n                        f\"Volume '{volume.name}': resolved OSSFS prefix escapes bucket root.\"\n                    ),\n                },\n            )\n\n        return backend_path, backend_path\n\n    def _build_ossfs_v1_command(\n        self,\n        volume,\n        source: str,\n        backend_path: str,\n        endpoint_url: str,\n        passwd_file: str,\n    ) -> list[str]:\n        # Validate inputs for security\n        self._validate_bucket_name(volume.ossfs.bucket)\n        self._validate_endpoint_url(endpoint_url)\n        self._validate_mount_path(backend_path)\n        \n        cmd: list[str] = [\n            \"ossfs\",\n            source,\n            backend_path,\n            \"-o\",\n            f\"url={endpoint_url}\",\n            \"-o\",\n            f\"passwd_file={passwd_file}\",\n        ]\n        if volume.ossfs.options:\n            for raw_opt in volume.ossfs.options:\n                opt = self._normalize_ossfs_option(raw_opt)\n                if opt:\n                    # Validate each option for dangerous characters\n                    self._validate_ossfs_option(opt)\n                    cmd.extend([\"-o\", opt])\n        return cmd\n\n    def _build_ossfs_v2_config_lines(\n        self,\n        volume,\n        endpoint_url: str,\n        prefix: str,\n    ) -> list[str]:\n        # Validate inputs for security\n        self._validate_bucket_name(volume.ossfs.bucket)\n        self._validate_endpoint_url(endpoint_url)\n        \n        conf_lines: list[str] = [\n            f\"--oss_endpoint={endpoint_url}\",\n            f\"--oss_bucket={volume.ossfs.bucket}\",\n            f\"--oss_access_key_id={volume.ossfs.access_key_id}\",\n            f\"--oss_access_key_secret={volume.ossfs.access_key_secret}\",\n        ]\n        if prefix:\n            normalized_prefix = prefix if prefix.endswith(\"/\") else f\"{prefix}/\"\n            conf_lines.append(f\"--oss_bucket_prefix={normalized_prefix}\")\n        if volume.ossfs.options:\n            for raw_opt in volume.ossfs.options:\n                opt = self._normalize_ossfs_option(raw_opt)\n                if opt:\n                    # Validate each option for dangerous characters\n                    self._validate_ossfs_option(opt)\n                    conf_lines.append(f\"--{opt}\")\n        return conf_lines\n\n    def _build_ossfs_v2_mount_command(self, backend_path: str, conf_file: str) -> list[str]:\n        # Validate backend path for security\n        self._validate_mount_path(backend_path)\n        return [\"ossfs2\", \"mount\", backend_path, \"-c\", conf_file]\n\n    @staticmethod\n    def _run_ossfs_mount_command(cmd: list[str], volume_name: str) -> None:\n        result = subprocess.run(\n            cmd,\n            capture_output=True,\n            text=True,\n            timeout=30,\n            check=False,\n        )\n        if result.returncode != 0:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.OSSFS_MOUNT_FAILED,\n                    \"message\": (\n                        f\"Volume '{volume_name}': failed to mount OSSFS backend. \"\n                        f\"stderr={result.stderr.strip() or 'unknown error'}\"\n                    ),\n                },\n            )\n\n    def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None:\n        \"\"\"Mount OSS bucket/path to backend_path with version-specific OSSFS arguments.\"\"\"\n        access_key_id = volume.ossfs.access_key_id\n        access_key_secret = volume.ossfs.access_key_secret\n        if not access_key_id or not access_key_secret:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS,\n                    \"message\": (\n                        \"OSSFS inline credentials are required: \"\n                        \"accessKeyId and accessKeySecret must be provided.\"\n                    ),\n                },\n            )\n        os.makedirs(backend_path, exist_ok=True)\n\n        bucket = volume.ossfs.bucket\n        prefix = (volume.sub_path or \"\").strip(\"/\")\n        source = f\"{bucket}:/{prefix}\" if prefix else bucket\n        endpoint = volume.ossfs.endpoint\n        endpoint_url = normalize_external_endpoint_url(endpoint)\n\n        passwd_file: Optional[str] = None\n        conf_file: Optional[str] = None\n        version = volume.ossfs.version or \"2.0\"\n        try:\n            if version == \"1.0\":\n                passwd_file = os.path.join(\n                    tempfile.gettempdir(),\n                    f\"opensandbox-ossfs-inline-{uuid4().hex}\",\n                )\n                with open(passwd_file, \"w\", encoding=\"utf-8\") as f:\n                    # ossfs passwd_file format: bucket:accessKeyId:accessKeySecret\n                    f.write(f\"{bucket}:{access_key_id}:{access_key_secret}\")\n                os.chmod(passwd_file, 0o600)\n                cmd = self._build_ossfs_v1_command(\n                    volume=volume,\n                    source=source,\n                    backend_path=backend_path,\n                    endpoint_url=endpoint_url,\n                    passwd_file=passwd_file,\n                )\n            elif version == \"2.0\":\n                conf_lines = self._build_ossfs_v2_config_lines(\n                    volume=volume,\n                    endpoint_url=endpoint_url,\n                    prefix=prefix,\n                )\n                conf_file = os.path.join(\n                    tempfile.gettempdir(),\n                    f\"opensandbox-ossfs2-{uuid4().hex}.conf\",\n                )\n                with open(conf_file, \"w\", encoding=\"utf-8\") as f:\n                    f.write(\"\\n\".join(conf_lines) + \"\\n\")\n                os.chmod(conf_file, 0o600)\n                cmd = self._build_ossfs_v2_mount_command(backend_path, conf_file)\n            else:\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_OSSFS_VERSION,\n                        \"message\": (\n                            f\"Volume '{volume.name}': unsupported OSSFS version '{version}'.\"\n                        ),\n                    },\n                )\n            self._run_ossfs_mount_command(cmd, volume.name)\n        except OSError as exc:\n            raise HTTPException(\n                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                detail={\n                    \"code\": SandboxErrorCodes.OSSFS_MOUNT_FAILED,\n                    \"message\": (\n                        f\"Volume '{volume.name}': failed to execute ossfs command: {exc}\"\n                    ),\n                },\n            ) from exc\n        finally:\n            if passwd_file:\n                try:\n                    os.remove(passwd_file)\n                except OSError:\n                    pass\n            if conf_file:\n                try:\n                    os.remove(conf_file)\n                except OSError:\n                    pass\n\n    def _ensure_ossfs_mounted(self, volume_or_mount_key) -> str:\n        \"\"\"Ensure OSSFS backend path is mounted and return mount key.\"\"\"\n        if isinstance(volume_or_mount_key, str):\n            mount_key = volume_or_mount_key\n            backend_path = volume_or_mount_key\n            volume = None\n        else:\n            volume = volume_or_mount_key\n            backend_path, _ = self._resolve_ossfs_paths(volume)\n            mount_key = backend_path\n\n        with self._ossfs_mount_lock:\n            current = self._ossfs_mount_ref_counts.get(mount_key, 0)\n            if current > 0:\n                self._ossfs_mount_ref_counts[mount_key] = current + 1\n                return mount_key\n\n            if not os.path.ismount(backend_path):\n                if volume is None:\n                    raise HTTPException(\n                        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                        detail={\n                            \"code\": SandboxErrorCodes.OSSFS_MOUNT_FAILED,\n                            \"message\": (\n                                f\"Failed to mount OSSFS path '{mount_key}': \"\n                                \"missing volume context.\"\n                            ),\n                        },\n                    )\n                self._mount_ossfs_backend_path(volume, backend_path)\n\n            self._ossfs_mount_ref_counts[mount_key] = 1\n            return mount_key\n\n    def _release_ossfs_mount(self, mount_key: str) -> None:\n        \"\"\"Release one reference and unmount when ref count reaches zero.\"\"\"\n        # Validate mount path before using in unmount commands\n        self._validate_mount_path(mount_key)\n        \n        with self._ossfs_mount_lock:\n            current = self._ossfs_mount_ref_counts.get(mount_key, 0)\n            if current <= 0:\n                logger.warning(\n                    \"Skipping OSSFS unmount for untracked mount key '%s'.\",\n                    mount_key,\n                )\n                return\n            if current == 1:\n                self._ossfs_mount_ref_counts.pop(mount_key, None)\n                should_unmount = True\n            else:\n                self._ossfs_mount_ref_counts[mount_key] = current - 1\n                should_unmount = False\n\n        if not should_unmount or not os.path.ismount(mount_key):\n            return\n\n        errors: list[str] = []\n        for cmd in ([\"fusermount\", \"-u\", mount_key], [\"umount\", mount_key]):\n            result = subprocess.run(\n                cmd,\n                capture_output=True,\n                text=True,\n                timeout=20,\n                check=False,\n            )\n            if result.returncode == 0:\n                return\n            errors.append(result.stderr.strip() or \"unknown error\")\n\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail={\n                \"code\": SandboxErrorCodes.OSSFS_UNMOUNT_FAILED,\n                \"message\": f\"Failed to unmount OSSFS path '{mount_key}': {'; '.join(errors)}\",\n            },\n        )\n\n    def _release_ossfs_mounts(self, mount_keys: list[str]) -> None:\n        for key in mount_keys:\n            try:\n                self._release_ossfs_mount(key)\n            except HTTPException as exc:\n                logger.warning(\"Failed to release OSSFS mount %s: %s\", key, exc.detail)\n\n    def _prepare_ossfs_mounts(self, volumes: Optional[list]) -> list[str]:\n        if not volumes:\n            return []\n        key_to_volume: dict[str, Any] = {}\n        prepared_mount_keys: list[str] = []\n        for volume in volumes:\n            if volume.ossfs is not None:\n                mount_key, _ = self._resolve_ossfs_paths(volume)\n                if mount_key not in key_to_volume:\n                    key_to_volume[mount_key] = volume\n        try:\n            for mount_key, volume in key_to_volume.items():\n                self._ensure_ossfs_mounted(volume)\n                prepared_mount_keys.append(mount_key)\n            return list(key_to_volume.keys())\n        except Exception:\n            # Roll back mounts already prepared in this batch.\n            self._release_ossfs_mounts(prepared_mount_keys)\n            raise\n\n    def _validate_ossfs_volume(self, volume) -> None:\n        \"\"\"\n        Docker-specific validation for OSSFS backend.\n\n        Ensures inline credentials and path semantics are valid.\n        \"\"\"\n        if os.name == \"nt\":\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                    \"message\": (\n                        \"OSSFS backend on Docker runtime requires a Linux host with FUSE support. \"\n                        \"Running OpenSandbox Server on Windows is not supported for OSSFS mounts.\"\n                    ),\n                },\n            )\n\n        if not volume.ossfs.access_key_id or not volume.ossfs.access_key_secret:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS,\n                    \"message\": (\n                        \"OSSFS inline credentials are required: \"\n                        \"accessKeyId and accessKeySecret must be provided.\"\n                    ),\n                },\n            )\n\n        self._resolve_ossfs_paths(volume)\n"
  },
  {
    "path": "server/src/services/runtime_resolver.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nSecure runtime resolver for translating secure runtime configuration\nto backend-specific parameters (Docker --runtime, Kubernetes RuntimeClass).\n\nThis module provides:\n- SecureRuntimeResolver: Translates AppConfig to runtime parameters\n- validate_secure_runtime_on_startup: Validates runtime availability at server startup\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nfrom typing import TYPE_CHECKING, Optional\n\nfrom kubernetes.client.exceptions import ApiException\n\nlogger = logging.getLogger(__name__)\n\nif TYPE_CHECKING:\n    from docker import DockerClient\n    from src.config import AppConfig, SecureRuntimeConfig\n    from src.services.k8s.client import K8sClient\n\n\nclass SecureRuntimeResolver:\n    \"\"\"\n    Resolver for secure container runtime configuration.\n\n    Translates server-level secure_runtime configuration into\n    backend-specific parameters:\n    - Docker: OCI runtime name (e.g., \"runsc\", \"kata-runtime\")\n    - Kubernetes: RuntimeClass name (e.g., \"gvisor\", \"kata-qemu\")\n    \"\"\"\n\n    # Default runtime mappings\n    DEFAULT_DOCKER_RUNTIMES = {\n        \"gvisor\": \"runsc\",\n        \"kata\": \"kata-runtime\",\n    }\n\n    DEFAULT_K8S_RUNTIME_CLASSES = {\n        \"gvisor\": \"gvisor\",\n        \"kata\": \"kata-qemu\",\n        \"firecracker\": \"kata-fc\",\n    }\n\n    def __init__(self, config: AppConfig):\n        \"\"\"\n        Initialize the resolver with application configuration.\n\n        Args:\n            config: Application configuration containing secure_runtime settings\n        \"\"\"\n        self.secure_runtime: Optional[SecureRuntimeConfig] = getattr(\n            config, \"secure_runtime\", None\n        )\n        self.runtime_mode = config.runtime.type  # \"docker\" or \"kubernetes\"\n\n    def is_enabled(self) -> bool:\n        \"\"\"Check if secure runtime is configured and enabled.\"\"\"\n        return (\n            self.secure_runtime is not None\n            and self.secure_runtime.type != \"\"\n        )\n\n    def get_docker_runtime(self) -> Optional[str]:\n        \"\"\"\n        Get the Docker OCI runtime name for secure containers.\n\n        Returns the configured docker_runtime if set, otherwise uses\n        the default mapping for the secure runtime type.\n\n        Returns:\n            OCI runtime name (e.g., \"runsc\", \"kata-runtime\") or None\n        \"\"\"\n        if not self.is_enabled():\n            return None\n\n        if self.secure_runtime is None:\n            return None\n\n        # Use explicit docker_runtime if configured\n        if self.secure_runtime.docker_runtime:\n            return self.secure_runtime.docker_runtime\n\n        # Fall back to default mapping\n        runtime_type = self.secure_runtime.type\n        return self.DEFAULT_DOCKER_RUNTIMES.get(runtime_type)\n\n    def get_k8s_runtime_class(self) -> Optional[str]:\n        \"\"\"\n        Get the Kubernetes RuntimeClass name for secure containers.\n\n        Returns the configured k8s_runtime_class if set, otherwise uses\n        the default mapping for the secure runtime type.\n\n        Returns:\n            RuntimeClass name (e.g., \"gvisor\", \"kata-qemu\") or None\n        \"\"\"\n        if not self.is_enabled():\n            return None\n\n        if self.secure_runtime is None:\n            return None\n\n        # Use explicit k8s_runtime_class if configured\n        if self.secure_runtime.k8s_runtime_class:\n            return self.secure_runtime.k8s_runtime_class\n\n        # Fall back to default mapping\n        runtime_type = self.secure_runtime.type\n        return self.DEFAULT_K8S_RUNTIME_CLASSES.get(runtime_type)\n\n\nasync def validate_secure_runtime_on_startup(\n    config: AppConfig,\n    docker_client: Optional[\"DockerClient\"] = None,\n    k8s_client: Optional[\"K8sClient\"] = None,\n) -> None:\n    \"\"\"\n    Validate that configured secure runtimes are available at startup.\n\n    This function performs fail-fast validation to ensure the server\n    starts with a valid secure runtime configuration. It checks:\n    - Docker runtimes: Verifies the runtime exists in Docker daemon\n    - Kubernetes RuntimeClasses: Verifies the RuntimeClass exists in cluster\n\n    Args:\n        config: Application configuration\n        docker_client: Optional Docker client for runtime validation\n        k8s_client: Optional K8s client wrapper for RuntimeClass validation\n\n    Raises:\n        ValueError: If a configured secure runtime is not available\n        Exception: For other validation errors\n    \"\"\"\n    resolver = SecureRuntimeResolver(config)\n\n    if not resolver.is_enabled():\n        logger.info(\"Secure runtime is not configured.\")\n        return\n\n    if config.runtime.type == \"docker\":\n        await _validate_docker_runtime(resolver, docker_client)\n    elif config.runtime.type == \"kubernetes\":\n        await _validate_k8s_runtime_class(resolver, k8s_client, config)\n    else:\n        logger.warning(\n            \"Secure runtime validation skipped for unknown runtime type: %s\",\n            config.runtime.type,\n        )\n\n\nasync def _validate_docker_runtime(\n    resolver: SecureRuntimeResolver,\n    docker_client: Optional[\"DockerClient\"],\n) -> None:\n    \"\"\"Validate that the Docker OCI runtime exists.\"\"\"\n    runtime_name = resolver.get_docker_runtime()\n\n    if not runtime_name:\n        logger.info(\"No Docker runtime configured for secure containers.\")\n        return\n\n    logger.info(\"Validating Docker OCI runtime: %s\", runtime_name)\n\n    if docker_client is None:\n        logger.warning(\n            \"Docker client not available; skipping runtime validation. \"\n            \"Runtime '%s' will be used but not validated.\",\n            runtime_name,\n        )\n        return\n\n    try:\n        # Get list of available runtimes from Docker daemon\n        # Docker stores runtimes in daemon configuration\n        info = docker_client.info()\n        runtimes = info.get(\"Runtimes\", {})\n\n        if runtime_name not in runtimes:\n            available = \", \".join(runtimes.keys()) if runtimes else \"none\"\n            raise ValueError(\n                f\"Configured Docker runtime '{runtime_name}' is not available. \"\n                f\"Available runtimes: {available}. \"\n                f\"Please install and configure the runtime before starting the server.\"\n            )\n\n        logger.info(\n            \"Docker OCI runtime '%s' is available: %s\",\n            runtime_name,\n            runtimes.get(runtime_name, {}),\n        )\n    except Exception as exc:\n        logger.error(\"Failed to validate Docker runtime: %s\", exc)\n        raise\n\n\nasync def _validate_k8s_runtime_class(\n    resolver: SecureRuntimeResolver,\n    k8s_client: Optional[\"K8sClient\"],\n    config: AppConfig,\n) -> None:\n    \"\"\"Validate that the Kubernetes RuntimeClass exists.\"\"\"\n    runtime_class_name = resolver.get_k8s_runtime_class()\n\n    if not runtime_class_name:\n        logger.info(\"No Kubernetes RuntimeClass configured for secure containers.\")\n        return\n\n    logger.info(\"Validating Kubernetes RuntimeClass: %s\", runtime_class_name)\n\n    if k8s_client is None:\n        logger.warning(\n            \"Kubernetes client not available; skipping RuntimeClass validation. \"\n            \"RuntimeClass '%s' will be used but not validated.\",\n            runtime_class_name,\n        )\n        return\n\n    try:\n        loop = asyncio.get_event_loop()\n        await loop.run_in_executor(None, k8s_client.read_runtime_class, runtime_class_name)\n        logger.info(\"Kubernetes RuntimeClass '%s' is available.\", runtime_class_name)\n    except ApiException as exc:\n        if exc.status == 404:\n            raise ValueError(\n                f\"Configured Kubernetes RuntimeClass '{runtime_class_name}' does not exist. \"\n                f\"Please create the RuntimeClass before starting the server.\"\n            ) from exc\n        logger.error(\"Failed to validate RuntimeClass: %s\", exc)\n        raise\n    except Exception as exc:\n        logger.error(\"Failed to validate RuntimeClass: %s\", exc)\n        raise\n\n\n__all__ = [\n    \"SecureRuntimeResolver\",\n    \"validate_secure_runtime_on_startup\",\n]\n"
  },
  {
    "path": "server/src/services/sandbox_service.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nSandbox service layer for business logic.\n\nThis module contains the business logic for sandbox lifecycle management.\nThis module defines the abstract interface for sandbox services.\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nimport socket\nfrom uuid import uuid4\n\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    CreateSandboxResponse,\n    Endpoint,\n    ListSandboxesRequest,\n    ListSandboxesResponse,\n    RenewSandboxExpirationRequest,\n    RenewSandboxExpirationResponse,\n    Sandbox,\n)\nfrom src.services.validators import ensure_valid_port\n\n\nclass SandboxService(ABC):\n    \"\"\"\n    Abstract service interface for sandbox lifecycle operations.\n\n    This class defines the interface for all sandbox service implementations.\n    Implementations should handle creating, managing, and destroying sandboxes.\n    \"\"\"\n\n    @staticmethod\n    def generate_sandbox_id() -> str:\n        \"\"\"\n        Generate a unique sandbox identifier.\n\n        Returns:\n            str: A RFC4122-compliant UUID4 string (with hyphens)\n        \"\"\"\n        return str(uuid4())\n\n    @staticmethod\n    def _resolve_bind_ip(family: int = socket.AF_INET) -> str:\n        \"\"\"\n        Resolve the outward-facing IP for hosts binding to 0.0.0.0.\n\n        Returns:\n            str: Detected local IP address, or 127.0.0.1 as a safe fallback.\n        \"\"\"\n        try:\n            target = (\"2001:4860:4860::8888\", 80, 0, 0) if family == socket.AF_INET6 else (\"8.8.8.8\", 80)\n            with socket.socket(family, socket.SOCK_DGRAM) as sock:\n                sock.connect(target)\n                ip = sock.getsockname()[0]\n                if ip:\n                    if family == socket.AF_INET or not ip.startswith(\"fe80\"):\n                        return ip\n        except OSError:\n            if family == socket.AF_INET6:\n                return SandboxService._resolve_bind_ip(socket.AF_INET)\n\n        try:\n            family_name = socket.AF_INET6 if family == socket.AF_INET6 else socket.AF_INET\n            hostname = socket.gethostname()\n            infos = socket.getaddrinfo(hostname, None, family_name, socket.SOCK_DGRAM)\n            if infos:\n                addr = infos[0][4][0]\n                if addr:\n                    return addr\n        except OSError:\n            pass\n\n        return \"::1\" if family == socket.AF_INET6 else \"127.0.0.1\"\n\n    @staticmethod\n    def validate_port(port: int) -> None:\n        \"\"\"\n        Validate that the supplied port falls within the allowed range.\n\n        Args:\n            port: Port to validate\n\n        Raises:\n            ValueError: If port is outside 1-65535\n        \"\"\"\n        ensure_valid_port(port)\n\n    @abstractmethod\n    async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:\n        \"\"\"\n        Create a new sandbox from a container image.\n\n        Args:\n            request: Sandbox creation request\n\n        Returns:\n            CreateSandboxResponse: Created sandbox information\n\n        Raises:\n            HTTPException: If sandbox creation fails\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def list_sandboxes(self, request: ListSandboxesRequest) -> ListSandboxesResponse:\n        \"\"\"\n        List sandboxes with optional filtering and pagination.\n\n        Args:\n            request: List request with filters and pagination\n\n        Returns:\n            ListSandboxesResponse: Paginated list of sandboxes\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_sandbox(self, sandbox_id: str) -> Sandbox:\n        \"\"\"\n        Fetch a sandbox by id.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Returns:\n            Sandbox: Complete sandbox information\n\n        Raises:\n            HTTPException: If sandbox not found\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def delete_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Delete a sandbox.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or deletion fails\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def pause_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Pause a running sandbox.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or cannot be paused\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def resume_sandbox(self, sandbox_id: str) -> None:\n        \"\"\"\n        Resume a paused sandbox.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n\n        Raises:\n            HTTPException: If sandbox not found or cannot be resumed\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def renew_expiration(\n        self,\n        sandbox_id: str,\n        request: RenewSandboxExpirationRequest,\n    ) -> RenewSandboxExpirationResponse:\n        \"\"\"\n        Renew sandbox expiration time.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            request: Renewal request with new expiration time\n\n        Returns:\n            RenewSandboxExpirationResponse: Updated expiration time\n\n        Raises:\n            HTTPException: If sandbox not found or renewal fails\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n        \"\"\"\n        Get sandbox access endpoint.\n\n        Args:\n            sandbox_id: Unique sandbox identifier\n            port: Port number where the service is listening inside the sandbox\n            resolve_internal: If True, return the internal container IP (for proxy), ignoring router config.\n\n        Returns:\n            Endpoint: Public endpoint URL\n\n        Raises:\n            HTTPException: If sandbox not found or endpoint not available\n        \"\"\"\n        pass\n"
  },
  {
    "path": "server/src/services/validators.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nShared validation helpers for container-based sandbox services.\n\nThese helpers centralize request validation so all container runtimes\nenforce the same preconditions before performing runtime-specific work.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import TYPE_CHECKING, Dict, List, Optional, Sequence\n\nfrom fastapi import HTTPException, status\nimport re\n\nfrom src.services.constants import RESERVED_LABEL_PREFIX, SandboxErrorCodes\n\nif TYPE_CHECKING:\n    from src.api.schema import NetworkPolicy, OSSFS, Volume\n    from src.config import EgressConfig\n\n\ndef ensure_entrypoint(entrypoint: Sequence[str]) -> None:\n    \"\"\"\n    Ensure a sandbox entrypoint is provided.\n\n    Raises:\n        HTTPException: When entrypoint is empty.\n    \"\"\"\n    if not entrypoint:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_ENTRYPOINT,\n                \"message\": \"Entrypoint must contain at least one command.\",\n            },\n        )\n\n\nDNS_LABEL_PATTERN = r\"[a-z0-9]([-a-z0-9]*[a-z0-9])?\"\nDNS_SUBDOMAIN_RE = re.compile(rf\"^(?:{DNS_LABEL_PATTERN}\\.)*{DNS_LABEL_PATTERN}$\")\nLABEL_NAME_RE = re.compile(r\"^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$\")\nLABEL_VALUE_RE = re.compile(r\"^([A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?)?$\")\n\n\ndef _is_valid_label_key(key: str) -> bool:\n    if \"/\" in key:\n        prefix, name = key.split(\"/\", 1)\n        if not prefix or not name:\n            return False\n        # Kubernetes requires the prefix to be a DNS subdomain <= 253 chars.\n        # The name portion is validated separately below (max 63 chars).\n        # Note: the total key length (prefix + \"/\" + name) may exceed 253 chars\n        # when the prefix uses its full 253-character allowance; this is valid.\n        if len(prefix) > 253:\n            return False\n        if not DNS_SUBDOMAIN_RE.match(prefix):\n            return False\n    else:\n        name = key\n    if len(name) > 63 or not LABEL_NAME_RE.match(name):\n        return False\n    return True\n\n\ndef _is_valid_label_value(value: str) -> bool:\n    if len(value) > 63:\n        return False\n    return bool(LABEL_VALUE_RE.match(value))\n\n\ndef ensure_metadata_labels(metadata: Optional[Dict[str, str]]) -> None:\n    \"\"\"\n    Validate metadata keys/values against Kubernetes label rules.\n\n    Raises:\n        HTTPException: When a key/value is invalid.\n    \"\"\"\n    if not metadata:\n        return\n    for key, value in metadata.items():\n        if not isinstance(key, str) or not isinstance(value, str):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_METADATA_LABEL,\n                    \"message\": \"Metadata keys and values must be strings.\",\n                },\n            )\n        if key.startswith(RESERVED_LABEL_PREFIX):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_METADATA_LABEL,\n                    \"message\": (\n                        f\"Metadata key '{key}' uses the reserved prefix '{RESERVED_LABEL_PREFIX}'. \"\n                        \"Keys under this prefix are managed by the system and cannot be set via metadata.\"\n                    ),\n                },\n            )\n        if not _is_valid_label_key(key):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_METADATA_LABEL,\n                    \"message\": f\"Metadata key '{key}' is not a valid Kubernetes label key.\",\n                },\n            )\n        if not _is_valid_label_value(value):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_METADATA_LABEL,\n                    \"message\": f\"Metadata value '{value}' is not a valid Kubernetes label value.\",\n                },\n            )\n\n\ndef ensure_future_expiration(expires_at: datetime) -> datetime:\n    \"\"\"\n    Validate and normalize expiration timestamps to UTC.\n\n    Args:\n        expires_at: Requested expiration time (timezone aware or naive).\n\n    Returns:\n        datetime: Normalized UTC expiration timestamp.\n\n    Raises:\n        HTTPException: If the timestamp is not in the future.\n    \"\"\"\n    if expires_at.tzinfo is None:\n        normalized = expires_at.replace(tzinfo=timezone.utc)\n    else:\n        normalized = expires_at.astimezone(timezone.utc)\n\n    if normalized <= datetime.now(timezone.utc):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_EXPIRATION,\n                \"message\": \"New expiration time must be in the future.\",\n            },\n        )\n\n    return normalized\n\n\ndef ensure_valid_port(port: int) -> None:\n    \"\"\"\n    Validate that a port falls within the 1-65535 range.\n\n    Raises:\n        HTTPException: When the port is out of range.\n    \"\"\"\n    if port < 1 or port > 65535:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PORT,\n                \"message\": \"Port must be between 1 and 65535.\",\n            },\n        )\n\n\ndef ensure_timeout_within_limit(timeout_seconds: Optional[int], max_timeout_seconds: Optional[int]) -> None:\n    \"\"\"\n    Validate that a requested sandbox TTL does not exceed the configured limit.\n\n    Args:\n        timeout_seconds: Requested sandbox TTL in seconds, or None for manual cleanup.\n        max_timeout_seconds: Configured maximum TTL in seconds, or None to disable the limit.\n\n    Raises:\n        HTTPException: When the timeout exceeds the configured maximum.\n    \"\"\"\n    if timeout_seconds is None:\n        return\n\n    calculate_expiration_or_raise(datetime.now(timezone.utc), timeout_seconds)\n\n    if max_timeout_seconds is None:\n        return\n\n    if timeout_seconds > max_timeout_seconds:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                \"message\": (\n                    f\"Sandbox timeout {timeout_seconds}s exceeds configured maximum \"\n                    f\"of {max_timeout_seconds}s.\"\n                ),\n            },\n        )\n\n\ndef calculate_expiration_or_raise(created_at: datetime, timeout_seconds: int) -> datetime:\n    \"\"\"\n    Compute an expiration timestamp and convert datetime overflow into a 400 error.\n\n    Raises:\n        HTTPException: When the timeout value is too large to represent safely.\n    \"\"\"\n    try:\n        return created_at + timedelta(seconds=timeout_seconds)\n    except (OverflowError, ValueError) as exc:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                \"message\": (\n                    f\"Sandbox timeout {timeout_seconds}s is too large to represent safely.\"\n                ),\n            },\n        ) from exc\n\n\n# Volume name must be a valid DNS label\nVOLUME_NAME_RE = re.compile(r\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\")\n# Kubernetes resource name pattern\nK8S_RESOURCE_NAME_RE = re.compile(r\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\")\n\n\ndef ensure_valid_volume_name(name: str) -> None:\n    \"\"\"\n    Validate that a volume name is a valid DNS label.\n\n    Args:\n        name: Volume name to validate.\n\n    Raises:\n        HTTPException: When the name is invalid.\n    \"\"\"\n    if not name:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_VOLUME_NAME,\n                \"message\": \"Volume name cannot be empty.\",\n            },\n        )\n    if len(name) > 63:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_VOLUME_NAME,\n                \"message\": f\"Volume name '{name}' exceeds maximum length of 63 characters.\",\n            },\n        )\n    if not VOLUME_NAME_RE.match(name):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_VOLUME_NAME,\n                \"message\": f\"Volume name '{name}' is not a valid DNS label. Must be lowercase alphanumeric with optional hyphens.\",\n            },\n        )\n\n\ndef ensure_valid_mount_path(mount_path: str) -> None:\n    \"\"\"\n    Validate that a mount path is an absolute path.\n\n    Args:\n        mount_path: Mount path to validate.\n\n    Raises:\n        HTTPException: When the path is not absolute.\n    \"\"\"\n    if not mount_path:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_MOUNT_PATH,\n                \"message\": \"Mount path cannot be empty.\",\n            },\n        )\n    if not mount_path.startswith(\"/\"):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_MOUNT_PATH,\n                \"message\": f\"Mount path '{mount_path}' must be an absolute path starting with '/'.\",\n            },\n        )\n\n\ndef ensure_valid_sub_path(sub_path: Optional[str]) -> None:\n    \"\"\"\n    Validate that a subPath does not contain path traversal or is absolute.\n\n    Args:\n        sub_path: SubPath to validate (optional).\n\n    Raises:\n        HTTPException: When the subPath is invalid.\n    \"\"\"\n    if sub_path is None:\n        return\n\n    if not sub_path:\n        # Empty string is valid (no subpath)\n        return\n\n    # Check for absolute path\n    if sub_path.startswith(\"/\"):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_SUB_PATH,\n                \"message\": f\"SubPath '{sub_path}' must be a relative path, not absolute.\",\n            },\n        )\n\n    # Check for path traversal\n    # Normalize and check each component\n    parts = sub_path.split(\"/\")\n    for part in parts:\n        if part == \"..\":\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_SUB_PATH,\n                    \"message\": f\"SubPath '{sub_path}' contains path traversal '..' which is not allowed.\",\n                },\n            )\n\n\ndef ensure_valid_host_path(\n    path: str,\n    allowed_prefixes: Optional[List[str]] = None,\n) -> None:\n    \"\"\"\n    Validate that a host path is absolute and optionally within allowed prefixes.\n\n    Args:\n        path: Host path to validate.\n        allowed_prefixes: Optional list of allowed path prefixes.\n\n    Raises:\n        HTTPException: When the path is invalid or not allowed.\n    \"\"\"\n    if not path:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_HOST_PATH,\n                \"message\": \"Host path cannot be empty.\",\n            },\n        )\n\n    if not os.path.isabs(path):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_HOST_PATH,\n                \"message\": f\"Host path '{path}' must be an absolute path.\",\n            },\n        )\n\n    # Normalize separators to forward slashes for consistent security checks.\n    # Strip the drive prefix (e.g. \"C:\") so that \"C:/\" is not mis-detected as\n    # containing \"//\".\n    _drive, _tail = os.path.splitdrive(path)\n    _tail_fwd = _tail.replace(\"\\\\\", \"/\")\n\n    # Reject path traversal components\n    if \"/..\" in _tail_fwd or _tail_fwd == \"/..\":\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_HOST_PATH,\n                \"message\": f\"Host path '{path}' contains path traversal component '..'.\",\n            },\n        )\n\n    # Reject non-normalized paths (double slashes, trailing slashes except root)\n    if \"//\" in _tail_fwd or (len(_tail_fwd) > 1 and _tail_fwd.endswith(\"/\")):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_HOST_PATH,\n                \"message\": f\"Host path '{path}' is not normalized. Remove redundant slashes.\",\n            },\n        )\n\n    # Check against allowed prefixes if provided\n    if allowed_prefixes is not None:\n        norm_path = os.path.normpath(path)\n        is_allowed = any(\n            norm_path == os.path.normpath(prefix)\n            or norm_path.startswith(os.path.normpath(prefix) + os.sep)\n            for prefix in allowed_prefixes\n        )\n        if not is_allowed:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.HOST_PATH_NOT_ALLOWED,\n                    \"message\": f\"Host path '{path}' is not under any allowed prefix. Allowed prefixes: {allowed_prefixes}\",\n                },\n            )\n\n\ndef ensure_valid_pvc_name(claim_name: str) -> None:\n    \"\"\"\n    Validate that a PVC claim name is a valid Kubernetes resource name.\n\n    Args:\n        claim_name: PVC claim name to validate.\n\n    Raises:\n        HTTPException: When the claim name is invalid.\n    \"\"\"\n    if not claim_name:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PVC_NAME,\n                \"message\": \"PVC claim name cannot be empty.\",\n            },\n        )\n    if len(claim_name) > 253:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PVC_NAME,\n                \"message\": f\"PVC claim name '{claim_name}' exceeds maximum length of 253 characters.\",\n            },\n        )\n    if not K8S_RESOURCE_NAME_RE.match(claim_name):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PVC_NAME,\n                \"message\": f\"PVC claim name '{claim_name}' is not a valid Kubernetes resource name.\",\n            },\n        )\n\n\ndef ensure_valid_ossfs_volume(ossfs: \"OSSFS\") -> None:\n    \"\"\"\n    Validate OSSFS backend fields.\n\n    Args:\n        ossfs: OSSFS backend model.\n\n    Raises:\n        HTTPException: When any OSSFS field is invalid.\n    \"\"\"\n    if not isinstance(ossfs.bucket, str) or not ossfs.bucket.strip():\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_OSSFS_BUCKET,\n                \"message\": \"OSSFS bucket cannot be empty.\",\n            },\n        )\n\n    if not ossfs.endpoint.strip():\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_OSSFS_ENDPOINT,\n                \"message\": \"OSSFS endpoint cannot be empty.\",\n            },\n        )\n\n    if ossfs.options is not None:\n        for opt in ossfs.options:\n            if not isinstance(opt, str) or not opt.strip():\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_OSSFS_OPTION,\n                        \"message\": \"OSSFS options must be non-empty strings.\",\n                    },\n                )\n            normalized = opt.strip()\n            if normalized.startswith(\"-\"):\n                raise HTTPException(\n                    status_code=status.HTTP_400_BAD_REQUEST,\n                    detail={\n                        \"code\": SandboxErrorCodes.INVALID_OSSFS_OPTION,\n                        \"message\": (\n                            \"OSSFS options must be raw option payloads without '-' prefix \"\n                            \"(e.g. 'allow_other', 'uid=1000').\"\n                        ),\n                    },\n                )\n\n    if not ossfs.access_key_id or not ossfs.access_key_secret:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS,\n                \"message\": (\n                    \"OSSFS inline credentials are required: \"\n                    \"accessKeyId and accessKeySecret must be provided.\"\n                ),\n            },\n        )\n\n\ndef ensure_egress_configured(\n    network_policy: Optional[\"NetworkPolicy\"],\n    egress_config: Optional[\"EgressConfig\"],\n) -> None:\n    \"\"\"\n    Validate that egress.image is configured when network policy is provided.\n    \n    This is a common validation shared by Docker and Kubernetes runtimes.\n    \n    Args:\n        network_policy: Optional network policy from the request.\n        egress_config: Optional egress configuration from app config.\n    \n    Raises:\n        HTTPException: When network_policy is provided but egress.image is not configured.\n    \"\"\"\n    if not network_policy:\n        return\n    \n    egress_image = egress_config.image if egress_config else None\n    if not egress_image:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail={\n                \"code\": SandboxErrorCodes.INVALID_PARAMETER,\n                \"message\": \"egress.image must be configured when networkPolicy is provided.\",\n            },\n        )\n\n\ndef ensure_volumes_valid(\n    volumes: Optional[List[\"Volume\"]],\n    allowed_host_prefixes: Optional[List[str]] = None,\n) -> None:\n    \"\"\"\n    Validate a list of volume definitions.\n\n    This function performs comprehensive validation:\n    - Unique volume names\n    - Exactly one backend per volume\n    - Valid mount paths\n    - Valid subPaths\n    - Backend-specific validation (host path, pvc name, ossfs config)\n\n    Args:\n        volumes: List of volumes to validate (optional).\n        allowed_host_prefixes: Optional list of allowed host path prefixes.\n\n    Raises:\n        HTTPException: When any validation fails.\n    \"\"\"\n    if volumes is None or len(volumes) == 0:\n        return\n\n    # Check for duplicate volume names\n    seen_names: set[str] = set()\n    for volume in volumes:\n        if volume.name in seen_names:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.DUPLICATE_VOLUME_NAME,\n                    \"message\": f\"Duplicate volume name '{volume.name}'. Each volume must have a unique name.\",\n                },\n            )\n        seen_names.add(volume.name)\n\n        # Validate volume name\n        ensure_valid_volume_name(volume.name)\n\n        # Validate mount path\n        ensure_valid_mount_path(volume.mount_path)\n\n        # Validate subPath\n        ensure_valid_sub_path(volume.sub_path)\n\n        # Count specified backends\n        backends_specified = sum([\n            volume.host is not None,\n            volume.pvc is not None,\n            volume.ossfs is not None,\n        ])\n\n        if backends_specified == 0:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_VOLUME_BACKEND,\n                    \"message\": (\n                        f\"Volume '{volume.name}' must specify exactly one backend \"\n                        \"(host, pvc, ossfs), but none was provided.\"\n                    ),\n                },\n            )\n\n        if backends_specified > 1:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail={\n                    \"code\": SandboxErrorCodes.INVALID_VOLUME_BACKEND,\n                    \"message\": (\n                        f\"Volume '{volume.name}' must specify exactly one backend \"\n                        \"(host, pvc, ossfs), but multiple were provided.\"\n                    ),\n                },\n            )\n\n        # Backend-specific validation\n        if volume.host is not None:\n            ensure_valid_host_path(volume.host.path, allowed_host_prefixes)\n\n        if volume.pvc is not None:\n            ensure_valid_pvc_name(volume.pvc.claim_name)\n\n        if volume.ossfs is not None:\n            ensure_valid_ossfs_volume(volume.ossfs)\n\n\n__all__ = [\n    \"ensure_entrypoint\",\n    \"ensure_future_expiration\",\n    \"ensure_valid_port\",\n    \"ensure_metadata_labels\",\n    \"ensure_egress_configured\",\n    \"ensure_valid_volume_name\",\n    \"ensure_valid_mount_path\",\n    \"ensure_valid_sub_path\",\n    \"ensure_valid_host_path\",\n    \"ensure_valid_pvc_name\",\n    \"ensure_valid_ossfs_volume\",\n    \"ensure_volumes_valid\",\n]\n"
  },
  {
    "path": "server/tests/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n"
  },
  {
    "path": "server/tests/conftest.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nPytest configuration and fixtures for sandbox server tests.\n\nThis module provides shared fixtures and configuration for all test modules.\n\"\"\"\n\nimport os\nfrom pathlib import Path\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom unittest.mock import MagicMock\n\nTEST_CONFIG_PATH = Path(__file__).resolve().parent / \"testdata\" / \"config.toml\"\nos.environ.setdefault(\"SANDBOX_CONFIG_PATH\", str(TEST_CONFIG_PATH))\n\n# Prevent real Docker connections during tests by mocking docker.from_env\nimport docker  # noqa: E402\n\n_mock_docker_client = MagicMock()\n_mock_docker_client.containers.list.return_value = []\ndocker.from_env = lambda: _mock_docker_client  # type: ignore\n\nfrom src.main import app  # noqa: E402\n\n\n@pytest.fixture(scope=\"session\")\ndef test_api_key() -> str:\n    \"\"\"\n    Fixture providing a test API key (matches test configuration file).\n    \"\"\"\n    return \"test-api-key-12345\"\n\n\n@pytest.fixture(scope=\"function\")\ndef client() -> TestClient:\n    \"\"\"\n    Fixture providing a FastAPI test client.\n    \"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture(scope=\"function\")\ndef auth_headers(test_api_key: str) -> dict:\n    \"\"\"\n    Fixture providing authentication headers.\n    \"\"\"\n    return {\"OPEN-SANDBOX-API-KEY\": test_api_key}\n\n\n@pytest.fixture(scope=\"session\")\ndef sample_sandbox_request() -> dict:\n    \"\"\"\n    Fixture providing a sample sandbox creation request.\n    \"\"\"\n    return {\n        \"image\": {\"uri\": \"python:3.11\"},\n        \"timeout\": 3600,\n        \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n        \"env\": {\"DEBUG\": \"true\", \"LOG_LEVEL\": \"info\"},\n        \"metadata\": {\"name\": \"Test Sandbox\", \"project\": \"test-project\"},\n        \"entrypoint\": [\"python\", \"-c\", \"print('Hello from sandbox')\"],\n    }\n"
  },
  {
    "path": "server/tests/k8s/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nKubernetes runtime unit tests.\n\"\"\"\n"
  },
  {
    "path": "server/tests/k8s/conftest.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nPytest configuration for K8s runtime tests.\n\"\"\"\n\n# Import fixtures directly to avoid using pytest_plugins\nimport pytest\nfrom tests.k8s.fixtures.k8s_fixtures import *  # noqa: F401, F403\n\n\n@pytest.fixture(autouse=True)\ndef stub_workload_informer(monkeypatch):\n    \"\"\"\n    Prevent real informer threads in unit tests.\n    \n    Stubs the WorkloadInformer used inside K8sClient so that watch threads are\n    not started during unit tests. Cache is always empty (has_synced=False),\n    so get_custom_object falls through to the mocked API call.\n    \"\"\"\n\n    class _FakeInformer:\n        def __init__(self, *args, **kwargs):\n            self.has_synced = False\n\n        def start(self):\n            return None\n\n        def stop(self):\n            return None\n\n        def get(self, name):\n            return None\n\n        def update_cache(self, obj):\n            return None\n\n    monkeypatch.setattr(\n        \"src.services.k8s.client.WorkloadInformer\", _FakeInformer\n    )\n"
  },
  {
    "path": "server/tests/k8s/fixtures/__init__.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nFixtures package for K8s tests.\n\"\"\"\n"
  },
  {
    "path": "server/tests/k8s/fixtures/k8s_fixtures.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nShared fixtures for Kubernetes runtime tests.\n\"\"\"\nfrom datetime import datetime, timezone, timedelta\nfrom unittest.mock import MagicMock\nfrom typing import Dict, Any\n\nimport pytest\n\nfrom src.api.schema import CreateSandboxRequest, ImageSpec, ResourceLimits\nfrom src.config import KubernetesRuntimeConfig\nfrom src.services.k8s.client import K8sClient\nfrom src.services.k8s.provider_factory import PROVIDER_TYPE_BATCHSANDBOX\n\n\n@pytest.fixture\ndef mock_k8s_client():\n    \"\"\"Provide mocked K8sClient\"\"\"\n    client = MagicMock(spec=K8sClient)\n    mock_custom_api = MagicMock()\n    mock_core_api = MagicMock()\n    client.get_custom_objects_api.return_value = mock_custom_api\n    client.get_core_v1_api.return_value = mock_core_api\n    client.custom_api = mock_custom_api\n    client.core_api = mock_core_api\n    # Unified resource operation methods\n    client.create_custom_object = MagicMock(return_value={\"metadata\": {\"name\": \"test\", \"uid\": \"uid\"}})\n    client.get_custom_object = MagicMock(return_value=None)\n    client.list_custom_objects = MagicMock(return_value=[])\n    client.delete_custom_object = MagicMock()\n    client.patch_custom_object = MagicMock()\n    client.create_secret = MagicMock()\n    client.list_pods = MagicMock(return_value=[])\n    return client\n\n\n@pytest.fixture\ndef k8s_runtime_config():\n    \"\"\"Provide test Kubernetes configuration\"\"\"\n    return KubernetesRuntimeConfig(\n        kubeconfig_path=\"/tmp/test-kubeconfig\",\n        namespace=\"test-namespace\",\n        service_account=\"test-sa\",\n        workload_provider=PROVIDER_TYPE_BATCHSANDBOX,\n    )\n\n\n@pytest.fixture\ndef agent_sandbox_runtime_config():\n    \"\"\"Provide agent-sandbox runtime configuration\"\"\"\n    return KubernetesRuntimeConfig(\n        kubeconfig_path=\"/tmp/test-kubeconfig\",\n        namespace=\"test-namespace\",\n        service_account=\"test-sa\",\n        workload_provider=\"agent-sandbox\",\n    )\n\n\n@pytest.fixture\ndef k8s_runtime_config_with_template(tmp_path):\n    \"\"\"Provide Kubernetes configuration with template file\"\"\"\n    template_file = tmp_path / \"template.yaml\"\n    template_file.write_text(\"\"\"\nmetadata:\n  annotations:\n    managed-by: opensandbox\nspec:\n  template:\n    spec:\n      nodeSelector:\n        workload: sandbox\n      tolerations:\n        - operator: Exists\n\"\"\")\n    return KubernetesRuntimeConfig(\n        kubeconfig_path=\"/tmp/test-kubeconfig\",\n        namespace=\"test-namespace\",\n        service_account=\"test-sa\",\n        workload_provider=PROVIDER_TYPE_BATCHSANDBOX,\n        batchsandbox_template_file=str(template_file),\n    )\n\n\n@pytest.fixture\ndef valid_batchsandbox_template() -> Dict[str, Any]:\n    \"\"\"Provide valid BatchSandbox template\"\"\"\n    return {\n        \"metadata\": {\n            \"annotations\": {\n                \"managed-by\": \"opensandbox\",\n                \"template-source\": \"test-template\"\n            }\n        },\n        \"spec\": {\n            \"template\": {\n                \"spec\": {\n                    \"restartPolicy\": \"Never\",\n                    \"nodeSelector\": {\n                        \"workload\": \"sandbox\",\n                        \"environment\": \"test\"\n                    },\n                    \"tolerations\": [\n                        {\n                            \"key\": \"sandbox\",\n                            \"operator\": \"Equal\",\n                            \"value\": \"true\",\n                            \"effect\": \"NoSchedule\"\n                        }\n                    ],\n                    \"priorityClassName\": \"sandbox-default\"\n                }\n            }\n        }\n    }\n\n\n@pytest.fixture\ndef sample_create_request():\n    \"\"\"Provide sample create request\"\"\"\n    return CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        entrypoint=[\"/bin/bash\", \"-c\", \"sleep 3600\"],\n        timeout=3600,\n        resourceLimits=ResourceLimits(root={\"cpu\": \"1\", \"memory\": \"1Gi\"}),\n        env={\"ENV\": \"test\", \"DEBUG\": \"true\"},\n        metadata={\"team\": \"platform\", \"project\": \"test\"}\n    )\n\n\n@pytest.fixture\ndef mock_batchsandbox_response():\n    \"\"\"Provide mocked BatchSandbox response\"\"\"\n    return {\n        \"apiVersion\": \"sandbox.opensandbox.io/v1alpha1\",\n        \"kind\": \"BatchSandbox\",\n        \"metadata\": {\n            \"name\": \"test-id\",\n            \"namespace\": \"test-namespace\",\n            \"creationTimestamp\": \"2025-12-24T10:00:00Z\",\n            \"uid\": \"test-uid-12345\",\n            \"annotations\": {\n                \"sandbox.opensandbox.io/endpoints\": '[\"10.0.0.1\"]'\n            },\n            \"labels\": {\n                \"opensandbox.io/id\": \"test-id\"\n            }\n        },\n        \"spec\": {\n            \"replicas\": 1,\n            \"expireTime\": \"2025-12-24T11:00:00+00:00\",\n            \"template\": {\n                \"spec\": {\n                    \"containers\": [\n                        {\n                            \"name\": \"sandbox\",\n                            \"image\": \"python:3.11\"\n                        }\n                    ]\n                }\n            }\n        },\n        \"status\": {\n            \"replicas\": 1,\n            \"allocated\": 1,\n            \"ready\": 1,\n            \"taskFailed\": 0,\n            \"taskPending\": 0,\n            \"taskRunning\": 0,\n            \"taskSucceed\": 0,\n            \"taskUnknown\": 0\n        }\n    }\n\n\n@pytest.fixture\ndef mock_batchsandbox_list_response(mock_batchsandbox_response):\n    \"\"\"Provide mocked BatchSandbox list response\"\"\"\n    return {\n        \"apiVersion\": \"sandbox.opensandbox.io/v1alpha1\",\n        \"kind\": \"BatchSandboxList\",\n        \"items\": [mock_batchsandbox_response]\n    }\n\n\n@pytest.fixture\ndef fixed_datetime():\n    \"\"\"Provide fixed datetime for testing\"\"\"\n    return datetime(2025, 12, 24, 10, 0, 0, tzinfo=timezone.utc)\n\n\n@pytest.fixture\ndef k8s_app_config(k8s_runtime_config):\n    \"\"\"Provide complete app configuration (Kubernetes type)\"\"\"\n    from src.config import AppConfig, RuntimeConfig, ServerConfig\n    \n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"kubernetes\",\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=k8s_runtime_config,\n    )\n\n\n@pytest.fixture\ndef agent_sandbox_app_config(agent_sandbox_runtime_config):\n    \"\"\"Provide complete app configuration (kubernetes + agent-sandbox provider)\"\"\"\n    from src.config import AppConfig, RuntimeConfig, ServerConfig, AgentSandboxRuntimeConfig\n\n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"kubernetes\",\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=agent_sandbox_runtime_config,\n        agent_sandbox=AgentSandboxRuntimeConfig(\n            template_file=None,\n            shutdown_policy=\"Delete\",\n            ingress_enabled=True,\n        ),\n    )\n\n\n@pytest.fixture\ndef app_config_no_k8s():\n    \"\"\"Provide app configuration without Kubernetes config\"\"\"\n    from src.config import AppConfig, RuntimeConfig, ServerConfig\n    \n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"kubernetes\",\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=None,  # No Kubernetes config\n    )\n\n\n@pytest.fixture\ndef app_config_docker():\n    \"\"\"Provide Docker type app configuration\"\"\"\n    from src.config import AppConfig, RuntimeConfig, ServerConfig\n    \n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"docker\",  # Docker type\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=None,\n    )\n\n\n@pytest.fixture\ndef k8s_service(k8s_app_config):\n    \"\"\"Provide mocked KubernetesSandboxService\"\"\"\n    from unittest.mock import patch, MagicMock\n    \n    with patch('src.services.k8s.kubernetes_service.K8sClient') as mock_k8s_client_cls, \\\n         patch('src.services.k8s.kubernetes_service.create_workload_provider') as mock_create_provider:\n        \n        # Mock K8sClient instance\n        mock_k8s_client = MagicMock()\n        mock_k8s_client_cls.return_value = mock_k8s_client\n        \n        # Mock WorkloadProvider instance\n        mock_provider = MagicMock()\n        mock_create_provider.return_value = mock_provider\n        \n        from src.services.k8s.kubernetes_service import KubernetesSandboxService\n        service = KubernetesSandboxService(k8s_app_config)\n        \n        # Save mock objects for access in tests\n        service.k8s_client = mock_k8s_client\n        service.workload_provider = mock_provider\n        \n        yield service\n\n\n@pytest.fixture\ndef create_sandbox_request():\n    \"\"\"Provide standard sandbox creation request\"\"\"\n    from src.api.schema import ResourceLimits\n    \n    return CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.9\"),\n        entrypoint=[\"/bin/bash\", \"-c\", \"sleep infinity\"],\n        timeout=3600,\n        env={\"ENV\": \"test\"},\n        metadata={\"team\": \"test\"},\n        resourceLimits=ResourceLimits(root={\"cpu\": \"1\", \"memory\": \"1Gi\"}),\n    )\n\n\n@pytest.fixture\ndef mock_workload():\n    \"\"\"Provide mocked workload object\"\"\"\n    return {\n        \"metadata\": {\n            \"name\": \"test-sandbox-123\",\n            \"uid\": \"abc-123\",\n            \"labels\": {\n                \"opensandbox.io/id\": \"test-sandbox-123\",\n            },\n            \"annotations\": {\n                \"opensandbox.io/created-at\": datetime.now(timezone.utc).isoformat(),\n                \"opensandbox.io/expires-at\": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),\n                \"opensandbox.io/image\": '{\"uri\": \"python:3.9\"}',\n                \"opensandbox.io/entrypoint\": '[\"/bin/bash\", \"-c\", \"sleep infinity\"]',\n            },\n            \"creationTimestamp\": datetime.now(timezone.utc).isoformat(),\n        },\n        \"spec\": {},\n        \"status\": {\n            \"state\": \"Running\",\n        },\n    }\n\n\n@pytest.fixture\ndef isolated_registry():\n    \"\"\"\n    Fixture to isolate provider registry for each test.\n\n    Saves the original registry before test and restores it after,\n    preventing global state pollution.\n    \"\"\"\n    from src.services.k8s import provider_factory\n\n    # Save original registry\n    original_registry = provider_factory._PROVIDER_REGISTRY.copy()\n\n    yield\n\n    # Restore original registry\n    provider_factory._PROVIDER_REGISTRY = original_registry\n"
  },
  {
    "path": "server/tests/k8s/test_agent_sandbox_provider.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for AgentSandboxProvider.\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock\n\nimport pytest\nfrom kubernetes.client import ApiException\n\nfrom src.api.schema import ImageSpec, NetworkPolicy, NetworkRule\nfrom src.config import (\n    AppConfig,\n    AgentSandboxRuntimeConfig,\n    EGRESS_MODE_DNS,\n    EGRESS_MODE_DNS_NFT,\n    ExecdInitResources,\n    KubernetesRuntimeConfig,\n    RuntimeConfig,\n)\nfrom src.services.constants import SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY\nfrom src.services.k8s.agent_sandbox_provider import AgentSandboxProvider\nfrom src.services.constants import OPENSANDBOX_EGRESS_TOKEN\n\n\ndef _app_config(shutdown_policy: str = \"Delete\", service_account: str | None = None, execd_init_resources: ExecdInitResources | None = None) -> AppConfig:\n    \"\"\"Build an AppConfig for AgentSandboxProvider tests.\"\"\"\n    return AppConfig(\n        runtime=RuntimeConfig(type=\"kubernetes\", execd_image=\"execd:test\"),\n        kubernetes=KubernetesRuntimeConfig(\n            namespace=\"test-ns\",\n            service_account=service_account,\n            workload_provider=\"agent-sandbox\",\n            execd_init_resources=execd_init_resources,\n        ),\n        agent_sandbox=AgentSandboxRuntimeConfig(shutdown_policy=shutdown_policy),\n    )\n\n\nclass TestAgentSandboxProvider:\n    \"\"\"AgentSandboxProvider unit tests\"\"\"\n\n    def test_init_sets_crd_constants_correctly(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify CRD constants set correctly\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n\n        assert provider.group == \"agents.x-k8s.io\"\n        assert provider.version == \"v1alpha1\"\n        assert provider.plural == \"sandboxes\"\n\n    def test_create_workload_builds_correct_manifest_init_mode(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify created manifest structure with init mode\n        \"\"\"\n        provider = AgentSandboxProvider(\n            mock_k8s_client,\n            _app_config(shutdown_policy=\"Delete\", service_account=\"agent-sa\"),\n        )\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={\"opensandbox.io/id\": \"test-id\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n        )\n\n        assert result == {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"apiVersion\"] == \"agents.x-k8s.io/v1alpha1\"\n        assert body[\"kind\"] == \"Sandbox\"\n        assert body[\"metadata\"][\"name\"] == \"test-id\"\n        assert body[\"metadata\"][\"namespace\"] == \"test-ns\"\n        assert body[\"spec\"][\"replicas\"] == 1\n        assert body[\"spec\"][\"shutdownTime\"] == \"2025-12-31T10:00:00+00:00\"\n        assert body[\"spec\"][\"shutdownPolicy\"] == \"Delete\"\n        assert body[\"spec\"][\"podTemplate\"][\"spec\"][\"serviceAccountName\"] == \"agent-sa\"\n        assert \"initContainers\" in body[\"spec\"][\"podTemplate\"][\"spec\"]\n        assert \"containers\" in body[\"spec\"][\"podTemplate\"][\"spec\"]\n        assert \"volumes\" in body[\"spec\"][\"podTemplate\"][\"spec\"]\n\n    def test_create_workload_sanitizes_resource_name(self, mock_k8s_client):\n        \"\"\"\n        Test case: Ensure sandbox names are DNS-1035 compliant when IDs start with digits\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-1234\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        result = provider.create_workload(\n            sandbox_id=\"1234\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={\"opensandbox.io/id\": \"1234\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n        )\n\n        assert result == {\"name\": \"sandbox-1234\", \"uid\": \"test-uid\"}\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"metadata\"][\"name\"] == \"sandbox-1234\"\n\n    def test_resource_name_uses_hash_when_id_has_no_alnum(self, mock_k8s_client):\n        \"\"\"\n        Test case: Ensure symbol-only sandbox ids do not collapse to the same name\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n\n        first = provider._resource_name(\"!!!\")\n        second = provider._resource_name(\"???\")\n\n        assert first.startswith(\"sandbox-\")\n        assert second.startswith(\"sandbox-\")\n        assert first != second\n\n    def test_get_workload_returns_none_on_404(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify None returned when not found\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = None\n\n        result = provider.get_workload(\"test-id\", \"test-ns\")\n\n        assert result is None\n\n    def test_get_workload_prefers_sanitized_name(self, mock_k8s_client):\n        \"\"\"\n        Test case: Ensure DNS-1035 resource name is tried before raw id\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.side_effect = [\n            None,\n            {\"metadata\": {\"name\": \"1234\"}},\n        ]\n\n        result = provider.get_workload(\"1234\", \"test-ns\")\n\n        assert result[\"metadata\"][\"name\"] == \"1234\"\n        assert mock_k8s_client.get_custom_object.call_args_list[0].kwargs[\"name\"] == \"sandbox-1234\"\n        assert mock_k8s_client.get_custom_object.call_args_list[1].kwargs[\"name\"] == \"1234\"\n\n    def test_get_workload_falls_back_to_legacy_name(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify legacy sandbox-<id> name is used when primary lookup returns None\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.side_effect = [\n            None,\n            {\"metadata\": {\"name\": \"sandbox-test-id\"}},\n        ]\n\n        result = provider.get_workload(\"test-id\", \"test-ns\")\n\n        assert result[\"metadata\"][\"name\"] == \"sandbox-test-id\"\n        assert mock_k8s_client.get_custom_object.call_args_list[0].kwargs[\"name\"] == \"test-id\"\n        assert mock_k8s_client.get_custom_object.call_args_list[1].kwargs[\"name\"] == \"sandbox-test-id\"\n\n    def test_get_workload_reraises_non_404_exceptions(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify non-404 exceptions are re-raised\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.side_effect = ApiException(status=500)\n\n        with pytest.raises(ApiException) as exc_info:\n            provider.get_workload(\"test-id\", \"test-ns\")\n\n        assert exc_info.value.status == 500\n\n    def test_get_workload_prefers_informer_cache(self, mock_k8s_client):\n        \"\"\"\n        Test case: get_workload calls k8s_client.get_custom_object and returns result\n        \"\"\"\n        cached = {\"metadata\": {\"name\": \"test-id\"}}\n        mock_k8s_client.get_custom_object.return_value = cached\n\n        provider = AgentSandboxProvider(mock_k8s_client)\n\n        result = provider.get_workload(\"test-id\", \"test-ns\")\n\n        assert result == cached\n        mock_k8s_client.get_custom_object.assert_called()\n\n    def test_create_workload_updates_informer_cache(self, mock_k8s_client):\n        \"\"\"\n        Test case: create_workload returns name and uid from created resource\n        \"\"\"\n        created_body = {\"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}}\n        mock_k8s_client.create_custom_object.return_value = created_body\n\n        provider = AgentSandboxProvider(mock_k8s_client)\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={\"opensandbox.io/id\": \"test-id\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n        )\n\n        assert result == {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n\n    def test_update_expiration_patches_spec(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify expiration time update\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = {\"metadata\": {\"name\": \"sandbox-test-id\"}}\n\n        expires_at = datetime(2025, 12, 31, 0, 0, 0, tzinfo=timezone.utc)\n        provider.update_expiration(\"test-id\", \"test-ns\", expires_at)\n\n        call_kwargs = mock_k8s_client.patch_custom_object.call_args.kwargs\n        assert call_kwargs[\"body\"] == {\n            \"spec\": {\"shutdownTime\": \"2025-12-31T00:00:00+00:00\"}\n        }\n\n    def test_get_expiration_parses_z_suffix(self):\n        \"\"\"\n        Test case: Verify handling time with Z suffix\n        \"\"\"\n        provider = AgentSandboxProvider(MagicMock())\n        workload = {\"spec\": {\"shutdownTime\": \"2025-12-31T10:00:00Z\"}}\n\n        result = provider.get_expiration(workload)\n\n        assert result == datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n    def test_get_status_ready_condition_true(self):\n        \"\"\"\n        Test case: Verify Ready True is Running\n        \"\"\"\n        provider = AgentSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\n                \"conditions\": [\n                    {\n                        \"type\": \"Ready\",\n                        \"status\": \"True\",\n                        \"reason\": \"SandboxReady\",\n                        \"message\": \"Ready\",\n                        \"lastTransitionTime\": \"2025-12-31T10:00:00Z\",\n                    }\n                ]\n            },\n            \"metadata\": {\"creationTimestamp\": \"2025-12-31T09:00:00Z\"},\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Running\"\n        assert result[\"reason\"] == \"SandboxReady\"\n        assert result[\"message\"] == \"Ready\"\n\n    def test_get_status_expired_condition(self):\n        \"\"\"\n        Test case: Verify SandboxExpired reason maps to Terminated\n        \"\"\"\n        provider = AgentSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\n                \"conditions\": [\n                    {\n                        \"type\": \"Ready\",\n                        \"status\": \"False\",\n                        \"reason\": \"SandboxExpired\",\n                        \"message\": \"Expired\",\n                        \"lastTransitionTime\": \"2025-12-31T10:00:00Z\",\n                    }\n                ]\n            },\n            \"metadata\": {\"creationTimestamp\": \"2025-12-31T09:00:00Z\"},\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Terminated\"\n        assert result[\"reason\"] == \"SandboxExpired\"\n\n    def test_get_status_falls_back_to_pod_state(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify status fallback uses pod selector state (Running + IP = Running)\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_pods.return_value = [\n            SimpleNamespace(\n                status=SimpleNamespace(phase=\"Running\", pod_ip=\"10.0.0.2\")\n            )\n        ]\n        workload = {\n            \"status\": {\"conditions\": [], \"selector\": \"app=sandbox\"},\n            \"metadata\": {\"creationTimestamp\": \"2025-12-31T09:00:00Z\", \"namespace\": \"test-ns\"},\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Running\"\n        assert result[\"reason\"] == \"POD_READY\"\n\n    def test_get_status_falls_back_to_allocated_when_ip_assigned_not_running(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify Allocated state when Pod has IP but is not Running yet\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_pods.return_value = [\n            SimpleNamespace(\n                status=SimpleNamespace(phase=\"Pending\", pod_ip=\"10.0.0.2\")\n            )\n        ]\n        workload = {\n            \"status\": {\"conditions\": [], \"selector\": \"app=sandbox\"},\n            \"metadata\": {\"creationTimestamp\": \"2025-12-31T09:00:00Z\", \"namespace\": \"test-ns\"},\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Allocated\"\n        assert result[\"reason\"] == \"IP_ASSIGNED\"\n\n    def test_get_endpoint_info_prefers_running_pod(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify endpoint uses running pod IP\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_pods.return_value = [\n            SimpleNamespace(\n                status=SimpleNamespace(phase=\"Running\", pod_ip=\"10.0.0.9\")\n            )\n        ]\n        workload = {\n            \"status\": {\"selector\": \"app=sandbox\"},\n            \"metadata\": {\"namespace\": \"test-ns\"},\n        }\n\n        endpoint = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n\n        assert endpoint.endpoint == \"10.0.0.9:8080\"\n        assert endpoint.headers is None\n\n    def test_get_endpoint_info_falls_back_to_service_fqdn(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify endpoint falls back to serviceFQDN on pod lookup failure\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_pods.side_effect = Exception(\"boom\")\n        workload = {\n            \"status\": {\"selector\": \"app=sandbox\", \"serviceFQDN\": \"svc.example.com\"},\n            \"metadata\": {\"namespace\": \"test-ns\"},\n        }\n\n        endpoint = provider.get_endpoint_info(workload, 9000, \"sandbox-123\")\n\n        assert endpoint.endpoint == \"svc.example.com:9000\"\n        assert endpoint.headers is None\n\n\nclass TestAgentSandboxProviderExecdInit:\n    \"\"\"AgentSandboxProvider execd init container resource tests\"\"\"\n\n    def test_init_container_has_no_resources_when_not_configured(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify init container has no resources when execd_init_resources is not set\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        init_containers = body[\"spec\"][\"podTemplate\"][\"spec\"][\"initContainers\"]\n        assert len(init_containers) == 1\n        assert \"resources\" not in init_containers[0]\n\n    def test_init_container_has_resources_when_configured(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify init container applies resources when execd_init_resources is set\n        \"\"\"\n        provider = AgentSandboxProvider(\n            mock_k8s_client,\n            _app_config(execd_init_resources=ExecdInitResources(\n                limits={\"cpu\": \"100m\", \"memory\": \"128Mi\"},\n                requests={\"cpu\": \"50m\", \"memory\": \"64Mi\"},\n            )),\n        )\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        init_containers = body[\"spec\"][\"podTemplate\"][\"spec\"][\"initContainers\"]\n        assert init_containers[0][\"resources\"][\"limits\"] == {\"cpu\": \"100m\", \"memory\": \"128Mi\"}\n        assert init_containers[0][\"resources\"][\"requests\"] == {\"cpu\": \"50m\", \"memory\": \"64Mi\"}\n\n\nclass TestAgentSandboxProviderEgress:\n    \"\"\"AgentSandboxProvider egress sidecar tests\"\"\"\n\n    def test_create_workload_without_network_policy_no_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify no sidecar is added when network_policy is None\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=None,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should only have main container\n        assert len(containers) == 1\n        assert containers[0][\"name\"] == \"sandbox\"\n        # Should not have securityContext with sysctls\n        assert \"securityContext\" not in pod_spec or \"sysctls\" not in pod_spec.get(\"securityContext\", {})\n\n    def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify egress sidecar is added when network_policy is provided\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should have both main container and sidecar\n        assert len(containers) == 2\n        \n        # Find sidecar container\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        assert sidecar[\"image\"] == \"opensandbox/egress:v1.0.3\"\n        \n        # Verify sidecar has environment variable\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert \"OPENSANDBOX_EGRESS_RULES\" in env_vars\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS\n\n        caps = sidecar.get(\"securityContext\", {}).get(\"capabilities\", {})\n        assert \"NET_ADMIN\" in caps.get(\"add\", [])\n        assert sidecar.get(\"securityContext\", {}).get(\"privileged\") is not True\n        assert \"command\" not in sidecar\n\n        inits = pod_spec.get(\"initContainers\", [])\n        assert len(inits) == 1\n        execd_init = inits[0]\n        assert execd_init[\"name\"] == \"execd-installer\"\n        assert execd_init[\"image\"] == \"execd:latest\"\n        assert execd_init.get(\"securityContext\", {}).get(\"privileged\") is True\n        assert \"/proc/sys/net/ipv6/conf/all/disable_ipv6\" in execd_init[\"args\"][0]\n\n    def test_create_workload_with_network_policy_persists_annotation_and_sidecar_token(self, mock_k8s_client):\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=None,\n            execd_image=\"execd:latest\",\n            network_policy=NetworkPolicy(default_action=\"deny\", egress=[]),\n            egress_image=\"opensandbox/egress:v1.0.3\",\n            annotations={SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\"},\n            egress_auth_token=\"egress-token\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"metadata\"][\"annotations\"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == \"egress-token\"\n\n        containers = body[\"spec\"][\"podTemplate\"][\"spec\"][\"containers\"]\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert env_vars[OPENSANDBOX_EGRESS_TOKEN] == \"egress-token\"\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS\n\n    def test_create_workload_with_egress_mode_dns_nft(self, mock_k8s_client):\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=None,\n            execd_image=\"execd:latest\",\n            network_policy=NetworkPolicy(default_action=\"deny\", egress=[]),\n            egress_image=\"opensandbox/egress:v1.0.3\",\n            egress_mode=EGRESS_MODE_DNS_NFT,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        containers = body[\"spec\"][\"podTemplate\"][\"spec\"][\"containers\"]\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS_NFT\n\n    def test_create_workload_with_network_policy_does_not_add_pod_ipv6_sysctls(self, mock_k8s_client):\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n\n        assert \"securityContext\" not in pod_spec or \"sysctls\" not in pod_spec.get(\"securityContext\", {})\n\n        sidecar = next(c for c in pod_spec[\"containers\"] if c[\"name\"] == \"egress\")\n        assert \"command\" not in sidecar\n        execd_init = pod_spec[\"initContainers\"][0]\n        assert execd_init[\"name\"] == \"execd-installer\"\n        assert \"/proc/sys/net/ipv6/conf/all/disable_ipv6\" in execd_init[\"args\"][0]\n\n    def test_create_workload_with_network_policy_drops_net_admin_from_main_container(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify main container drops NET_ADMIN when network_policy is enabled\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Find main container\n        main_container = next((c for c in containers if c[\"name\"] == \"sandbox\"), None)\n        assert main_container is not None\n        \n        # Verify main container has securityContext\n        assert \"securityContext\" in main_container\n        assert \"capabilities\" in main_container[\"securityContext\"]\n        assert \"drop\" in main_container[\"securityContext\"][\"capabilities\"]\n        assert \"NET_ADMIN\" in main_container[\"securityContext\"][\"capabilities\"][\"drop\"]\n\n    def test_create_workload_without_egress_image_no_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify no sidecar is added when egress_image is None even if network_policy exists\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should only have main container\n        assert len(containers) == 1\n        assert containers[0][\"name\"] == \"sandbox\"\n\n    def test_egress_sidecar_contains_network_policy_in_env(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify sidecar environment variable contains serialized network policy\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[\n                NetworkRule(action=\"allow\", target=\"pypi.org\"),\n                NetworkRule(action=\"deny\", target=\"*.malicious.com\"),\n            ],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        \n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert \"OPENSANDBOX_EGRESS_RULES\" in env_vars\n        \n        # Verify the environment variable contains valid JSON with network policy\n        import json\n        policy_json = json.loads(env_vars[\"OPENSANDBOX_EGRESS_RULES\"])\n        assert policy_json[\"defaultAction\"] == \"deny\"\n        assert len(policy_json[\"egress\"]) == 2\n        assert policy_json[\"egress\"][0][\"action\"] == \"allow\"\n        assert policy_json[\"egress\"][0][\"target\"] == \"pypi.org\"\n\n    def test_main_container_no_security_context_without_network_policy(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify main container has no securityContext when network_policy is None\n        \"\"\"\n        provider = AgentSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=None,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"podTemplate\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        main_container = containers[0]\n        # Main container should not have securityContext when no network policy\n        assert \"securityContext\" not in main_container\n"
  },
  {
    "path": "server/tests/k8s/test_agent_sandbox_template.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for AgentSandboxTemplateManager.\n\"\"\"\n\nimport pytest\nimport yaml\n\nfrom src.services.k8s.agent_sandbox_template import AgentSandboxTemplateManager\n\n\nclass TestAgentSandboxTemplateManager:\n    \"\"\"AgentSandboxTemplateManager unit tests\"\"\"\n\n    def test_load_valid_yaml_template_successfully(self, tmp_path):\n        \"\"\"\n        Test case: Verify loading valid YAML template\n        \"\"\"\n        template_file = tmp_path / \"valid_template.yaml\"\n        template_content = {\n            \"metadata\": {\"annotations\": {\"test\": \"value\"}},\n            \"spec\": {\"podTemplate\": {\"spec\": {\"nodeSelector\": {\"env\": \"test\"}}}},\n        }\n        template_file.write_text(yaml.dump(template_content))\n\n        manager = AgentSandboxTemplateManager(str(template_file))\n\n        assert manager._template == template_content\n        assert manager.template_file_path == str(template_file)\n\n    def test_load_nonexistent_file_raises_error(self):\n        \"\"\"\n        Test case: Verify FileNotFoundError raised when file doesn't exist\n        \"\"\"\n        with pytest.raises(FileNotFoundError) as exc_info:\n            AgentSandboxTemplateManager(\"/path/to/nonexistent.yaml\")\n\n        assert \"not found\" in str(exc_info.value)\n\n    def test_load_invalid_yaml_raises_error(self, tmp_path):\n        \"\"\"\n        Test case: Verify RuntimeError raised with invalid YAML file\n        \"\"\"\n        template_file = tmp_path / \"invalid.yaml\"\n        template_file.write_text(\"invalid: yaml: [missing: bracket\")\n\n        with pytest.raises(RuntimeError) as exc_info:\n            AgentSandboxTemplateManager(str(template_file))\n\n        assert \"Failed to load\" in str(exc_info.value)\n\n    def test_load_non_dict_yaml_raises_error(self, tmp_path):\n        \"\"\"\n        Test case: Verify ValueError raised when YAML content is not a dict\n        \"\"\"\n        template_file = tmp_path / \"list.yaml\"\n        template_file.write_text(\"- item1\\n- item2\")\n\n        with pytest.raises(ValueError) as exc_info:\n            AgentSandboxTemplateManager(str(template_file))\n\n        assert \"must be a YAML object\" in str(exc_info.value)\n        assert \"got list\" in str(exc_info.value)\n\n    def test_init_without_template_file_creates_empty_manager(self):\n        \"\"\"\n        Test case: Verify empty manager created without template file\n        \"\"\"\n        manager = AgentSandboxTemplateManager(None)\n\n        assert manager._template is None\n        assert manager.template_file_path is None\n\n    def test_deep_merge_runtime_overrides_template(self):\n        \"\"\"\n        Test case: Verify runtime values override template values\n        \"\"\"\n        base = {\"spec\": {\"replicas\": 1, \"shutdownTime\": \"old\"}}\n        override = {\"spec\": {\"shutdownTime\": \"new\"}}\n\n        result = AgentSandboxTemplateManager._deep_merge(base, override)\n\n        assert result == {\"spec\": {\"replicas\": 1, \"shutdownTime\": \"new\"}}\n\n    def test_deep_merge_preserves_template_only_fields(self):\n        \"\"\"\n        Test case: Verify template-only fields are preserved\n        \"\"\"\n        base = {\n            \"spec\": {\n                \"podTemplate\": {\n                    \"spec\": {\n                        \"nodeSelector\": {\"env\": \"prod\"},\n                        \"tolerations\": [{\"key\": \"test\"}],\n                    }\n                }\n            }\n        }\n        override = {\"spec\": {\"replicas\": 1}}\n\n        result = AgentSandboxTemplateManager._deep_merge(base, override)\n\n        assert result[\"spec\"][\"replicas\"] == 1\n        assert result[\"spec\"][\"podTemplate\"][\"spec\"][\"nodeSelector\"] == {\"env\": \"prod\"}\n        assert result[\"spec\"][\"podTemplate\"][\"spec\"][\"tolerations\"] == [{\"key\": \"test\"}]\n\n    def test_deep_merge_nested_dicts_recursively(self):\n        \"\"\"\n        Test case: Verify nested dicts are merged recursively\n        \"\"\"\n        base = {\"metadata\": {\"annotations\": {\"a\": \"1\", \"b\": \"2\"}}}\n        override = {\"metadata\": {\"annotations\": {\"b\": \"3\", \"c\": \"4\"}}}\n\n        result = AgentSandboxTemplateManager._deep_merge(base, override)\n\n        expected = {\"metadata\": {\"annotations\": {\"a\": \"1\", \"b\": \"3\", \"c\": \"4\"}}}\n        assert result == expected\n\n    def test_deep_merge_replaces_lists_not_merges(self):\n        \"\"\"\n        Test case: Verify lists are replaced not merged\n        \"\"\"\n        base = {\"spec\": {\"tolerations\": [{\"key\": \"a\"}]}}\n        override = {\"spec\": {\"tolerations\": [{\"key\": \"b\"}]}}\n\n        result = AgentSandboxTemplateManager._deep_merge(base, override)\n\n        assert result == {\"spec\": {\"tolerations\": [{\"key\": \"b\"}]}}\n\n    def test_deep_merge_none_values_do_not_override(self):\n        \"\"\"\n        Test case: Verify None values don't override existing values\n        \"\"\"\n        base = {\"spec\": {\"shutdownTime\": \"2024-12-31\"}}\n        override = {\"spec\": {\"shutdownTime\": None}}\n\n        result = AgentSandboxTemplateManager._deep_merge(base, override)\n\n        assert result == {\"spec\": {\"shutdownTime\": \"2024-12-31\"}}\n\n    def test_deep_copy_creates_independent_copies(self):\n        \"\"\"\n        Test case: Verify deep copy creates independent copies\n        \"\"\"\n        original = {\n            \"nested\": {\"list\": [1, 2, 3], \"dict\": {\"key\": \"value\"}},\n        }\n\n        copy = AgentSandboxTemplateManager._deep_copy(original)\n\n        copy[\"nested\"][\"list\"].append(4)\n        copy[\"nested\"][\"dict\"][\"key\"] = \"new_value\"\n\n        assert original[\"nested\"][\"list\"] == [1, 2, 3]\n        assert original[\"nested\"][\"dict\"][\"key\"] == \"value\"\n\n    def test_get_base_template_returns_copy(self, tmp_path):\n        \"\"\"\n        Test case: Verify get_base_template returns a copy\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_content = {\"spec\": {\"replicas\": 1}}\n        template_file.write_text(yaml.dump(template_content))\n\n        manager = AgentSandboxTemplateManager(str(template_file))\n\n        template1 = manager.get_base_template()\n        template2 = manager.get_base_template()\n\n        assert template1 == template2\n        assert template1 is not template2\n\n    def test_get_base_template_returns_empty_dict_when_no_template(self):\n        \"\"\"\n        Test case: Verify empty dict returned when no template\n        \"\"\"\n        manager = AgentSandboxTemplateManager(None)\n\n        assert manager.get_base_template() == {}\n"
  },
  {
    "path": "server/tests/k8s/test_batchsandbox_provider.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for BatchSandboxProvider.\n\"\"\"\n\nimport pytest\nfrom datetime import datetime, timezone\nfrom unittest.mock import MagicMock\nfrom kubernetes.client import ApiException\n\nfrom src.api.schema import ImageSpec, ImageAuth, NetworkPolicy, NetworkRule\nfrom src.config import (\n    AppConfig,\n    EGRESS_MODE_DNS,\n    EGRESS_MODE_DNS_NFT,\n    ExecdInitResources,\n    KubernetesRuntimeConfig,\n    RuntimeConfig,\n)\nfrom src.services.constants import SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY\nfrom src.services.k8s.batchsandbox_provider import BatchSandboxProvider\nfrom src.services.constants import OPENSANDBOX_EGRESS_TOKEN\nfrom src.services.k8s.image_pull_secret_helper import IMAGE_AUTH_SECRET_PREFIX\nfrom src.services.k8s.volume_helper import apply_volumes_to_pod_spec\n\n\ndef _app_config_with_template(template_file_path: str) -> AppConfig:\n    \"\"\"Build an AppConfig with a batchsandbox_template_file set.\"\"\"\n    return AppConfig(\n        runtime=RuntimeConfig(type=\"kubernetes\", execd_image=\"execd:test\"),\n        kubernetes=KubernetesRuntimeConfig(\n            namespace=\"test-ns\",\n            batchsandbox_template_file=template_file_path,\n        ),\n    )\n\n\ndef _app_config_with_execd_resources(execd_init_resources: ExecdInitResources) -> AppConfig:\n    \"\"\"Build an AppConfig with execd_init_resources set.\"\"\"\n    return AppConfig(\n        runtime=RuntimeConfig(type=\"kubernetes\", execd_image=\"execd:test\"),\n        kubernetes=KubernetesRuntimeConfig(\n            namespace=\"test-ns\",\n            execd_init_resources=execd_init_resources,\n        ),\n    )\n\n\nclass TestBatchSandboxProvider:\n    \"\"\"BatchSandboxProvider unit tests\"\"\"\n    \n    # ===== Initialization Tests =====\n    \n    def test_init_without_template_creates_provider(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify normal initialization without template\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        assert provider.k8s_client == mock_k8s_client\n        assert provider.template_manager._template is None\n        assert provider.group == \"sandbox.opensandbox.io\"\n        assert provider.version == \"v1alpha1\"\n        assert provider.plural == \"batchsandboxes\"\n    \n    def test_init_with_template_loads_template(self, mock_k8s_client, tmp_path):\n        \"\"\"\n        Test case: Verify correct loading with template\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_file.write_text(\"spec:\\n  replicas: 1\")\n        \n        provider = BatchSandboxProvider(mock_k8s_client, _app_config_with_template(str(template_file)))\n        \n        assert provider.template_manager._template is not None\n    \n    def test_init_sets_crd_constants_correctly(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify CRD constants set correctly\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        assert provider.group == \"sandbox.opensandbox.io\"\n        assert provider.version == \"v1alpha1\"\n        assert provider.plural == \"batchsandboxes\"\n    \n    # ===== Workload Creation Tests =====\n    \n    def test_create_workload_builds_correct_manifest(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify created manifest structure is correct\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n        \n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        \n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={\"opensandbox.io/id\": \"test-id\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\"\n        )\n        \n        assert result == {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        \n        # Verify API call\n        call_args = mock_k8s_client.create_custom_object.call_args\n        body = call_args.kwargs[\"body\"]\n        \n        assert body[\"apiVersion\"] == \"sandbox.opensandbox.io/v1alpha1\"\n        assert body[\"kind\"] == \"BatchSandbox\"\n        assert body[\"metadata\"][\"name\"] == \"test-id\"\n        assert body[\"metadata\"][\"namespace\"] == \"test-ns\"\n        assert body[\"spec\"][\"replicas\"] == 1\n        assert body[\"spec\"][\"expireTime\"] == \"2025-12-31T10:00:00+00:00\"\n        assert \"template\" in body[\"spec\"]\n        assert \"initContainers\" in body[\"spec\"][\"template\"][\"spec\"]\n        assert \"containers\" in body[\"spec\"][\"template\"][\"spec\"]\n        assert \"volumes\" in body[\"spec\"][\"template\"][\"spec\"]\n    \n    def test_create_workload_builds_execd_init_container(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify execd init container built correctly without resources when not configured\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test\", \"uid\": \"uid\"}\n        }\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:test\"\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        init_container = body[\"spec\"][\"template\"][\"spec\"][\"initContainers\"][0]\n        \n        assert init_container[\"name\"] == \"execd-installer\"\n        assert init_container[\"image\"] == \"execd:test\"\n        assert init_container[\"command\"] == [\"/bin/sh\", \"-c\"]\n        assert \"bootstrap.sh\" in init_container[\"args\"][0]\n        assert init_container[\"volumeMounts\"][0][\"name\"] == \"opensandbox-bin\"\n        # No resources configured: resources field should be absent\n        assert \"resources\" not in init_container\n\n    def test_create_workload_init_container_with_configured_resources(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify init container applies resources when execd_init_resources is configured\n        \"\"\"\n        provider = BatchSandboxProvider(\n            mock_k8s_client,\n            _app_config_with_execd_resources(ExecdInitResources(\n                limits={\"cpu\": \"100m\", \"memory\": \"128Mi\"},\n                requests={\"cpu\": \"50m\", \"memory\": \"64Mi\"},\n            )),\n        )\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test\", \"uid\": \"uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:test\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        init_container = body[\"spec\"][\"template\"][\"spec\"][\"initContainers\"][0]\n        assert init_container[\"resources\"][\"limits\"] == {\"cpu\": \"100m\", \"memory\": \"128Mi\"}\n        assert init_container[\"resources\"][\"requests\"] == {\"cpu\": \"50m\", \"memory\": \"64Mi\"}\n    \n    def test_create_workload_wraps_entrypoint_with_bootstrap(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify user entrypoint is wrapped with bootstrap\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/usr/bin/python\", \"app.py\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        main_container = body[\"spec\"][\"template\"][\"spec\"][\"containers\"][0]\n        \n        assert main_container[\"command\"] == [\n            \"/opt/opensandbox/bin/bootstrap.sh\",\n            \"/usr/bin/python\",\n            \"app.py\"\n        ]\n    \n    def test_create_workload_converts_env_to_list(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify environment variable dict converted to list.\n        Also verifies EXECD environment variable is automatically injected.\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\", \"BAZ\": \"qux\"},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        env_vars = body[\"spec\"][\"template\"][\"spec\"][\"containers\"][0][\"env\"]\n        \n        # Should have user env vars plus EXECD\n        assert len(env_vars) == 3\n        env_dict = {e[\"name\"]: e[\"value\"] for e in env_vars}\n        assert env_dict[\"FOO\"] == \"bar\"\n        assert env_dict[\"BAZ\"] == \"qux\"\n        # Verify EXECD is automatically injected\n        assert env_dict[\"EXECD\"] == \"/opt/opensandbox/bin/execd\"\n\n    def test_create_workload_merges_template_volumes_and_mounts(self, mock_k8s_client, tmp_path):\n        \"\"\"\n        Test case: Verify template volumes/volumeMounts are merged into runtime manifest\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_file.write_text(\n            \"\"\"\nspec:\n  template:\n    spec:\n      volumes:\n        - name: sandbox-shared-data\n          emptyDir: {}\n      containers:\n        - name: sandbox\n          image: ubuntu:latest\n          volumeMounts:\n            - name: sandbox-shared-data\n              mountPath: /data\n\"\"\"\n        )\n        provider = BatchSandboxProvider(mock_k8s_client, _app_config_with_template(str(template_file)))\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        volume_names = [v[\"name\"] for v in spec[\"volumes\"]]\n        assert \"sandbox-shared-data\" in volume_names\n        assert \"opensandbox-bin\" in volume_names\n\n        # Runtime container should stay intact (template image should not override)\n        container = spec[\"containers\"][0]\n        assert container[\"name\"] == \"sandbox\"\n        assert container[\"image\"] == \"python:3.11\"\n\n        mount_names = [m[\"name\"] for m in container[\"volumeMounts\"]]\n        assert \"sandbox-shared-data\" in mount_names\n        assert \"opensandbox-bin\" in mount_names\n\n    def test_create_workload_dedupes_template_volume_and_mount_names(self, mock_k8s_client, tmp_path):\n        \"\"\"\n        Test case: Verify template entries do not duplicate runtime volumes/volumeMounts\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_file.write_text(\n            \"\"\"\nspec:\n  template:\n    spec:\n      volumes:\n        - name: opensandbox-bin\n          emptyDir: {}\n        - name: sandbox-shared-data\n          emptyDir: {}\n      containers:\n        - name: sandbox\n          volumeMounts:\n            - name: opensandbox-bin\n              mountPath: /opt/opensandbox/bin\n            - name: sandbox-shared-data\n              mountPath: /data\n\"\"\"\n        )\n        provider = BatchSandboxProvider(mock_k8s_client, _app_config_with_template(str(template_file)))\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        volume_names = [v[\"name\"] for v in spec[\"volumes\"]]\n        assert volume_names.count(\"opensandbox-bin\") == 1\n        assert \"sandbox-shared-data\" in volume_names\n\n        mount_names = [m[\"name\"] for m in spec[\"containers\"][0][\"volumeMounts\"]]\n        assert mount_names.count(\"opensandbox-bin\") == 1\n        assert \"sandbox-shared-data\" in mount_names\n    \n    def test_create_workload_sets_resource_limits_and_requests(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify resource limits set correctly\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        resources = body[\"spec\"][\"template\"][\"spec\"][\"containers\"][0][\"resources\"]\n        \n        assert resources[\"limits\"] == {\"cpu\": \"1\", \"memory\": \"1Gi\"}\n        assert resources[\"requests\"] == {\"cpu\": \"1\", \"memory\": \"1Gi\"}\n    \n    def test_create_workload_handles_empty_resource_limits(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify resources not set when resource limits are empty\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test\", \"uid\": \"uid\"}\n        }\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\"\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        container = body[\"spec\"][\"template\"][\"spec\"][\"containers\"][0]\n        \n        assert \"resources\" not in container\n    \n    # ===== Workload Query Tests =====\n    \n    def test_get_workload_finds_existing_sandbox(\n        self, mock_k8s_client, mock_batchsandbox_list_response\n    ):\n        \"\"\"\n        Test case: Verify successfully querying existing sandbox\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = mock_batchsandbox_list_response[\"items\"][0]\n        \n        result = provider.get_workload(\"test-id\", \"test-ns\")\n        \n        assert result is not None\n        assert result[\"metadata\"][\"name\"] == \"test-id\"\n    \n    def test_get_workload_returns_none_when_not_found(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify None returned when not found\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = None\n        \n        result = provider.get_workload(\"test-id\", \"test-ns\")\n        \n        assert result is None\n\n    def test_get_workload_falls_back_to_legacy_name(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify legacy sandbox-<id> name is used when primary lookup returns None\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.side_effect = [\n            None,\n            {\"metadata\": {\"name\": \"sandbox-test-id\"}},\n        ]\n        \n        result = provider.get_workload(\"test-id\", \"test-ns\")\n        \n        assert result[\"metadata\"][\"name\"] == \"sandbox-test-id\"\n        assert mock_k8s_client.get_custom_object.call_args_list[0].kwargs[\"name\"] == \"test-id\"\n        assert mock_k8s_client.get_custom_object.call_args_list[1].kwargs[\"name\"] == \"sandbox-test-id\"\n    \n    def test_get_workload_handles_404_gracefully(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify None returned when not found\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        mock_k8s_client.get_custom_object.return_value = None\n        \n        result = provider.get_workload(\"test-id\", \"test-ns\")\n        \n        assert result is None\n    \n    def test_get_workload_reraises_non_404_exceptions(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify non-404 exceptions are re-raised\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        # Mock 500 exception\n        error = ApiException(status=500)\n        mock_k8s_client.get_custom_object.side_effect = error\n        \n        with pytest.raises(ApiException) as exc_info:\n            provider.get_workload(\"test-id\", \"test-ns\")\n        \n        assert exc_info.value.status == 500\n\n    def test_get_workload_prefers_informer_cache(self, mock_k8s_client):\n        \"\"\"\n        Test case: get_workload calls k8s_client.get_custom_object and returns result\n        \"\"\"\n        cached = {\"metadata\": {\"name\": \"test-id\"}}\n        mock_k8s_client.get_custom_object.return_value = cached\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n\n        result = provider.get_workload(\"test-id\", \"test-ns\")\n\n        assert result == cached\n        mock_k8s_client.get_custom_object.assert_called()\n    \n    def test_get_workload_logs_unexpected_errors(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify unexpected errors are re-raised\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.side_effect = RuntimeError(\"Unexpected\")\n        \n        with pytest.raises(RuntimeError, match=\"Unexpected\"):\n            provider.get_workload(\"test-id\", \"test-ns\")\n\n    def test_create_workload_updates_informer_cache(self, mock_k8s_client):\n        \"\"\"\n        Test case: create_workload returns name and uid from created resource\n        \"\"\"\n        created_body = {\"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}}\n        mock_k8s_client.create_custom_object.return_value = created_body\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n\n        expires_at = datetime(2025, 12, 31, tzinfo=timezone.utc)\n\n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={\"opensandbox.io/id\": \"test-id\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n        )\n\n        assert result == {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n    \n    # ===== Workload List Tests =====\n    \n    def test_list_workloads_returns_items(\n        self, mock_k8s_client, mock_batchsandbox_list_response\n    ):\n        \"\"\"\n        Test case: Verify list query returns results\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_custom_objects.return_value = mock_batchsandbox_list_response[\"items\"]\n        \n        result = provider.list_workloads(\"test-ns\", \"opensandbox.io/id\")\n        \n        assert len(result) == 1\n        assert result[0][\"metadata\"][\"name\"] == \"test-id\"\n    \n    def test_list_workloads_returns_empty_on_404(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify empty list returned when no items\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.list_custom_objects.return_value = []\n        \n        result = provider.list_workloads(\"test-ns\", \"opensandbox.io/id\")\n        \n        assert result == []\n    \n    # ===== Workload Deletion Tests =====\n    \n    def test_delete_workload_deletes_existing_sandbox(\n        self, mock_k8s_client, mock_batchsandbox_list_response\n    ):\n        \"\"\"\n        Test case: Verify successfully deleting existing sandbox\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = mock_batchsandbox_list_response[\"items\"][0]\n        \n        provider.delete_workload(\"test-id\", \"test-ns\")\n        \n        mock_k8s_client.delete_custom_object.assert_called_once_with(\n            group=\"sandbox.opensandbox.io\",\n            version=\"v1alpha1\",\n            namespace=\"test-ns\",\n            plural=\"batchsandboxes\",\n            name=\"test-id\",\n            grace_period_seconds=0\n        )\n    \n    def test_delete_workload_raises_when_not_found(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify exception raised when not found\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = None\n        \n        with pytest.raises(Exception) as exc_info:\n            provider.delete_workload(\"test-id\", \"test-ns\")\n        \n        assert \"not found\" in str(exc_info.value)\n    \n    def test_delete_workload_sets_grace_period_zero(\n        self, mock_k8s_client, mock_batchsandbox_list_response\n    ):\n        \"\"\"\n        Test case: Verify immediate deletion (grace period = 0)\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = mock_batchsandbox_list_response[\"items\"][0]\n        \n        provider.delete_workload(\"test-id\", \"test-ns\")\n        \n        call_kwargs = mock_k8s_client.delete_custom_object.call_args.kwargs\n        assert call_kwargs[\"grace_period_seconds\"] == 0\n    \n    # ===== Expiration Time Management Tests =====\n    \n    def test_update_expiration_patches_spec(\n        self, mock_k8s_client, mock_batchsandbox_list_response\n    ):\n        \"\"\"\n        Test case: Verify expiration time update\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.get_custom_object.return_value = mock_batchsandbox_list_response[\"items\"][0]\n        \n        expires_at = datetime(2025, 12, 31, 0, 0, 0, tzinfo=timezone.utc)\n        provider.update_expiration(\"test-id\", \"test-ns\", expires_at)\n        \n        call_kwargs = mock_k8s_client.patch_custom_object.call_args.kwargs\n        assert call_kwargs[\"body\"] == {\n            \"spec\": {\"expireTime\": \"2025-12-31T00:00:00+00:00\"}\n        }\n    \n    def test_get_expiration_parses_iso_format(self):\n        \"\"\"\n        Test case: Verify parsing ISO format time\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"spec\": {\"expireTime\": \"2025-12-31T10:00:00+00:00\"}\n        }\n        \n        result = provider.get_expiration(workload)\n        \n        assert result == datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n    \n    def test_get_expiration_handles_z_suffix(self):\n        \"\"\"\n        Test case: Verify handling time with Z suffix\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"spec\": {\"expireTime\": \"2025-12-31T10:00:00Z\"}\n        }\n        \n        result = provider.get_expiration(workload)\n        \n        assert result == datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n    \n    def test_get_expiration_returns_none_on_invalid_format(self):\n        \"\"\"\n        Test case: Verify None returned on invalid format\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"spec\": {\"expireTime\": \"invalid-date\"}\n        }\n        \n        # Should return None and not raise exception\n        result = provider.get_expiration(workload)\n        \n        assert result is None\n    \n    def test_get_expiration_returns_none_when_missing(self):\n        \"\"\"\n        Test case: Verify None returned when missing\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\"spec\": {}}\n        \n        result = provider.get_expiration(workload)\n        \n        assert result is None\n    \n    # ===== Status Retrieval Tests =====\n    \n    def test_get_status_running_with_ip(self):\n        \"\"\"\n        Test case: Verify status when Pod is Ready and has IP\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 1, \"allocated\": 1},\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": '[\"10.0.0.1\"]'\n                },\n                \"creationTimestamp\": \"2025-12-24T10:00:00Z\"\n            }\n        }\n        \n        result = provider.get_status(workload)\n        \n        assert result[\"state\"] == \"Running\"\n        assert result[\"reason\"] == \"POD_READY_WITH_IP\"\n        assert \"IP\" in result[\"message\"]\n    \n    def test_get_status_allocated_with_ip_not_ready(self):\n        \"\"\"\n        Test case: Verify status when IP is assigned but Pod is not Ready (Allocated state)\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 0, \"allocated\": 1},\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": '[\"10.0.0.1\"]'\n                },\n                \"creationTimestamp\": \"2025-12-24T10:00:00Z\"\n            }\n        }\n        \n        result = provider.get_status(workload)\n        \n        assert result[\"state\"] == \"Allocated\"\n        assert result[\"reason\"] == \"IP_ASSIGNED\"\n    \n    def test_get_status_pending_scheduled(self):\n        \"\"\"\n        Test case: Verify Pod is scheduled but not Ready\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 0, \"allocated\": 1},\n            \"metadata\": {\"creationTimestamp\": \"2025-12-24T10:00:00Z\"}\n        }\n        \n        result = provider.get_status(workload)\n        \n        assert result[\"state\"] == \"Pending\"\n        assert result[\"reason\"] == \"POD_SCHEDULED\"\n    \n    def test_get_status_pending_when_endpoints_invalid_json(self):\n        \"\"\"\n        Test case: Verify Pending when endpoints annotation contains invalid JSON\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 0, \"allocated\": 1},\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": \"invalid-json\"\n                },\n                \"creationTimestamp\": \"2025-12-24T10:00:00Z\"\n            }\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Pending\"\n        assert result[\"reason\"] == \"POD_SCHEDULED\"\n\n    def test_get_status_pending_when_endpoints_empty_array(self):\n        \"\"\"\n        Test case: Verify Pending when endpoints annotation is empty array\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 0, \"allocated\": 1},\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": \"[]\"\n                },\n                \"creationTimestamp\": \"2025-12-24T10:00:00Z\"\n            }\n        }\n\n        result = provider.get_status(workload)\n\n        assert result[\"state\"] == \"Pending\"\n        assert result[\"reason\"] == \"POD_SCHEDULED\"\n    \n    def test_get_status_pending_unallocated(self):\n        \"\"\"\n        Test case: Verify Pod is not scheduled\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"status\": {\"replicas\": 1, \"ready\": 0, \"allocated\": 0},\n            \"metadata\": {\"creationTimestamp\": \"2025-12-24T10:00:00Z\"}\n        }\n        \n        result = provider.get_status(workload)\n        \n        assert result[\"state\"] == \"Pending\"\n        assert result[\"reason\"] == \"BATCHSANDBOX_PENDING\"\n    \n    # ===== Endpoint Information Tests =====\n    \n    def test_get_endpoint_info_parses_json_annotation(self):\n        \"\"\"\n        Test case: Verify parsing IP from annotation\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": '[\"10.0.0.1\"]'\n                }\n            }\n        }\n        \n        result = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n        \n        assert result.endpoint == \"10.0.0.1:8080\"\n        assert result.headers is None\n    \n    def test_get_endpoint_info_uses_first_ip(self):\n        \"\"\"\n        Test case: Verify using first IP when multiple IPs exist\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": '[\"10.0.0.1\", \"10.0.0.2\"]'\n                }\n            }\n        }\n        \n        result = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n        \n        assert result.endpoint == \"10.0.0.1:8080\"\n        assert result.headers is None\n    \n    def test_get_endpoint_info_returns_none_when_missing(self):\n        \"\"\"\n        Test case: Verify None returned when annotation is missing\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\"metadata\": {\"annotations\": {}}}\n        \n        result = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n        \n        assert result is None\n    \n    def test_get_endpoint_info_returns_none_on_invalid_json(self):\n        \"\"\"\n        Test case: Verify None returned on invalid JSON\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": \"invalid-json\"\n                }\n            }\n        }\n        \n        result = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n        \n        assert result is None\n    \n    def test_get_endpoint_info_returns_none_on_empty_array(self):\n        \"\"\"\n        Test case: Verify None returned on empty array\n        \"\"\"\n        provider = BatchSandboxProvider(MagicMock())\n        workload = {\n            \"metadata\": {\n                \"annotations\": {\n                    \"sandbox.opensandbox.io/endpoints\": \"[]\"\n                }\n            }\n        }\n        \n        result = provider.get_endpoint_info(workload, 8080, \"sandbox-123\")\n        \n        assert result is None\n\n    # ===== Pool-based Creation Tests =====\n    \n    def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client):\n        \"\"\"\n        Test that pool-based creation ignores image_spec parameter.\n        \n        Pool already defines the image, so image_spec is not used even if provided.\n        This verifies backward compatibility - no error is raised.\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        }\n        \n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"python\", \"app.py\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            extensions={\"poolRef\": \"my-pool\"}\n        )\n        \n        # Should succeed and return workload info\n        assert result == {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        \n        # Verify poolRef is used\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"spec\"][\"poolRef\"] == \"my-pool\"\n    \n    def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client):\n        \"\"\"\n        Test that pool-based creation ignores resource_limits parameter.\n        \n        Pool already defines the resources, so resource_limits is not used even if provided.\n        This verifies backward compatibility - no error is raised.\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        }\n        \n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"\"),\n            entrypoint=[\"python\", \"app.py\"],\n            env={},\n            resource_limits={\"cpu\": \"1\", \"memory\": \"1Gi\"},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            extensions={\"poolRef\": \"my-pool\"}\n        )\n        \n        # Should succeed and return workload info\n        assert result == {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        \n        # Verify poolRef is used\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"spec\"][\"poolRef\"] == \"my-pool\"\n    \n    def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client):\n        \"\"\"\n        Test that pool-based creation allows customizing entrypoint and env.\n        \n        Verifies taskTemplate structure is correctly generated with user's entrypoint and env.\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        }\n        \n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"\"),\n            entrypoint=[\"python\", \"app.py\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            extensions={\"poolRef\": \"my-pool\"}\n        )\n        \n        assert result == {\"name\": \"sandbox-test-id\", \"uid\": \"test-uid\"}\n        \n        # Verify the call\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"spec\"][\"poolRef\"] == \"my-pool\"\n        assert \"taskTemplate\" in body[\"spec\"]\n        \n        # Verify taskTemplate structure\n        task_template = body[\"spec\"][\"taskTemplate\"]\n        assert \"spec\" in task_template\n        assert \"process\" in task_template[\"spec\"]\n        command = task_template[\"spec\"][\"process\"][\"command\"]\n        assert command[0] == \"/bin/sh\"\n        assert command[1] == \"-c\"\n        # Command should contain bootstrap.sh execution\n        # Example: /opt/opensandbox/bin/bootstrap.sh python app.py &\n        assert \"/opt/opensandbox/bin/bootstrap.sh python app.py\" in command[2]\n        assert command[2].endswith(\" &\")\n        assert task_template[\"spec\"][\"process\"][\"env\"] == [{\"name\": \"FOO\", \"value\": \"bar\"}]\n    \n    def test_build_task_template_with_env(self, mock_k8s_client):\n        \"\"\"\n        Test _build_task_template with environment variables.\n        \n        Verifies:\n        - Command uses shell wrapper: /bin/sh -c \"...\"\n        - Entrypoint executed via bootstrap.sh in background (&)\n        - Env list formatted correctly for K8s\n        \n        Generated command example:\n        /bin/sh -c \"/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &\"\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        result = provider._build_task_template(\n            entrypoint=[\"/usr/bin/python\", \"app.py\"],\n            env={\"KEY1\": \"value1\", \"KEY2\": \"value2\"}\n        )\n        \n        assert \"spec\" in result\n        assert \"process\" in result[\"spec\"]\n        process_task = result[\"spec\"][\"process\"]\n        \n        # Verify command structure\n        command = process_task[\"command\"]\n        assert command[0] == \"/bin/sh\"\n        assert command[1] == \"-c\"\n        # Should execute via bootstrap.sh in background (&)\n        assert \"/opt/opensandbox/bin/bootstrap.sh\" in command[2]\n        assert \"/usr/bin/python\" in command[2]\n        assert \"app.py\" in command[2]\n        # Should end with & (run in background)\n        assert command[2].endswith(\"&\")\n        \n        # Verify env list\n        assert process_task[\"env\"] == [\n            {\"name\": \"KEY1\", \"value\": \"value1\"},\n            {\"name\": \"KEY2\", \"value\": \"value2\"}\n        ]\n    \n    def test_build_task_template_without_env(self, mock_k8s_client):\n        \"\"\"\n        Test _build_task_template without environment variables.\n        \n        Verifies command is wrapped in shell and executes via bootstrap.sh in background.\n        \n        Generated command example:\n        /bin/sh -c \"/opt/opensandbox/bin/bootstrap.sh /usr/bin/python app.py &\"\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        result = provider._build_task_template(\n            entrypoint=[\"/usr/bin/python\", \"app.py\"],\n            env={}\n        )\n        \n        assert \"spec\" in result\n        assert \"process\" in result[\"spec\"]\n        process_task = result[\"spec\"][\"process\"]\n        assert process_task[\"env\"] == []\n        # Without env, command directly calls bootstrap.sh in background\n        command = process_task[\"command\"]\n        assert command[0] == \"/bin/sh\"\n        assert command[1] == \"-c\"\n        # Check escaped entrypoint\n        assert \"/opt/opensandbox/bin/bootstrap.sh\" in command[2]\n        assert \"/usr/bin/python\" in command[2]\n        assert \"app.py\" in command[2]\n        assert command[2].endswith(\" &\")\n    \n    def test_build_task_template_uses_default_env_path(self, mock_k8s_client):\n        \"\"\"\n        Test that taskTemplate executes bootstrap.sh properly.\n        \n        Verifies:\n        - Entrypoint is properly escaped\n        - Command runs in background\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        result = provider._build_task_template(\n            entrypoint=[\"python\", \"app.py\"],\n            env={\"TEST_VAR\": \"test_value\"}\n        )\n        \n        command = result[\"spec\"][\"process\"][\"command\"][2]\n        # Should execute bootstrap.sh in background\n        assert \"/opt/opensandbox/bin/bootstrap.sh\" in command\n        assert \"python\" in command\n        assert \"app.py\" in command\n        assert command.endswith(\" &\")\n    \n    def test_build_task_template_escapes_special_characters(self, mock_k8s_client):\n        \"\"\"\n        Test that taskTemplate properly escapes arguments with spaces, quotes, and special chars.\n        \n        This prevents shell injection and ensures arguments are preserved correctly.\n        For example: ['python', '-c', 'print(\"a b\")'] should work correctly.\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        \n        result = provider._build_task_template(\n            entrypoint=[\"python\", \"-c\", 'print(\"hello world\")'],\n            env={\"KEY\": \"value with spaces\", \"QUOTE\": \"it's fine\"}\n        )\n        \n        command = result[\"spec\"][\"process\"][\"command\"][2]\n        \n        # Verify entrypoint args are properly escaped\n        assert \"python\" in command\n        assert \"-c\" in command\n        # The python code with spaces and quotes should be properly escaped\n        assert \"'print(\" in command or '\"print(' in command  # Escaped\n        \n        # Verify env is passed through env list, not in command\n        env_list = result[\"spec\"][\"process\"][\"env\"]\n        assert {\"name\": \"KEY\", \"value\": \"value with spaces\"} in env_list\n        assert {\"name\": \"QUOTE\", \"value\": \"it's fine\"} in env_list\n    \n    def test_create_workload_poolref_builds_correct_manifest(self, mock_k8s_client):\n        \"\"\"\n        Test complete pool-based BatchSandbox manifest structure.\n        \n        Verifies:\n        - Basic metadata (apiVersion, kind, name, labels)\n        - Pool-specific fields (poolRef, taskTemplate, expireTime)\n        - No template field (pool mode doesn't use pod template)\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n        \n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        \n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"\"),\n            entrypoint=[\"python\", \"app.py\"],\n            env={\"FOO\": \"bar\"},\n            resource_limits={},\n            labels={\"test\": \"label\"},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            extensions={\"poolRef\": \"test-pool\"}\n        )\n        \n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        \n        # Verify basic structure\n        assert body[\"apiVersion\"] == \"sandbox.opensandbox.io/v1alpha1\"\n        assert body[\"kind\"] == \"BatchSandbox\"\n        assert body[\"metadata\"][\"name\"] == \"test-id\"\n        assert body[\"metadata\"][\"labels\"] == {\"test\": \"label\"}\n        \n        # Verify pool-specific fields\n        assert body[\"spec\"][\"replicas\"] == 1\n        assert body[\"spec\"][\"poolRef\"] == \"test-pool\"\n        assert body[\"spec\"][\"expireTime\"] == \"2025-12-31T10:00:00+00:00\"\n        assert \"taskTemplate\" in body[\"spec\"]\n        \n        # Verify no template field (pool-based doesn't use template)\n        assert \"template\" not in body[\"spec\"]\n\n\nclass TestBatchSandboxProviderEgress:\n    \"\"\"BatchSandboxProvider egress sidecar tests\"\"\"\n\n    def test_create_workload_without_network_policy_no_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify no sidecar is added when network_policy is None\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=None,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should only have main container\n        assert len(containers) == 1\n        assert containers[0][\"name\"] == \"sandbox\"\n        # Should not have securityContext with sysctls\n        assert \"securityContext\" not in pod_spec or \"sysctls\" not in pod_spec.get(\"securityContext\", {})\n\n    def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify egress sidecar is added when network_policy is provided\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should have both main container and sidecar\n        assert len(containers) == 2\n        \n        # Find sidecar container\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        assert sidecar[\"image\"] == \"opensandbox/egress:v1.0.3\"\n        \n        # Verify sidecar has environment variable\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert \"OPENSANDBOX_EGRESS_RULES\" in env_vars\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS\n\n        caps = sidecar.get(\"securityContext\", {}).get(\"capabilities\", {})\n        assert \"NET_ADMIN\" in caps.get(\"add\", [])\n        assert sidecar.get(\"securityContext\", {}).get(\"privileged\") is not True\n        assert \"command\" not in sidecar\n\n        inits = pod_spec.get(\"initContainers\", [])\n        assert len(inits) == 1\n        execd_init = inits[0]\n        assert execd_init[\"name\"] == \"execd-installer\"\n        assert execd_init[\"image\"] == \"execd:latest\"\n        assert execd_init.get(\"securityContext\", {}).get(\"privileged\") is True\n        assert \"/proc/sys/net/ipv6/conf/all/disable_ipv6\" in execd_init[\"args\"][0]\n\n    def test_create_workload_with_network_policy_persists_annotation_and_sidecar_token(self, mock_k8s_client):\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=None,\n            execd_image=\"execd:latest\",\n            network_policy=NetworkPolicy(default_action=\"deny\", egress=[]),\n            egress_image=\"opensandbox/egress:v1.0.3\",\n            annotations={SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\"},\n            egress_auth_token=\"egress-token\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert body[\"metadata\"][\"annotations\"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == \"egress-token\"\n\n        containers = body[\"spec\"][\"template\"][\"spec\"][\"containers\"]\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert env_vars[OPENSANDBOX_EGRESS_TOKEN] == \"egress-token\"\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS\n\n    def test_create_workload_with_egress_mode_dns_nft(self, mock_k8s_client):\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=None,\n            execd_image=\"execd:latest\",\n            network_policy=NetworkPolicy(default_action=\"deny\", egress=[]),\n            egress_image=\"opensandbox/egress:v1.0.3\",\n            egress_mode=EGRESS_MODE_DNS_NFT,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        containers = body[\"spec\"][\"template\"][\"spec\"][\"containers\"]\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert env_vars[\"OPENSANDBOX_EGRESS_MODE\"] == EGRESS_MODE_DNS_NFT\n\n    def test_create_workload_with_network_policy_does_not_add_pod_ipv6_sysctls(self, mock_k8s_client):\n        \"\"\"IPv6 all.disable is applied in privileged execd init, not Pod sysctls.\"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        assert \"securityContext\" not in pod_spec or \"sysctls\" not in pod_spec.get(\"securityContext\", {})\n\n        sidecar = next(c for c in pod_spec[\"containers\"] if c[\"name\"] == \"egress\")\n        assert \"command\" not in sidecar\n        execd_init = pod_spec[\"initContainers\"][0]\n        assert execd_init[\"name\"] == \"execd-installer\"\n        assert \"/proc/sys/net/ipv6/conf/all/disable_ipv6\" in execd_init[\"args\"][0]\n\n    def test_create_workload_with_network_policy_drops_net_admin_from_main_container(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify main container drops NET_ADMIN when network_policy is enabled\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Find main container\n        main_container = next((c for c in containers if c[\"name\"] == \"sandbox\"), None)\n        assert main_container is not None\n        \n        # Verify main container has securityContext\n        assert \"securityContext\" in main_container\n        assert \"capabilities\" in main_container[\"securityContext\"]\n        assert \"drop\" in main_container[\"securityContext\"][\"capabilities\"]\n        assert \"NET_ADMIN\" in main_container[\"securityContext\"][\"capabilities\"][\"drop\"]\n\n    def test_create_workload_without_egress_image_no_sidecar(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify no sidecar is added when egress_image is None even if network_policy exists\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should only have main container\n        assert len(containers) == 1\n        assert containers[0][\"name\"] == \"sandbox\"\n\n    def test_egress_sidecar_contains_network_policy_in_env(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify sidecar environment variable contains serialized network policy\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[\n                NetworkRule(action=\"allow\", target=\"pypi.org\"),\n                NetworkRule(action=\"deny\", target=\"*.malicious.com\"),\n            ],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        \n        env_vars = {e[\"name\"]: e[\"value\"] for e in sidecar.get(\"env\", [])}\n        assert \"OPENSANDBOX_EGRESS_RULES\" in env_vars\n        \n        # Verify the environment variable contains valid JSON with network policy\n        import json\n        policy_json = json.loads(env_vars[\"OPENSANDBOX_EGRESS_RULES\"])\n        assert policy_json[\"defaultAction\"] == \"deny\"\n        assert len(policy_json[\"egress\"]) == 2\n        assert policy_json[\"egress\"][0][\"action\"] == \"allow\"\n        assert policy_json[\"egress\"][0][\"target\"] == \"pypi.org\"\n\n    def test_main_container_no_security_context_without_network_policy(self, mock_k8s_client):\n        \"\"\"\n        Test case: Verify main container has no securityContext when network_policy is None\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=None,\n            egress_image=None,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        main_container = containers[0]\n        # Main container should not have securityContext when no network policy\n        assert \"securityContext\" not in main_container\n\n    def test_create_workload_with_network_policy_works_with_template(self, mock_k8s_client, tmp_path):\n        \"\"\"\n        Test case: Verify egress sidecar works correctly when template is provided\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_file.write_text(\n            \"\"\"\nspec:\n  template:\n    spec:\n      volumes:\n        - name: sandbox-shared-data\n          emptyDir: {}\n\"\"\"\n        )\n        provider = BatchSandboxProvider(mock_k8s_client, _app_config_with_template(str(template_file)))\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            network_policy=network_policy,\n            egress_image=\"opensandbox/egress:v1.0.3\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n        containers = pod_spec[\"containers\"]\n        \n        # Should have both main container and sidecar\n        assert len(containers) == 2\n        \n        # Verify sidecar exists\n        sidecar = next((c for c in containers if c[\"name\"] == \"egress\"), None)\n        assert sidecar is not None\n        \n        # Pod-level IPv6 sysctls are not injected for egress (sidecar startup handles all.disable)\n        assert \"securityContext\" not in pod_spec or \"sysctls\" not in pod_spec.get(\"securityContext\", {})\n\n        # Verify template volumes are still merged\n        volume_names = [v[\"name\"] for v in pod_spec[\"volumes\"]]\n        assert \"sandbox-shared-data\" in volume_names\n        assert \"opensandbox-bin\" in volume_names\n\n    # ===== Image Auth Tests =====\n\n    def test_supports_image_auth_returns_true(self, mock_k8s_client):\n        \"\"\"\n        Test case: BatchSandboxProvider declares image auth support\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        assert provider.supports_image_auth() is True\n\n    def test_create_workload_with_image_auth_injects_image_pull_secrets(self, mock_k8s_client):\n        \"\"\"\n        Test case: imagePullSecrets is injected into pod spec when image auth is provided\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"uid-123\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(\n                uri=\"registry.example.com/img:tag\",\n                auth=ImageAuth(username=\"user\", password=\"pass\"),\n            ),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pull_secrets = body[\"spec\"][\"template\"][\"spec\"].get(\"imagePullSecrets\")\n        assert pull_secrets == [{\"name\": f\"{IMAGE_AUTH_SECRET_PREFIX}-test-id\"}]\n\n    def test_create_workload_with_image_auth_creates_secret(self, mock_k8s_client):\n        \"\"\"\n        Test case: a kubernetes.io/dockerconfigjson Secret is created with correct ownerReference\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"uid-abc\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(\n                uri=\"registry.example.com/img:tag\",\n                auth=ImageAuth(username=\"user\", password=\"pass\"),\n            ),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n        )\n\n        mock_k8s_client.create_secret.assert_called_once()\n        call_kwargs = mock_k8s_client.create_secret.call_args.kwargs\n        assert call_kwargs[\"namespace\"] == \"test-ns\"\n        secret = call_kwargs[\"body\"]\n        assert secret.type == \"kubernetes.io/dockerconfigjson\"\n        ref = secret.metadata.owner_references[0]\n        assert ref.uid == \"uid-abc\"\n        assert ref.kind == \"BatchSandbox\"\n        assert ref.name == \"test-id\"\n\n    def test_create_workload_without_image_auth_skips_secret(self, mock_k8s_client):\n        \"\"\"\n        Test case: no Secret is created when image auth is absent\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"uid-123\"}\n        }\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n        )\n\n        mock_k8s_client.create_secret.assert_not_called()\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        assert \"imagePullSecrets\" not in body[\"spec\"][\"template\"][\"spec\"]\n\n    def test_create_workload_with_image_auth_secret_failure_rolls_back_batchsandbox(self, mock_k8s_client):\n        \"\"\"\n        Test case: BatchSandbox is deleted when Secret creation fails\n        \"\"\"\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"uid-123\"}\n        }\n        mock_k8s_client.create_secret.side_effect = ApiException(status=403)\n\n        with pytest.raises(ApiException):\n            provider.create_workload(\n                sandbox_id=\"test-id\",\n                namespace=\"test-ns\",\n                image_spec=ImageSpec(\n                    uri=\"registry.example.com/img:tag\",\n                    auth=ImageAuth(username=\"user\", password=\"pass\"),\n                ),\n                entrypoint=[\"/bin/bash\"],\n                env={},\n                resource_limits={},\n                labels={},\n                expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n                execd_image=\"execd:latest\",\n            )\n\n        mock_k8s_client.delete_custom_object.assert_called_once_with(\n            group=provider.group,\n            version=provider.version,\n            namespace=\"test-ns\",\n            plural=provider.plural,\n            name=\"test-id\",\n            grace_period_seconds=0,\n        )\n\n    # ===== Volume Support Tests =====\n\n    def test_create_workload_with_pvc_volume(self, mock_k8s_client):\n        \"\"\"\n        Test creating workload with PVC volume mount.\n\n        Verifies:\n        - PVC volume is correctly added to pod spec\n        - Volume mount is added to main container\n        - claimName is correctly set\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        expires_at = datetime(2025, 12, 31, 10, 0, 0, tzinfo=timezone.utc)\n\n        volumes = [\n            Volume(\n                name=\"data-volume\",\n                pvc=PVC(claim_name=\"my-pvc\"),\n                mount_path=\"/mnt/data\",\n                read_only=False,\n            )\n        ]\n\n        result = provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=expires_at,\n            execd_image=\"execd:latest\",\n            volumes=volumes,\n        )\n\n        assert result == {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n\n        # Verify API call\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        # Check volume definition\n        volumes_list = pod_spec.get(\"volumes\", [])\n        pvc_volume = next((v for v in volumes_list if v[\"name\"] == \"data-volume\"), None)\n        assert pvc_volume is not None\n        assert pvc_volume[\"persistentVolumeClaim\"][\"claimName\"] == \"my-pvc\"\n\n        # Check volume mount in main container\n        main_container = pod_spec[\"containers\"][0]\n        mounts = main_container.get(\"volumeMounts\", [])\n        data_mount = next((m for m in mounts if m[\"name\"] == \"data-volume\"), None)\n        assert data_mount is not None\n        assert data_mount[\"mountPath\"] == \"/mnt/data\"\n        assert data_mount[\"readOnly\"] is False\n\n    def test_create_workload_with_pvc_volume_readonly(self, mock_k8s_client):\n        \"\"\"\n        Test creating workload with read-only PVC volume mount.\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        volumes = [\n            Volume(\n                name=\"models-volume\",\n                pvc=PVC(claim_name=\"models-pvc\"),\n                mount_path=\"/mnt/models\",\n                read_only=True,\n            )\n        ]\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            volumes=volumes,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        main_container = pod_spec[\"containers\"][0]\n        mounts = main_container.get(\"volumeMounts\", [])\n        models_mount = next((m for m in mounts if m[\"name\"] == \"models-volume\"), None)\n        assert models_mount is not None\n        assert models_mount[\"readOnly\"] is True\n\n    def test_create_workload_with_pvc_volume_subpath(self, mock_k8s_client):\n        \"\"\"\n        Test creating workload with PVC volume mount with subPath.\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        volumes = [\n            Volume(\n                name=\"data-volume\",\n                pvc=PVC(claim_name=\"shared-pvc\"),\n                mount_path=\"/mnt/data\",\n                sub_path=\"task-001\",\n                read_only=False,\n            )\n        ]\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            volumes=volumes,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        main_container = pod_spec[\"containers\"][0]\n        mounts = main_container.get(\"volumeMounts\", [])\n        data_mount = next((m for m in mounts if m[\"name\"] == \"data-volume\"), None)\n        assert data_mount is not None\n        assert data_mount.get(\"subPath\") == \"task-001\"\n\n    def test_create_workload_with_host_volume(self, mock_k8s_client):\n        \"\"\"\n        Test creating workload with hostPath volume mount.\n        \"\"\"\n        from src.api.schema import Volume, Host\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        volumes = [\n            Volume(\n                name=\"host-volume\",\n                host=Host(path=\"/data/shared\"),\n                mount_path=\"/mnt/host\",\n                read_only=True,\n            )\n        ]\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            volumes=volumes,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        # Check volume definition\n        volumes_list = pod_spec.get(\"volumes\", [])\n        host_volume = next((v for v in volumes_list if v[\"name\"] == \"host-volume\"), None)\n        assert host_volume is not None\n        assert host_volume[\"hostPath\"][\"path\"] == \"/data/shared\"\n        assert host_volume[\"hostPath\"][\"type\"] == \"DirectoryOrCreate\"\n\n        # Check volume mount\n        main_container = pod_spec[\"containers\"][0]\n        mounts = main_container.get(\"volumeMounts\", [])\n        host_mount = next((m for m in mounts if m[\"name\"] == \"host-volume\"), None)\n        assert host_mount is not None\n        assert host_mount[\"mountPath\"] == \"/mnt/host\"\n        assert host_mount[\"readOnly\"] is True\n\n    def test_create_workload_with_multiple_volumes(self, mock_k8s_client):\n        \"\"\"\n        Test creating workload with multiple volumes (PVC and hostPath).\n        \"\"\"\n        from src.api.schema import Volume, PVC, Host\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n        mock_k8s_client.create_custom_object.return_value = {\n            \"metadata\": {\"name\": \"test-id\", \"uid\": \"test-uid\"}\n        }\n\n        volumes = [\n            Volume(\n                name=\"pvc-volume\",\n                pvc=PVC(claim_name=\"data-pvc\"),\n                mount_path=\"/mnt/data\",\n                read_only=False,\n            ),\n            Volume(\n                name=\"host-volume\",\n                host=Host(path=\"/tmp/cache\"),\n                mount_path=\"/mnt/cache\",\n                read_only=True,\n            ),\n        ]\n\n        provider.create_workload(\n            sandbox_id=\"test-id\",\n            namespace=\"test-ns\",\n            image_spec=ImageSpec(uri=\"python:3.11\"),\n            entrypoint=[\"/bin/bash\"],\n            env={},\n            resource_limits={},\n            labels={},\n            expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n            execd_image=\"execd:latest\",\n            volumes=volumes,\n        )\n\n        body = mock_k8s_client.create_custom_object.call_args.kwargs[\"body\"]\n        pod_spec = body[\"spec\"][\"template\"][\"spec\"]\n\n        # Check both volumes exist\n        volumes_list = pod_spec.get(\"volumes\", [])\n        assert len([v for v in volumes_list if v[\"name\"] in (\"pvc-volume\", \"host-volume\")]) == 2\n\n        # Check both mounts exist\n        main_container = pod_spec[\"containers\"][0]\n        mounts = main_container.get(\"volumeMounts\", [])\n        mount_names = {m[\"name\"] for m in mounts}\n        assert \"pvc-volume\" in mount_names\n        assert \"host-volume\" in mount_names\n\n    def test_create_workload_pool_mode_rejects_volumes(self, mock_k8s_client):\n        \"\"\"\n        Test that pool mode rejects volumes with clear error message.\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        provider = BatchSandboxProvider(mock_k8s_client)\n\n        volumes = [\n            Volume(\n                name=\"data-volume\",\n                pvc=PVC(claim_name=\"my-pvc\"),\n                mount_path=\"/mnt/data\",\n            )\n        ]\n\n        with pytest.raises(ValueError, match=\"Pool mode does not support volumes\"):\n            provider.create_workload(\n                sandbox_id=\"test-id\",\n                namespace=\"test-ns\",\n                image_spec=ImageSpec(uri=\"python:3.11\"),\n                entrypoint=[\"/bin/bash\"],\n                env={},\n                resource_limits={},\n                labels={},\n                expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc),\n                execd_image=\"execd:latest\",\n                extensions={\"poolRef\": \"my-pool\"},\n                volumes=volumes,\n            )\n\n    def test_apply_volumes_to_pod_spec_empty_volumes(self, mock_k8s_client):\n        \"\"\"\n        Test apply_volumes_to_pod_spec with empty volumes list.\n        \"\"\"\n        pod_spec = {\n            \"containers\": [{\"name\": \"main\", \"volumeMounts\": []}],\n            \"volumes\": [],\n        }\n\n        apply_volumes_to_pod_spec(pod_spec, [])\n\n        # Should not modify pod_spec\n        assert pod_spec[\"volumes\"] == []\n        assert pod_spec[\"containers\"][0][\"volumeMounts\"] == []\n\n    def test_apply_volumes_to_pod_spec_no_containers(self, mock_k8s_client):\n        \"\"\"\n        Test apply_volumes_to_pod_spec with no containers returns early without error.\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        pod_spec = {\"volumes\": []}\n        volumes = [Volume(name=\"test\", pvc=PVC(claim_name=\"pvc\"), mount_path=\"/mnt\")]\n\n        # Should not raise exception\n        apply_volumes_to_pod_spec(pod_spec, volumes)\n\n        # Pod spec should remain unchanged (no containers to mount to)\n        assert pod_spec[\"volumes\"] == []\n\n    def test_apply_volumes_to_pod_spec_duplicate_internal_volume(self, mock_k8s_client):\n        \"\"\"\n        Test apply_volumes_to_pod_spec rejects volume names that collide with internal volumes.\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        pod_spec = {\n            \"containers\": [{\"name\": \"sandbox\", \"volumeMounts\": []}],\n            \"volumes\": [{\"name\": \"opensandbox-bin\", \"emptyDir\": {}}],\n        }\n        volumes = [Volume(name=\"opensandbox-bin\", pvc=PVC(claim_name=\"pvc\"), mount_path=\"/mnt\")]\n\n        # Should raise ValueError for duplicate volume name\n        with pytest.raises(ValueError) as exc_info:\n            apply_volumes_to_pod_spec(pod_spec, volumes)\n\n        assert \"conflicts with an internal volume\" in str(exc_info.value)\n\n    def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts(self, mock_k8s_client):\n        \"\"\"\n        When multiple Volume API objects share the same claim_name, only one\n        Kubernetes volume is created; multiple volumeMounts reference it (avoids\n        CSI driver issues from duplicate PVC volume definitions).\n        \"\"\"\n        from src.api.schema import Volume, PVC\n\n        pod_spec = {\n            \"containers\": [{\"name\": \"main\", \"volumeMounts\": []}],\n            \"volumes\": [],\n        }\n        volumes = [\n            Volume(\n                name=\"skills\",\n                pvc=PVC(claim_name=\"oss-pvc-r\"),\n                mount_path=\"/path/to/skills\",\n                sub_path=\"skill-hub/publish\",\n                read_only=True,\n            ),\n            Volume(\n                name=\"draft\",\n                pvc=PVC(claim_name=\"oss-pvc-r\"),\n                mount_path=\"/path/to/draft\",\n                sub_path=\"skill-hub/draft\",\n                read_only=True,\n            ),\n        ]\n\n        apply_volumes_to_pod_spec(pod_spec, volumes)\n\n        # One volume definition for the shared PVC (first Volume name used)\n        assert len(pod_spec[\"volumes\"]) == 1\n        assert pod_spec[\"volumes\"][0][\"name\"] == \"skills\"\n        assert pod_spec[\"volumes\"][0][\"persistentVolumeClaim\"][\"claimName\"] == \"oss-pvc-r\"\n\n        # Two volumeMounts, both referencing the same volume name\n        mounts = pod_spec[\"containers\"][0][\"volumeMounts\"]\n        assert len(mounts) == 2\n        by_path = {m[\"mountPath\"]: m for m in mounts}\n        assert by_path[\"/path/to/skills\"][\"name\"] == \"skills\"\n        assert by_path[\"/path/to/skills\"].get(\"subPath\") == \"skill-hub/publish\"\n        assert by_path[\"/path/to/draft\"][\"name\"] == \"skills\"\n        assert by_path[\"/path/to/draft\"].get(\"subPath\") == \"skill-hub/draft\"\n"
  },
  {
    "path": "server/tests/k8s/test_batchsandbox_template.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for BatchSandboxTemplateManager.\n\"\"\"\n\nimport pytest\nimport yaml\n\nfrom src.services.k8s.batchsandbox_template import BatchSandboxTemplateManager\n\n\nclass TestBatchSandboxTemplateManager:\n    \"\"\"BatchSandboxTemplateManager unit tests\"\"\"\n    \n    def test_load_valid_yaml_template_successfully(self, tmp_path):\n        \"\"\"\n        Test case: Verify loading valid YAML template\n        \"\"\"\n        # Create valid template file\n        template_file = tmp_path / \"valid_template.yaml\"\n        template_content = {\n            \"metadata\": {\"annotations\": {\"test\": \"value\"}},\n            \"spec\": {\"template\": {\"spec\": {\"nodeSelector\": {\"env\": \"test\"}}}}\n        }\n        template_file.write_text(yaml.dump(template_content))\n        \n        manager = BatchSandboxTemplateManager(str(template_file))\n        \n        assert manager._template == template_content\n        assert manager.template_file_path == str(template_file)\n    \n    def test_load_nonexistent_file_raises_error(self):\n        \"\"\"\n        Test case: Verify FileNotFoundError raised when file doesn't exist\n        \"\"\"\n        # Should raise FileNotFoundError\n        with pytest.raises(FileNotFoundError) as exc_info:\n            BatchSandboxTemplateManager(\"/path/to/nonexistent.yaml\")\n        \n        assert \"not found\" in str(exc_info.value)\n    \n    def test_load_invalid_yaml_raises_error(self, tmp_path):\n        \"\"\"\n        Test case: Verify RuntimeError raised with invalid YAML file\n        \"\"\"\n        # Create malformed YAML\n        template_file = tmp_path / \"invalid.yaml\"\n        template_file.write_text(\"invalid: yaml: [missing: bracket\")\n        \n        # Should raise RuntimeError\n        with pytest.raises(RuntimeError) as exc_info:\n            BatchSandboxTemplateManager(str(template_file))\n        \n        assert \"Failed to load\" in str(exc_info.value)\n    \n    def test_load_non_dict_yaml_raises_error(self, tmp_path):\n        \"\"\"\n        Test case: Verify ValueError raised when YAML content is not a dict\n        \"\"\"\n        # Create YAML with list\n        template_file = tmp_path / \"list.yaml\"\n        template_file.write_text(\"- item1\\n- item2\")\n        \n        # Should raise ValueError\n        with pytest.raises(ValueError) as exc_info:\n            BatchSandboxTemplateManager(str(template_file))\n        \n        assert \"must be a YAML object\" in str(exc_info.value)\n        assert \"got list\" in str(exc_info.value)\n    \n    def test_init_without_template_file_creates_empty_manager(self):\n        \"\"\"\n        Test case: Verify empty manager created without template file\n        \"\"\"\n        manager = BatchSandboxTemplateManager(None)\n        \n        assert manager._template is None\n        assert manager.template_file_path is None\n    \n    def test_deep_merge_runtime_overrides_template(self):\n        \"\"\"\n        Test case: Verify runtime values override template values\n        \"\"\"\n        base = {\"spec\": {\"replicas\": 1, \"expireTime\": \"old\"}}\n        override = {\"spec\": {\"expireTime\": \"new\"}}\n        \n        result = BatchSandboxTemplateManager._deep_merge(base, override)\n        \n        assert result == {\"spec\": {\"replicas\": 1, \"expireTime\": \"new\"}}\n    \n    def test_deep_merge_preserves_template_only_fields(self):\n        \"\"\"\n        Test case: Verify template-only fields are preserved\n        \"\"\"\n        base = {\n            \"spec\": {\n                \"template\": {\n                    \"spec\": {\n                        \"nodeSelector\": {\"env\": \"prod\"},\n                        \"tolerations\": [{\"key\": \"test\"}]\n                    }\n                }\n            }\n        }\n        override = {\"spec\": {\"replicas\": 1}}\n        \n        result = BatchSandboxTemplateManager._deep_merge(base, override)\n        \n        assert result[\"spec\"][\"replicas\"] == 1\n        assert result[\"spec\"][\"template\"][\"spec\"][\"nodeSelector\"] == {\"env\": \"prod\"}\n        assert result[\"spec\"][\"template\"][\"spec\"][\"tolerations\"] == [{\"key\": \"test\"}]\n    \n    def test_deep_merge_nested_dicts_recursively(self):\n        \"\"\"\n        Test case: Verify nested dicts are merged recursively\n        \"\"\"\n        base = {\"metadata\": {\"annotations\": {\"a\": \"1\", \"b\": \"2\"}}}\n        override = {\"metadata\": {\"annotations\": {\"b\": \"3\", \"c\": \"4\"}}}\n        \n        result = BatchSandboxTemplateManager._deep_merge(base, override)\n        \n        expected = {\"metadata\": {\"annotations\": {\"a\": \"1\", \"b\": \"3\", \"c\": \"4\"}}}\n        assert result == expected\n    \n    def test_deep_merge_replaces_lists_not_merges(self):\n        \"\"\"\n        Test case: Verify lists are replaced not merged\n        \"\"\"\n        base = {\"spec\": {\"tolerations\": [{\"key\": \"a\"}]}}\n        override = {\"spec\": {\"tolerations\": [{\"key\": \"b\"}]}}\n        \n        result = BatchSandboxTemplateManager._deep_merge(base, override)\n        \n        assert result == {\"spec\": {\"tolerations\": [{\"key\": \"b\"}]}}\n    \n    def test_deep_merge_none_values_do_not_override(self):\n        \"\"\"\n        Test case: Verify None values don't override existing values\n        \"\"\"\n        base = {\"spec\": {\"expireTime\": \"2024-12-31\"}}\n        override = {\"spec\": {\"expireTime\": None}}\n        \n        result = BatchSandboxTemplateManager._deep_merge(base, override)\n        \n        assert result == {\"spec\": {\"expireTime\": \"2024-12-31\"}}\n    \n    def test_deep_copy_creates_independent_copies(self):\n        \"\"\"\n        Test case: Verify deep copy creates independent copies\n        \"\"\"\n        original = {\n            \"nested\": {\"list\": [1, 2, 3], \"dict\": {\"key\": \"value\"}}\n        }\n        \n        copy = BatchSandboxTemplateManager._deep_copy(original)\n        \n        # Modify copy\n        copy[\"nested\"][\"list\"].append(4)\n        copy[\"nested\"][\"dict\"][\"key\"] = \"new_value\"\n        \n        # Original should not be affected\n        assert original[\"nested\"][\"list\"] == [1, 2, 3]\n        assert original[\"nested\"][\"dict\"][\"key\"] == \"value\"\n    \n    def test_get_base_template_returns_copy(self, tmp_path):\n        \"\"\"\n        Test case: Verify get_base_template returns a copy\n        \"\"\"\n        template_file = tmp_path / \"template.yaml\"\n        template_content = {\"spec\": {\"replicas\": 1}}\n        template_file.write_text(yaml.dump(template_content))\n        \n        manager = BatchSandboxTemplateManager(str(template_file))\n        \n        template1 = manager.get_base_template()\n        template2 = manager.get_base_template()\n        \n        # Same content but not same object\n        assert template1 == template2\n        assert template1 is not template2\n    \n    def test_get_base_template_returns_empty_dict_when_no_template(self):\n        \"\"\"\n        Test case: Verify empty dict returned when no template\n        \"\"\"\n        manager = BatchSandboxTemplateManager(None)\n        \n        result = manager.get_base_template()\n        \n        assert result == {}\n    \n    def test_merge_with_runtime_values_without_template(self):\n        \"\"\"\n        Test case: Verify runtime manifest returned directly when no template\n        \"\"\"\n        manager = BatchSandboxTemplateManager(None)\n        runtime_manifest = {\"spec\": {\"replicas\": 1}}\n        \n        result = manager.merge_with_runtime_values(runtime_manifest)\n        \n        assert result == runtime_manifest\n    \n    def test_merge_with_runtime_values_with_template(self, tmp_path):\n        \"\"\"\n        Test case: Verify correct merge when template exists\n        \"\"\"\n        # Create template\n        template_file = tmp_path / \"template.yaml\"\n        template_content = {\n            \"spec\": {\n                \"template\": {\n                    \"spec\": {\n                        \"nodeSelector\": {\"workload\": \"sandbox\"},\n                        \"tolerations\": [{\"operator\": \"Exists\"}]\n                    }\n                }\n            }\n        }\n        template_file.write_text(yaml.dump(template_content))\n        \n        manager = BatchSandboxTemplateManager(str(template_file))\n        \n        # Runtime manifest\n        runtime_manifest = {\n            \"spec\": {\n                \"replicas\": 1,\n                \"template\": {\n                    \"spec\": {\n                        \"containers\": [{\"name\": \"test\"}],\n                        \"volumes\": [{\"name\": \"vol\"}]\n                    }\n                }\n            }\n        }\n        \n        result = manager.merge_with_runtime_values(runtime_manifest)\n        \n        # Verify template fields are preserved\n        assert result[\"spec\"][\"template\"][\"spec\"][\"nodeSelector\"] == {\"workload\": \"sandbox\"}\n        assert result[\"spec\"][\"template\"][\"spec\"][\"tolerations\"] == [{\"operator\": \"Exists\"}]\n        # Verify runtime fields are added\n        assert result[\"spec\"][\"replicas\"] == 1\n        assert result[\"spec\"][\"template\"][\"spec\"][\"containers\"] == [{\"name\": \"test\"}]\n        assert result[\"spec\"][\"template\"][\"spec\"][\"volumes\"] == [{\"name\": \"vol\"}]\n"
  },
  {
    "path": "server/tests/k8s/test_egress_helper.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for egress helper functions.\n\"\"\"\n\nimport json\nfrom typing import Optional\n\nfrom src.api.schema import NetworkPolicy, NetworkRule\nfrom src.config import EGRESS_MODE_DNS, EGRESS_MODE_DNS_NFT\nfrom src.services.constants import EGRESS_MODE_ENV, EGRESS_RULES_ENV, OPENSANDBOX_EGRESS_TOKEN\nfrom src.services.k8s.egress_helper import (\n    apply_egress_to_spec,\n    build_security_context_for_sandbox_container,\n    prep_execd_init_for_egress,\n)\n\n\ndef _egress_container(\n    egress_image: str,\n    network_policy: NetworkPolicy,\n    *,\n    egress_auth_token: Optional[str] = None,\n    egress_mode: str = EGRESS_MODE_DNS,\n) -> dict:\n    \"\"\"Sidecar dict produced by ``apply_egress_to_spec``.\"\"\"\n    containers: list = []\n    apply_egress_to_spec(\n        containers,\n        network_policy,\n        egress_image,\n        egress_auth_token=egress_auth_token,\n        egress_mode=egress_mode,\n    )\n    return containers[0]\n\n\nclass TestEgressSidecarViaApply:\n    \"\"\"Egress sidecar shape (via ``apply_egress_to_spec``).\"\"\"\n\n    def test_builds_container_with_basic_config(self):\n        \"\"\"Test that container is built with correct basic configuration.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[\n                NetworkRule(action=\"allow\", target=\"pypi.org\"),\n            ],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        assert container[\"name\"] == \"egress\"\n        assert container[\"image\"] == egress_image\n        assert \"env\" in container\n        assert \"securityContext\" in container\n\n    def test_contains_egress_rules_environment_variable(self):\n        \"\"\"Test that container includes OPENSANDBOX_EGRESS_RULES environment variable.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        env_vars = container[\"env\"]\n        assert len(env_vars) == 2\n        assert env_vars[0][\"name\"] == EGRESS_RULES_ENV\n        assert env_vars[0][\"value\"] is not None\n        assert env_vars[1][\"name\"] == EGRESS_MODE_ENV\n        assert env_vars[1][\"value\"] == EGRESS_MODE_DNS\n\n    def test_contains_egress_token_when_provided(self):\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        container = _egress_container(\n            egress_image,\n            network_policy,\n            egress_auth_token=\"egress-token\",\n        )\n\n        env_vars = {env[\"name\"]: env[\"value\"] for env in container[\"env\"]}\n        assert env_vars[OPENSANDBOX_EGRESS_TOKEN] == \"egress-token\"\n        assert env_vars[EGRESS_MODE_ENV] == EGRESS_MODE_DNS\n\n    def test_egress_mode_dns_nft(self):\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        container = _egress_container(\n            egress_image,\n            network_policy,\n            egress_mode=EGRESS_MODE_DNS_NFT,\n        )\n\n        env_vars = {env[\"name\"]: env[\"value\"] for env in container[\"env\"]}\n        assert env_vars[EGRESS_MODE_ENV] == EGRESS_MODE_DNS_NFT\n\n    def test_serializes_network_policy_correctly(self):\n        \"\"\"Test that network policy is correctly serialized to JSON.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[\n                NetworkRule(action=\"allow\", target=\"pypi.org\"),\n                NetworkRule(action=\"deny\", target=\"*.malicious.com\"),\n            ],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        env_value = container[\"env\"][0][\"value\"]\n        policy_dict = json.loads(env_value)\n\n        assert \"defaultAction\" in policy_dict\n        assert policy_dict[\"defaultAction\"] == \"deny\"\n        assert \"egress\" in policy_dict\n        assert len(policy_dict[\"egress\"]) == 2\n        assert policy_dict[\"egress\"][0][\"action\"] == \"allow\"\n        assert policy_dict[\"egress\"][0][\"target\"] == \"pypi.org\"\n        assert policy_dict[\"egress\"][1][\"action\"] == \"deny\"\n        assert policy_dict[\"egress\"][1][\"target\"] == \"*.malicious.com\"\n\n    def test_handles_empty_egress_rules(self):\n        \"\"\"Test that empty egress rules are handled correctly.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"allow\",\n            egress=[],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        env_value = container[\"env\"][0][\"value\"]\n        policy_dict = json.loads(env_value)\n\n        assert policy_dict[\"defaultAction\"] == \"allow\"\n        assert policy_dict[\"egress\"] == []\n\n    def test_handles_missing_default_action(self):\n        \"\"\"Test that missing default_action is handled (exclude_none=True).\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        env_value = container[\"env\"][0][\"value\"]\n        policy_dict = json.loads(env_value)\n\n        assert \"defaultAction\" not in policy_dict or policy_dict.get(\"defaultAction\") is None\n        assert \"egress\" in policy_dict\n\n    def test_security_context_adds_net_admin_not_privileged(self):\n        \"\"\"Egress sidecar uses NET_ADMIN only (IPv6 is disabled in execd init when egress is on).\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        security_context = container[\"securityContext\"]\n        assert security_context.get(\"privileged\") is not True\n        assert \"NET_ADMIN\" in security_context.get(\"capabilities\", {}).get(\"add\", [])\n\n    def test_no_command_uses_image_entrypoint(self):\n        container = _egress_container(\n            \"opensandbox/egress:v1.0.3\",\n            NetworkPolicy(default_action=\"deny\", egress=[]),\n        )\n        assert \"command\" not in container\n\n    def test_container_spec_is_valid_kubernetes_format(self):\n        \"\"\"Test that returned container spec is in valid Kubernetes format.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        assert \"name\" in container\n        assert \"image\" in container\n        assert \"env\" in container\n        assert \"securityContext\" in container\n\n        assert isinstance(container[\"env\"], list)\n        assert len(container[\"env\"]) > 0\n        assert \"name\" in container[\"env\"][0]\n        assert \"value\" in container[\"env\"][0]\n        assert \"command\" not in container\n\n    def test_handles_wildcard_domains(self):\n        \"\"\"Test that wildcard domains in egress rules are handled correctly.\"\"\"\n        egress_image = \"opensandbox/egress:v1.0.3\"\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[\n                NetworkRule(action=\"allow\", target=\"*.python.org\"),\n                NetworkRule(action=\"allow\", target=\"pypi.org\"),\n            ],\n        )\n\n        container = _egress_container(egress_image, network_policy)\n\n        env_value = container[\"env\"][0][\"value\"]\n        policy_dict = json.loads(env_value)\n\n        assert len(policy_dict[\"egress\"]) == 2\n        assert policy_dict[\"egress\"][0][\"target\"] == \"*.python.org\"\n        assert policy_dict[\"egress\"][1][\"target\"] == \"pypi.org\"\n\n\nclass TestBuildSecurityContextForMainContainer:\n    \"\"\"Tests for build_security_context_for_sandbox_container function.\"\"\"\n\n    def test_returns_empty_dict_when_no_network_policy(self):\n        \"\"\"Test that empty dict is returned when network policy is disabled.\"\"\"\n        result = build_security_context_for_sandbox_container(has_network_policy=False)\n        assert result == {}\n\n    def test_drops_net_admin_when_network_policy_enabled(self):\n        \"\"\"Test that NET_ADMIN is dropped when network policy is enabled.\"\"\"\n        result = build_security_context_for_sandbox_container(has_network_policy=True)\n\n        assert \"capabilities\" in result\n        assert \"drop\" in result[\"capabilities\"]\n        assert \"NET_ADMIN\" in result[\"capabilities\"][\"drop\"]\n\n\nclass TestApplyEgressToSpec:\n    \"\"\"Tests for apply_egress_to_spec function.\"\"\"\n\n    def test_adds_egress_sidecar_container(self):\n        \"\"\"Test that egress sidecar container is added to containers list.\"\"\"\n        containers: list = []\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n        egress_image = \"opensandbox/egress:v1.0.3\"\n\n        apply_egress_to_spec(\n            containers,\n            network_policy,\n            egress_image,\n        )\n\n        assert len(containers) == 1\n        assert containers[0][\"name\"] == \"egress\"\n        assert containers[0][\"image\"] == egress_image\n\n    def test_does_not_touch_unrelated_pod_state(self):\n        \"\"\"apply_egress_to_spec only appends to containers (no pod_spec parameter).\"\"\"\n        containers: list = []\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n        egress_image = \"opensandbox/egress:v1.0.3\"\n\n        apply_egress_to_spec(\n            containers,\n            network_policy,\n            egress_image,\n        )\n\n        assert len(containers) == 1\n\n    def test_preserves_existing_pod_sysctls_when_not_passed_in(self):\n        \"\"\"Callers keep pod sysctls in their own dict; apply does not mutate them.\"\"\"\n        pod_spec: dict = {\n            \"securityContext\": {\n                \"sysctls\": [\n                    {\"name\": \"net.core.somaxconn\", \"value\": \"1024\"},\n                    {\"name\": \"net.ipv6.conf.all.disable_ipv6\", \"value\": \"0\"},\n                ]\n            }\n        }\n        containers: list = []\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n        egress_image = \"opensandbox/egress:v1.0.3\"\n\n        apply_egress_to_spec(\n            containers,\n            network_policy,\n            egress_image,\n        )\n\n        sysctls = pod_spec[\"securityContext\"][\"sysctls\"]\n        sysctl_dict = {s[\"name\"]: s[\"value\"] for s in sysctls}\n\n        assert sysctl_dict[\"net.core.somaxconn\"] == \"1024\"\n        assert sysctl_dict[\"net.ipv6.conf.all.disable_ipv6\"] == \"0\"\n        assert len(sysctls) == 2\n\n    def test_no_op_when_no_network_policy(self):\n        \"\"\"Test that function does nothing when network_policy is None.\"\"\"\n        containers: list = []\n\n        apply_egress_to_spec(\n            containers,\n            None,\n            \"opensandbox/egress:v1.0.3\",\n        )\n\n        assert len(containers) == 0\n\n    def test_no_op_when_no_egress_image(self):\n        \"\"\"Test that function does nothing when egress_image is None.\"\"\"\n        containers: list = []\n        network_policy = NetworkPolicy(\n            default_action=\"deny\",\n            egress=[NetworkRule(action=\"allow\", target=\"example.com\")],\n        )\n\n        apply_egress_to_spec(\n            containers,\n            network_policy,\n            None,\n        )\n\n        assert len(containers) == 0\n\n\nclass TestPrepExecdInitForEgress:\n    def test_returns_privileged_security_dict_and_prefixed_script(self):\n        base = \"cp ./execd /opt/opensandbox/bin/execd\"\n        script, sc = prep_execd_init_for_egress(base)\n        assert sc == {\"privileged\": True}\n        assert \"/proc/sys/net/ipv6/conf/all/disable_ipv6\" in script\n        assert script.endswith(base)\n"
  },
  {
    "path": "server/tests/k8s/test_image_pull_secret_helper.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for image_pull_secret_helper.\n\"\"\"\n\nimport base64\nimport json\n\nfrom src.api.schema import ImageAuth\nfrom src.services.k8s.image_pull_secret_helper import (\n    IMAGE_AUTH_SECRET_PREFIX,\n    build_image_pull_secret,\n    build_image_pull_secret_name,\n)\n\n\nclass TestBuildImagePullSecretName:\n\n    def test_returns_deterministic_name(self):\n        assert build_image_pull_secret_name(\"abc123\") == f\"{IMAGE_AUTH_SECRET_PREFIX}-abc123\"\n\n    def test_different_ids_produce_different_names(self):\n        assert build_image_pull_secret_name(\"id-1\") != build_image_pull_secret_name(\"id-2\")\n\n\nclass TestBuildImagePullSecret:\n\n    def _auth(self, username=\"user\", password=\"pass\") -> ImageAuth:\n        return ImageAuth(username=username, password=password)\n\n    def _decode_docker_config(self, secret) -> dict:\n        raw = base64.b64decode(secret.data[\".dockerconfigjson\"])\n        return json.loads(raw)\n\n    def test_secret_metadata(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"registry.example.com/ns/img:tag\",\n            auth=self._auth(),\n            owner_uid=\"uid-1\",\n            owner_api_version=\"sandbox.opensandbox.io/v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        assert secret.metadata.name == f\"{IMAGE_AUTH_SECRET_PREFIX}-sid\"\n        assert secret.type == \"kubernetes.io/dockerconfigjson\"\n        assert secret.api_version == \"v1\"\n        assert secret.kind == \"Secret\"\n\n    def test_owner_reference(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"registry.example.com/img:tag\",\n            auth=self._auth(),\n            owner_uid=\"uid-abc\",\n            owner_api_version=\"sandbox.opensandbox.io/v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        refs = secret.metadata.owner_references\n        assert len(refs) == 1\n        ref = refs[0]\n        assert ref.uid == \"uid-abc\"\n        assert ref.api_version == \"sandbox.opensandbox.io/v1alpha1\"\n        assert ref.kind == \"BatchSandbox\"\n        assert ref.name == \"sid\"\n        assert ref.controller is False\n\n    def test_private_registry_extracted_from_image_uri(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"registry.example.com/ns/img:tag\",\n            auth=self._auth(\"u\", \"p\"),\n            owner_uid=\"uid\",\n            owner_api_version=\"sandbox.opensandbox.io/v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        config = self._decode_docker_config(secret)\n        assert \"registry.example.com\" in config[\"auths\"]\n\n    def test_docker_hub_image_uses_default_registry(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"python:3.11\",\n            auth=self._auth(\"u\", \"p\"),\n            owner_uid=\"uid\",\n            owner_api_version=\"sandbox.opensandbox.io/v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        config = self._decode_docker_config(secret)\n        assert \"https://index.docker.io/v1/\" in config[\"auths\"]\n\n    def test_auth_credentials_encoded_correctly(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"registry.example.com/img:tag\",\n            auth=self._auth(\"myuser\", \"mypass\"),\n            owner_uid=\"uid\",\n            owner_api_version=\"sandbox.opensandbox.io/v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        config = self._decode_docker_config(secret)\n        registry_config = config[\"auths\"][\"registry.example.com\"]\n        assert registry_config[\"username\"] == \"myuser\"\n        assert registry_config[\"password\"] == \"mypass\"\n        expected_auth = base64.b64encode(b\"myuser:mypass\").decode()\n        assert registry_config[\"auth\"] == expected_auth\n\n    def test_image_with_port_uses_host_port_as_registry(self):\n        secret = build_image_pull_secret(\n            sandbox_id=\"sid\",\n            image_uri=\"localhost:5000/myimage:latest\",\n            auth=self._auth(),\n            owner_uid=\"uid\",\n            owner_api_version=\"v1alpha1\",\n            owner_kind=\"BatchSandbox\",\n        )\n        config = self._decode_docker_config(secret)\n        assert \"localhost:5000\" in config[\"auths\"]\n"
  },
  {
    "path": "server/tests/k8s/test_informer.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Unit tests for WorkloadInformer.\"\"\"\n\nimport time\nfrom unittest.mock import MagicMock\n\nfrom src.services.k8s.informer import WorkloadInformer\n\n\ndef _make_informer(**kwargs) -> WorkloadInformer:\n    \"\"\"Return a WorkloadInformer with a mocked list_fn (watch disabled).\"\"\"\n    list_fn = kwargs.pop(\"list_fn\", MagicMock(return_value={\"items\": [], \"metadata\": {}}))\n    return WorkloadInformer(list_fn=list_fn, enable_watch=False, **kwargs)\n\n\ndef _list_response(*names: str) -> dict:\n    \"\"\"Build a fake CustomObjects list API response.\"\"\"\n    return {\n        \"metadata\": {\"resourceVersion\": \"42\"},\n        \"items\": [{\"metadata\": {\"name\": n, \"resourceVersion\": \"1\"}} for n in names],\n    }\n\n\nclass TestWorkloadInformerInit:\n    \"\"\"Construction and property defaults.\"\"\"\n\n    def test_has_synced_is_false_before_start(self):\n        \"\"\"has_synced starts as False before the first list completes.\"\"\"\n        informer = _make_informer()\n        assert informer.has_synced is False\n\n    def test_get_returns_none_before_sync(self):\n        \"\"\"get() returns None before the cache is populated.\"\"\"\n        informer = _make_informer()\n        assert informer.get(\"anything\") is None\n\n    def test_resync_and_watch_params_stored(self):\n        \"\"\"Constructor stores resync and watch timeout parameters.\"\"\"\n        informer = _make_informer(resync_period_seconds=120, watch_timeout_seconds=30)\n        assert informer.resync_period_seconds == 120\n        assert informer.watch_timeout_seconds == 30\n\n    def test_custom_thread_name_is_stored(self):\n        \"\"\"thread_name parameter is stored and used when start() is called.\"\"\"\n        informer = _make_informer(thread_name=\"informer-foos-default\")\n        assert informer._thread_name == \"informer-foos-default\"\n\n    def test_default_thread_name(self):\n        \"\"\"Default thread_name is 'workload-informer' when not specified.\"\"\"\n        informer = _make_informer()\n        assert informer._thread_name == \"workload-informer\"\n\n\nclass TestWorkloadInformerFullResync:\n    \"\"\"_full_resync populates the cache correctly.\"\"\"\n\n    def test_full_resync_populates_cache(self):\n        \"\"\"After _full_resync, objects from list_fn are accessible via get().\"\"\"\n        list_fn = MagicMock(return_value=_list_response(\"alpha\", \"beta\"))\n        informer = _make_informer(list_fn=list_fn)\n        informer._full_resync()\n\n        assert informer.get(\"alpha\") is not None\n        assert informer.get(\"beta\") is not None\n        assert informer.get(\"gamma\") is None\n\n    def test_full_resync_sets_has_synced(self):\n        \"\"\"_full_resync marks the informer as synced.\"\"\"\n        list_fn = MagicMock(return_value=_list_response(\"x\"))\n        informer = _make_informer(list_fn=list_fn)\n        informer._full_resync()\n        assert informer.has_synced is True\n\n    def test_full_resync_stores_resource_version(self):\n        \"\"\"_full_resync saves the resourceVersion from the list metadata.\"\"\"\n        list_fn = MagicMock(return_value=_list_response(\"x\"))\n        informer = _make_informer(list_fn=list_fn)\n        informer._full_resync()\n        assert informer._resource_version == \"42\"\n\n    def test_full_resync_replaces_stale_cache(self):\n        \"\"\"A second _full_resync replaces the previous cache contents.\"\"\"\n        list_fn = MagicMock(return_value=_list_response(\"old\"))\n        informer = _make_informer(list_fn=list_fn)\n        informer._full_resync()\n        assert informer.get(\"old\") is not None\n\n        list_fn.return_value = _list_response(\"new\")\n        informer._full_resync()\n        assert informer.get(\"old\") is None\n        assert informer.get(\"new\") is not None\n\n\nclass TestWorkloadInformerUpdateCache:\n    \"\"\"update_cache upserts objects into the cache.\"\"\"\n\n    def test_update_cache_adds_new_object(self):\n        \"\"\"update_cache makes a previously missing object retrievable.\"\"\"\n        informer = _make_informer()\n        obj = {\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"5\"}}\n        informer.update_cache(obj)\n        assert informer.get(\"foo\") == obj\n\n    def test_update_cache_overwrites_existing_object(self):\n        \"\"\"update_cache replaces the cached version of an object.\"\"\"\n        informer = _make_informer()\n        informer.update_cache({\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"1\"}})\n        updated = {\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"2\"}}\n        informer.update_cache(updated)\n        assert informer.get(\"foo\") == updated\n\n    def test_update_cache_ignores_object_without_name(self):\n        \"\"\"update_cache silently ignores objects that lack a metadata.name.\"\"\"\n        informer = _make_informer()\n        informer.update_cache({\"metadata\": {}})\n        # Cache remains empty — no exception raised\n        assert informer._cache == {}\n\n    def test_update_cache_updates_resource_version(self):\n        \"\"\"update_cache advances _resource_version from object metadata.\"\"\"\n        informer = _make_informer()\n        informer.update_cache({\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"99\"}})\n        assert informer._resource_version == \"99\"\n\n    def test_update_cache_does_not_downgrade_resource_version(self):\n        \"\"\"update_cache never rolls back _resource_version to an older value.\"\"\"\n        informer = _make_informer()\n        informer._resource_version = \"200\"\n        informer.update_cache({\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"100\"}})\n        assert informer._resource_version == \"200\"\n\n    def test_update_cache_advances_resource_version_when_newer(self):\n        \"\"\"update_cache advances _resource_version when the incoming value is strictly newer.\"\"\"\n        informer = _make_informer()\n        informer._resource_version = \"50\"\n        informer.update_cache({\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"99\"}})\n        assert informer._resource_version == \"99\"\n\n\nclass TestWorkloadInformerHandleEvent:\n    \"\"\"_handle_event applies watch events to the cache.\"\"\"\n\n    def test_handle_added_event_inserts_object(self):\n        \"\"\"ADDED event inserts the object into the cache.\"\"\"\n        informer = _make_informer()\n        obj = {\"metadata\": {\"name\": \"bar\", \"resourceVersion\": \"10\"}}\n        informer._handle_event({\"type\": \"ADDED\", \"object\": obj})\n        assert informer.get(\"bar\") == obj\n\n    def test_handle_modified_event_replaces_object(self):\n        \"\"\"MODIFIED event replaces the cached object.\"\"\"\n        informer = _make_informer()\n        informer._cache[\"bar\"] = {\"metadata\": {\"name\": \"bar\", \"resourceVersion\": \"1\"}}\n        updated = {\"metadata\": {\"name\": \"bar\", \"resourceVersion\": \"2\"}}\n        informer._handle_event({\"type\": \"MODIFIED\", \"object\": updated})\n        assert informer.get(\"bar\") == updated\n\n    def test_handle_deleted_event_removes_object(self):\n        \"\"\"DELETED event removes the object from the cache.\"\"\"\n        informer = _make_informer()\n        informer._cache[\"bar\"] = {\"metadata\": {\"name\": \"bar\"}}\n        informer._handle_event({\"type\": \"DELETED\", \"object\": {\"metadata\": {\"name\": \"bar\"}}})\n        assert informer.get(\"bar\") is None\n\n    def test_handle_event_ignores_none_object(self):\n        \"\"\"Events with a None object are silently ignored.\"\"\"\n        informer = _make_informer()\n        informer._handle_event({\"type\": \"ADDED\", \"object\": None})\n        assert informer._cache == {}\n\n    def test_handle_event_ignores_object_without_name(self):\n        \"\"\"Events whose object has no metadata.name are silently ignored.\"\"\"\n        informer = _make_informer()\n        informer._handle_event({\"type\": \"ADDED\", \"object\": {\"metadata\": {}}})\n        assert informer._cache == {}\n\n    def test_handle_event_converts_non_dict_object(self):\n        \"\"\"Non-dict objects are converted via to_dict() before caching.\"\"\"\n        informer = _make_informer()\n        sdk_obj = MagicMock()\n        sdk_obj.to_dict.return_value = {\"metadata\": {\"name\": \"sdk-obj\", \"resourceVersion\": \"3\"}}\n        informer._handle_event({\"type\": \"ADDED\", \"object\": sdk_obj})\n        assert informer.get(\"sdk-obj\") is not None\n\n    def test_handle_event_updates_resource_version(self):\n        \"\"\"_handle_event advances _resource_version from the object metadata.\"\"\"\n        informer = _make_informer()\n        informer._handle_event({\n            \"type\": \"ADDED\",\n            \"object\": {\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"77\"}},\n        })\n        assert informer._resource_version == \"77\"\n\n    def test_handle_event_does_not_downgrade_resource_version(self):\n        \"\"\"_handle_event never rolls back _resource_version to an older value.\"\"\"\n        informer = _make_informer()\n        informer._resource_version = \"200\"\n        informer._handle_event({\n            \"type\": \"MODIFIED\",\n            \"object\": {\"metadata\": {\"name\": \"foo\", \"resourceVersion\": \"50\"}},\n        })\n        assert informer._resource_version == \"200\"\n\n\nclass TestWorkloadInformerStartStop:\n    \"\"\"start/stop thread lifecycle.\"\"\"\n\n    def test_start_launches_daemon_thread(self):\n        \"\"\"start() spawns a daemon thread that is alive.\"\"\"\n        list_fn = MagicMock(return_value={\"items\": [], \"metadata\": {}})\n        informer = WorkloadInformer(list_fn=list_fn, enable_watch=False,\n                                    resync_period_seconds=9999)\n        informer.start()\n        assert informer._thread is not None\n        assert informer._thread.is_alive()\n        informer.stop()\n\n    def test_start_is_idempotent(self):\n        \"\"\"Calling start() twice does not create a second thread.\"\"\"\n        list_fn = MagicMock(return_value={\"items\": [], \"metadata\": {}})\n        informer = WorkloadInformer(list_fn=list_fn, enable_watch=False,\n                                    resync_period_seconds=9999)\n        informer.start()\n        first_thread = informer._thread\n        informer.start()\n        assert informer._thread is first_thread\n        informer.stop()\n\n    def test_stop_signals_stop_event(self):\n        \"\"\"stop() sets the internal stop event.\"\"\"\n        informer = _make_informer()\n        informer.stop()\n        assert informer._stop_event.is_set()\n\n    def test_poll_mode_resets_has_synced_after_wait(self):\n        \"\"\"In poll mode (enable_watch=False), _has_synced is reset after each wait so the\n        cache is refreshed on the next loop iteration.\"\"\"\n        call_count = 0\n\n        def list_fn():\n            nonlocal call_count\n            call_count += 1\n            return {\"items\": [], \"metadata\": {\"resourceVersion\": str(call_count)}}\n\n        informer = WorkloadInformer(\n            list_fn=list_fn,\n            enable_watch=False,\n            resync_period_seconds=0,  # no wait, loop immediately\n        )\n        informer.start()\n\n        # Give the thread time to execute at least two full loops\n        deadline = time.monotonic() + 2.0\n        while call_count < 2 and time.monotonic() < deadline:\n            time.sleep(0.01)\n\n        informer.stop()\n        assert call_count >= 2, \"list_fn should be called more than once in poll mode\"\n"
  },
  {
    "path": "server/tests/k8s/test_k8s_client.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for K8sClient.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch\n\nfrom kubernetes.client import ApiException\n\nfrom src.config import KubernetesRuntimeConfig\nfrom src.services.k8s.client import K8sClient\n\n\nclass TestK8sClient:\n    \"\"\"K8sClient unit tests\"\"\"\n    \n    def test_init_with_kubeconfig_loads_successfully(self, k8s_runtime_config):\n        \"\"\"Verify successful initialization with kubeconfig path.\"\"\"\n        with patch('kubernetes.config.load_kube_config') as mock_load:\n            client = K8sClient(k8s_runtime_config)\n\n            assert client.config == k8s_runtime_config\n            mock_load.assert_called_once_with(\n                config_file=k8s_runtime_config.kubeconfig_path\n            )\n\n    def test_init_with_incluster_config_loads_successfully(self):\n        \"\"\"Verify successful initialization with in-cluster config.\"\"\"\n        config = KubernetesRuntimeConfig(\n            kubeconfig_path=None,\n            namespace=\"test-ns\"\n        )\n\n        with patch('kubernetes.config.load_incluster_config') as mock_load:\n            client = K8sClient(config)\n\n            assert client.config == config\n            mock_load.assert_called_once()\n\n    def test_init_with_invalid_kubeconfig_raises_exception(self):\n        \"\"\"Verify exception raised with invalid config file.\"\"\"\n        config = KubernetesRuntimeConfig(\n            kubeconfig_path=\"/invalid/path\",\n            namespace=\"test-ns\"\n        )\n\n        with patch('kubernetes.config.load_kube_config') as mock_load:\n            mock_load.side_effect = Exception(\"Config file not found\")\n\n            with pytest.raises(Exception) as exc_info:\n                K8sClient(config)\n\n            assert \"Failed to load Kubernetes configuration\" in str(exc_info.value)\n\n    def test_get_core_v1_api_returns_singleton(self, k8s_runtime_config):\n        \"\"\"Verify CoreV1Api returns singleton.\"\"\"\n        with patch('kubernetes.config.load_kube_config'), \\\n             patch('kubernetes.client.CoreV1Api') as mock_api_class:\n\n            mock_api_instance = MagicMock()\n            mock_api_class.return_value = mock_api_instance\n\n            client = K8sClient(k8s_runtime_config)\n\n            api1 = client.get_core_v1_api()\n            api2 = client.get_core_v1_api()\n\n            assert api1 is api2\n            assert mock_api_class.call_count == 1\n\n    def test_get_custom_objects_api_returns_singleton(self, k8s_runtime_config):\n        \"\"\"Verify CustomObjectsApi returns singleton.\"\"\"\n        with patch('kubernetes.config.load_kube_config'), \\\n             patch('kubernetes.client.CustomObjectsApi') as mock_api_class:\n\n            mock_api_instance = MagicMock()\n            mock_api_class.return_value = mock_api_instance\n\n            client = K8sClient(k8s_runtime_config)\n\n            api1 = client.get_custom_objects_api()\n            api2 = client.get_custom_objects_api()\n\n            assert api1 is api2\n            assert mock_api_class.call_count == 1\n    \n    def test_get_core_v1_api_creates_on_first_call(self, k8s_runtime_config):\n        \"\"\"Verify API client is created on first call, not at init time.\"\"\"\n        with patch('kubernetes.config.load_kube_config'), \\\n             patch('kubernetes.client.CoreV1Api') as mock_api_class:\n\n            client = K8sClient(k8s_runtime_config)\n\n            assert mock_api_class.call_count == 0\n            client.get_core_v1_api()\n            assert mock_api_class.call_count == 1\n\n    # ------------------------------------------------------------------\n    # Rate limiter initialization\n    # ------------------------------------------------------------------\n\n    def test_no_rate_limiters_when_qps_is_zero(self, k8s_runtime_config):\n        \"\"\"read_qps=0 and write_qps=0 means no rate limiters are created.\"\"\"\n        with patch('kubernetes.config.load_kube_config'):\n            client = K8sClient(k8s_runtime_config)\n            assert client._read_limiter is None\n            assert client._write_limiter is None\n\n    def test_read_limiter_created_when_read_qps_set(self):\n        \"\"\"read_qps > 0 creates a read rate limiter.\"\"\"\n        config = KubernetesRuntimeConfig(read_qps=10.0, read_burst=20)\n        with patch('kubernetes.config.load_incluster_config'):\n            client = K8sClient(config)\n            assert client._read_limiter is not None\n            assert client._write_limiter is None\n\n    def test_write_limiter_created_when_write_qps_set(self):\n        \"\"\"write_qps > 0 creates a write rate limiter.\"\"\"\n        config = KubernetesRuntimeConfig(write_qps=5.0, write_burst=10)\n        with patch('kubernetes.config.load_incluster_config'):\n            client = K8sClient(config)\n            assert client._read_limiter is None\n            assert client._write_limiter is not None\n\n    # ------------------------------------------------------------------\n    # CustomObject CRUD\n    # ------------------------------------------------------------------\n\n    def _make_client(self, k8s_runtime_config):\n        \"\"\"Return a K8sClient with mocked kubeconfig and raw API handles.\"\"\"\n        with patch('kubernetes.config.load_kube_config'):\n            c = K8sClient(k8s_runtime_config)\n        c._custom_objects_api = MagicMock()\n        c._core_v1_api = MagicMock()\n        c._node_v1_api = MagicMock()\n        return c\n\n    def test_create_custom_object_delegates_to_api(self, k8s_runtime_config):\n        \"\"\"create_custom_object forwards arguments to the raw API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        body = {\"metadata\": {\"name\": \"foo\"}}\n        c.create_custom_object(\"g\", \"v1\", \"ns\", \"foos\", body)\n        c._custom_objects_api.create_namespaced_custom_object.assert_called_once_with(\n            group=\"g\", version=\"v1\", namespace=\"ns\", plural=\"foos\", body=body\n        )\n\n    def test_get_custom_object_returns_none_on_404(self, k8s_runtime_config):\n        \"\"\"get_custom_object returns None when the API raises a 404.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.get_namespaced_custom_object.side_effect = ApiException(status=404)\n        result = c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        assert result is None\n\n    def test_get_custom_object_returns_object(self, k8s_runtime_config):\n        \"\"\"get_custom_object returns the object from the API on a successful call.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        obj = {\"metadata\": {\"name\": \"foo-1\"}}\n        c._custom_objects_api.get_namespaced_custom_object.return_value = obj\n        result = c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        assert result == obj\n\n    def test_get_custom_object_updates_informer_cache_on_api_hit(self, k8s_runtime_config):\n        \"\"\"get_custom_object calls informer.update_cache with the returned object.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        obj = {\"metadata\": {\"name\": \"foo-1\", \"resourceVersion\": \"10\"}}\n        c._custom_objects_api.get_namespaced_custom_object.return_value = obj\n        fake_informer = MagicMock()\n        fake_informer.has_synced = False\n        c._informers[(\"g\", \"v1\", \"foos\", \"ns\")] = fake_informer\n        c.config = MagicMock(informer_enabled=True,\n                             informer_resync_seconds=300,\n                             informer_watch_timeout_seconds=60,\n                             read_qps=0.0, write_qps=0.0)\n        c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        fake_informer.update_cache.assert_called_once_with(obj)\n\n    def test_get_custom_object_reraises_non_404(self, k8s_runtime_config):\n        \"\"\"get_custom_object re-raises non-404 API exceptions.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.get_namespaced_custom_object.side_effect = ApiException(status=500)\n        with pytest.raises(ApiException):\n            c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n\n    def test_get_custom_object_returns_cached_when_synced(self, k8s_runtime_config):\n        \"\"\"get_custom_object returns cached value and skips API when informer is synced.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        cached_obj = {\"metadata\": {\"name\": \"foo-1\"}}\n        fake_informer = MagicMock()\n        fake_informer.has_synced = True\n        fake_informer.get.return_value = cached_obj\n        c._informers[(\"g\", \"v1\", \"foos\", \"ns\")] = fake_informer\n        # Disable real informer creation\n        c.config = MagicMock(informer_enabled=True,\n                             informer_resync_seconds=300,\n                             informer_watch_timeout_seconds=60,\n                             read_qps=0.0, write_qps=0.0)\n\n        result = c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n\n        assert result is cached_obj\n        c._custom_objects_api.get_namespaced_custom_object.assert_not_called()\n\n    def test_get_custom_object_skips_informer_when_disabled(self, k8s_runtime_config):\n        \"\"\"get_custom_object bypasses informer and calls API when informer_enabled=False.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c.config = MagicMock(informer_enabled=False, read_qps=0.0)\n        obj = {\"metadata\": {\"name\": \"foo-1\"}}\n        c._custom_objects_api.get_namespaced_custom_object.return_value = obj\n        result = c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        assert result == obj\n        c._custom_objects_api.get_namespaced_custom_object.assert_called_once()\n\n    def test_list_custom_objects_returns_items(self, k8s_runtime_config):\n        \"\"\"list_custom_objects returns the items list from the API response.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.list_namespaced_custom_object.return_value = {\n            \"items\": [{\"metadata\": {\"name\": \"a\"}}, {\"metadata\": {\"name\": \"b\"}}]\n        }\n        result = c.list_custom_objects(\"g\", \"v1\", \"ns\", \"foos\")\n        assert len(result) == 2\n\n    def test_list_custom_objects_returns_empty_on_404(self, k8s_runtime_config):\n        \"\"\"list_custom_objects returns [] when the API raises a 404.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.list_namespaced_custom_object.side_effect = ApiException(status=404)\n        result = c.list_custom_objects(\"g\", \"v1\", \"ns\", \"foos\")\n        assert result == []\n\n    def test_list_custom_objects_reraises_non_404(self, k8s_runtime_config):\n        \"\"\"list_custom_objects re-raises non-404 API exceptions.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.list_namespaced_custom_object.side_effect = ApiException(status=500)\n        with pytest.raises(ApiException):\n            c.list_custom_objects(\"g\", \"v1\", \"ns\", \"foos\")\n\n    def test_delete_custom_object_delegates_to_api(self, k8s_runtime_config):\n        \"\"\"delete_custom_object forwards arguments to the raw API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c.delete_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\", grace_period_seconds=0)\n        c._custom_objects_api.delete_namespaced_custom_object.assert_called_once_with(\n            group=\"g\", version=\"v1\", namespace=\"ns\", plural=\"foos\",\n            name=\"foo-1\", grace_period_seconds=0\n        )\n\n    def test_patch_custom_object_delegates_to_api(self, k8s_runtime_config):\n        \"\"\"patch_custom_object forwards arguments to the raw API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        body = {\"spec\": {\"replicas\": 2}}\n        c.patch_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\", body)\n        c._custom_objects_api.patch_namespaced_custom_object.assert_called_once_with(\n            group=\"g\", version=\"v1\", namespace=\"ns\", plural=\"foos\",\n            name=\"foo-1\", body=body\n        )\n\n    # ------------------------------------------------------------------\n    # Secret / Pod / RuntimeClass\n    # ------------------------------------------------------------------\n\n    def test_create_secret_delegates_to_api(self, k8s_runtime_config):\n        \"\"\"create_secret forwards to CoreV1Api.create_namespaced_secret.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        body = {\"metadata\": {\"name\": \"my-secret\"}}\n        c.create_secret(\"ns\", body)\n        c._core_v1_api.create_namespaced_secret.assert_called_once_with(\n            namespace=\"ns\", body=body\n        )\n\n    def test_list_pods_returns_items(self, k8s_runtime_config):\n        \"\"\"list_pods returns the items attribute from the API response.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_pod = MagicMock()\n        c._core_v1_api.list_namespaced_pod.return_value = MagicMock(items=[mock_pod])\n        result = c.list_pods(\"ns\", label_selector=\"app=foo\")\n        assert result == [mock_pod]\n        c._core_v1_api.list_namespaced_pod.assert_called_once_with(\n            namespace=\"ns\", label_selector=\"app=foo\"\n        )\n\n    def test_list_pods_returns_empty_list_on_exception(self, k8s_runtime_config):\n        \"\"\"list_pods re-raises exceptions from the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._core_v1_api.list_namespaced_pod.side_effect = Exception(\"network error\")\n        with pytest.raises(Exception, match=\"network error\"):\n            c.list_pods(\"ns\")\n\n    def test_read_runtime_class_delegates_to_api(self, k8s_runtime_config):\n        \"\"\"read_runtime_class forwards to NodeV1Api.read_runtime_class.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._node_v1_api.read_runtime_class.return_value = MagicMock(metadata=MagicMock(name=\"gvisor\"))\n        result = c.read_runtime_class(\"gvisor\")\n        c._node_v1_api.read_runtime_class.assert_called_once_with(\"gvisor\")\n        assert result is not None\n\n    # ------------------------------------------------------------------\n    # Write limiter integration\n    # ------------------------------------------------------------------\n\n    def test_write_limiter_called_on_create(self, k8s_runtime_config):\n        \"\"\"create_custom_object acquires the write limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_limiter = MagicMock()\n        c._write_limiter = mock_limiter\n        c.create_custom_object(\"g\", \"v1\", \"ns\", \"foos\", {})\n        mock_limiter.acquire.assert_called_once()\n\n    def test_write_limiter_called_on_delete(self, k8s_runtime_config):\n        \"\"\"delete_custom_object acquires the write limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_limiter = MagicMock()\n        c._write_limiter = mock_limiter\n        c.delete_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        mock_limiter.acquire.assert_called_once()\n\n    def test_write_limiter_called_on_patch(self, k8s_runtime_config):\n        \"\"\"patch_custom_object acquires the write limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_limiter = MagicMock()\n        c._write_limiter = mock_limiter\n        c.patch_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\", {})\n        mock_limiter.acquire.assert_called_once()\n\n    def test_write_limiter_called_on_create_secret(self, k8s_runtime_config):\n        \"\"\"create_secret acquires the write limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_limiter = MagicMock()\n        c._write_limiter = mock_limiter\n        c.create_secret(\"ns\", {})\n        mock_limiter.acquire.assert_called_once()\n\n    def test_read_limiter_called_on_get(self, k8s_runtime_config):\n        \"\"\"get_custom_object acquires the read limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c.config = MagicMock(informer_enabled=False, read_qps=0.0)\n        c._custom_objects_api.get_namespaced_custom_object.return_value = {}\n        mock_limiter = MagicMock()\n        c._read_limiter = mock_limiter\n        c.get_custom_object(\"g\", \"v1\", \"ns\", \"foos\", \"foo-1\")\n        mock_limiter.acquire.assert_called_once()\n\n    def test_read_limiter_called_on_list(self, k8s_runtime_config):\n        \"\"\"list_custom_objects acquires the read limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._custom_objects_api.list_namespaced_custom_object.return_value = {\"items\": []}\n        mock_limiter = MagicMock()\n        c._read_limiter = mock_limiter\n        c.list_custom_objects(\"g\", \"v1\", \"ns\", \"foos\")\n        mock_limiter.acquire.assert_called_once()\n\n    def test_read_limiter_called_on_list_pods(self, k8s_runtime_config):\n        \"\"\"list_pods acquires the read limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        c._core_v1_api.list_namespaced_pod.return_value = MagicMock(items=[])\n        mock_limiter = MagicMock()\n        c._read_limiter = mock_limiter\n        c.list_pods(\"ns\")\n        mock_limiter.acquire.assert_called_once()\n\n    def test_read_limiter_called_on_read_runtime_class(self, k8s_runtime_config):\n        \"\"\"read_runtime_class acquires the read limiter before calling the API.\"\"\"\n        c = self._make_client(k8s_runtime_config)\n        mock_limiter = MagicMock()\n        c._read_limiter = mock_limiter\n        c.read_runtime_class(\"gvisor\")\n        mock_limiter.acquire.assert_called_once()\n"
  },
  {
    "path": "server/tests/k8s/test_kubernetes_service.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for KubernetesSandboxService.\n\"\"\"\n\nimport pytest\nfrom datetime import datetime, timedelta, timezone\nfrom unittest.mock import MagicMock, patch\nfrom fastapi import HTTPException\n\nfrom src.services.k8s.kubernetes_service import KubernetesSandboxService\nfrom src.services.constants import (\n    OPEN_SANDBOX_EGRESS_AUTH_HEADER,\n    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,\n    SANDBOX_MANUAL_CLEANUP_LABEL,\n    SandboxErrorCodes,\n)\nfrom src.api.schema import ImageAuth, ListSandboxesRequest, NetworkPolicy\nfrom src.config import EGRESS_MODE_DNS, EGRESS_MODE_DNS_NFT, EgressConfig\nfrom src.api.schema import Endpoint\n\n\nclass TestKubernetesSandboxServiceInit:\n    \"\"\"KubernetesSandboxService initialization tests\"\"\"\n    \n    def test_init_with_valid_config_succeeds(self, k8s_app_config):\n        \"\"\"\n        Test case: Successful initialization with valid config\n        \n        Purpose: Verify that service can be successfully initialized with valid Kubernetes config\n        \"\"\"\n        with patch('src.services.k8s.kubernetes_service.K8sClient') as mock_k8s_client, \\\n             patch('src.services.k8s.kubernetes_service.create_workload_provider') as mock_create_provider:\n            \n            mock_provider = MagicMock()\n            mock_create_provider.return_value = mock_provider\n            \n            service = KubernetesSandboxService(k8s_app_config)\n            \n            assert service.namespace == k8s_app_config.kubernetes.namespace\n            assert service.execd_image == k8s_app_config.runtime.execd_image\n            mock_k8s_client.assert_called_once_with(k8s_app_config.kubernetes)\n            mock_create_provider.assert_called_once()\n    \n    def test_init_without_kubernetes_config_raises_error(self, app_config_no_k8s):\n        \"\"\"\n        Test case: Raises exception when Kubernetes config is missing\n        \n        Purpose: Verify that ValueError is raised when kubernetes section is missing from config\n        \"\"\"\n        # app_config_no_k8s still has kubernetes config, just without kubeconfig\n        # This will cause K8sClient initialization to fail and raise HTTPException\n        with pytest.raises(HTTPException) as exc_info:\n            KubernetesSandboxService(app_config_no_k8s)\n        \n        assert exc_info.value.status_code == 503\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.K8S_INITIALIZATION_ERROR\n    \n    def test_init_with_wrong_runtime_type_raises_error(self, app_config_docker):\n        \"\"\"\n        Test case: Raises exception with wrong runtime type\n        \n        Purpose: Verify that ValueError is raised when runtime.type is not 'kubernetes'\n        \"\"\"\n        with pytest.raises(ValueError, match=\"requires runtime.type = 'kubernetes'\"):\n            KubernetesSandboxService(app_config_docker)\n    \n    def test_init_with_k8s_client_failure_raises_http_exception(self, k8s_app_config):\n        \"\"\"\n        Test case: Raises HTTPException when K8sClient initialization fails\n        \n        Purpose: Verify that correct HTTPException is raised when K8sClient initialization fails\n        \"\"\"\n        with patch('src.services.k8s.kubernetes_service.K8sClient') as mock_k8s_client:\n            mock_k8s_client.side_effect = Exception(\"Failed to load kubeconfig\")\n            \n            with pytest.raises(HTTPException) as exc_info:\n                KubernetesSandboxService(k8s_app_config)\n            \n            assert exc_info.value.status_code == 503\n            assert \"code\" in exc_info.value.detail\n            assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.K8S_INITIALIZATION_ERROR\n\n\nclass TestKubernetesSandboxServiceCreate:\n    \"\"\"KubernetesSandboxService create_sandbox tests\"\"\"\n    \n    @pytest.mark.asyncio\n    async def test_create_sandbox_with_valid_request_succeeds(\n        self, k8s_service, create_sandbox_request, mock_workload\n    ):\n        \"\"\"\n        Test case: Successfully create sandbox with valid request\n        \n        Purpose: Verify that sandbox can be successfully created with valid CreateSandboxRequest\n        \"\"\"\n        # Mock workload provider\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-sandbox-123\",\n            \"uid\": \"abc-123\",\n        }\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Pod is running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = \"10.244.0.5:8080\"\n        k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)\n        \n        response = await k8s_service.create_sandbox(create_sandbox_request)\n        \n        # CreateSandboxResponse uses 'id' field\n        assert response.id is not None\n        assert response.status.state == \"Running\"\n        k8s_service.workload_provider.create_workload.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_uses_configured_timeout_and_poll_interval(\n        self, k8s_service, create_sandbox_request, mock_workload\n    ):\n        \"\"\"\n        Test case: create_sandbox uses timeout and poll_interval from config\n\n        Purpose: Verify that sandbox_create_timeout_seconds and\n        sandbox_create_poll_interval_seconds are read from KubernetesRuntimeConfig\n        and forwarded to _wait_for_sandbox_ready.\n        \"\"\"\n\n\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-sandbox-123\",\n            \"uid\": \"abc-123\",\n        }\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Pod is running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n\n        # Override config values\n        k8s_service.app_config.kubernetes.sandbox_create_timeout_seconds = 120\n        k8s_service.app_config.kubernetes.sandbox_create_poll_interval_seconds = 0.5\n\n        with patch.object(k8s_service, \"_wait_for_sandbox_ready\", wraps=k8s_service._wait_for_sandbox_ready) as mock_wait:\n            await k8s_service.create_sandbox(create_sandbox_request)\n\n        mock_wait.assert_called_once()\n        _, kwargs = mock_wait.call_args\n        assert kwargs[\"timeout_seconds\"] == 120\n        assert kwargs[\"poll_interval_seconds\"] == 0.5\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_rejects_image_auth_when_provider_not_supported(\n        self, k8s_service, create_sandbox_request\n    ):\n        k8s_service.workload_provider.supports_image_auth.return_value = False\n        create_sandbox_request.image.auth = ImageAuth(\n            username=\"registry-user\",\n            password=\"registry-pass\",\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            await k8s_service.create_sandbox(create_sandbox_request)\n\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n        k8s_service.workload_provider.create_workload.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_allows_image_auth_when_provider_supported(\n        self, k8s_service, create_sandbox_request\n    ):\n        k8s_service.workload_provider.supports_image_auth.return_value = True\n        create_sandbox_request.image.auth = ImageAuth(\n            username=\"registry-user\",\n            password=\"registry-pass\",\n        )\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-id\", \"uid\": \"uid-1\"\n        }\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\", \"reason\": \"\", \"message\": \"\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n\n        # Should not raise\n        await k8s_service.create_sandbox(create_sandbox_request)\n        k8s_service.workload_provider.create_workload.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_with_no_timeout_calls_provider_with_expires_at_none_and_manual_cleanup_label(\n        self, k8s_service, create_sandbox_request\n    ):\n        \"\"\"When timeout is None (manual cleanup), provider receives expires_at=None and manual-cleanup label.\"\"\"\n        create_sandbox_request.timeout = None\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-id\", \"uid\": \"uid-1\"\n        }\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\", \"reason\": \"\", \"message\": \"\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n\n        await k8s_service.create_sandbox(create_sandbox_request)\n\n        k8s_service.workload_provider.create_workload.assert_called_once()\n        _, kwargs = k8s_service.workload_provider.create_workload.call_args\n        assert kwargs[\"expires_at\"] is None\n        assert kwargs[\"labels\"].get(SANDBOX_MANUAL_CLEANUP_LABEL) == \"true\"\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_with_network_policy_passes_egress_token_and_annotations(\n        self, k8s_service, create_sandbox_request\n    ):\n        create_sandbox_request.network_policy = NetworkPolicy(default_action=\"deny\", egress=[])\n        k8s_service.app_config.egress = EgressConfig(image=\"opensandbox/egress:v1.0.3\")\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-id\", \"uid\": \"uid-1\"\n        }\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\", \"reason\": \"\", \"message\": \"\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n\n        with patch(\n            \"src.services.k8s.kubernetes_service.generate_egress_token\",\n            return_value=\"egress-token\",\n        ):\n            await k8s_service.create_sandbox(create_sandbox_request)\n\n        _, kwargs = k8s_service.workload_provider.create_workload.call_args\n        assert kwargs[\"egress_auth_token\"] == \"egress-token\"\n        assert kwargs[\"egress_mode\"] == EGRESS_MODE_DNS\n        assert kwargs[\"annotations\"][SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == \"egress-token\"\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_with_network_policy_passes_egress_mode_dns_nft_from_config(\n        self, k8s_service, create_sandbox_request\n    ):\n        create_sandbox_request.network_policy = NetworkPolicy(default_action=\"deny\", egress=[])\n        k8s_service.app_config.egress = EgressConfig(\n            image=\"opensandbox/egress:v1.0.3\",\n            mode=EGRESS_MODE_DNS_NFT,\n        )\n        k8s_service.workload_provider.create_workload.return_value = {\n            \"name\": \"test-id\", \"uid\": \"uid-1\"\n        }\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\", \"reason\": \"\", \"message\": \"\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n\n        with patch(\n            \"src.services.k8s.kubernetes_service.generate_egress_token\",\n            return_value=\"egress-token\",\n        ):\n            await k8s_service.create_sandbox(create_sandbox_request)\n\n        _, kwargs = k8s_service.workload_provider.create_workload.call_args\n        assert kwargs[\"egress_mode\"] == EGRESS_MODE_DNS_NFT\n\n    def test_get_endpoint_merges_egress_auth_header_from_instance_metadata(\n        self, k8s_service\n    ):\n        k8s_service.workload_provider.get_workload.return_value = {\n            \"metadata\": {\n                \"annotations\": {\n                    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\",\n                }\n            }\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = Endpoint(\n            endpoint=\"gateway.example.com\",\n            headers={\"OpenSandbox-Ingress-To\": \"sbx-123-44772\"},\n        )\n\n        endpoint = k8s_service.get_endpoint(\"sbx-123\", 44772)\n\n        assert endpoint.endpoint == \"gateway.example.com\"\n        assert endpoint.headers == {\n            \"OpenSandbox-Ingress-To\": \"sbx-123-44772\",\n            OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\",\n        }\n\n    @pytest.mark.asyncio\n    async def test_create_sandbox_rejects_timeout_above_configured_maximum(\n        self, k8s_service, create_sandbox_request\n    ):\n        k8s_service.app_config.server.max_sandbox_timeout_seconds = 3600\n        create_sandbox_request.timeout = 7200\n\n        with pytest.raises(HTTPException) as exc_info:\n            await k8s_service.create_sandbox(create_sandbox_request)\n\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n        assert \"configured maximum of 3600s\" in exc_info.value.detail[\"message\"]\n        k8s_service.workload_provider.create_workload.assert_not_called()\n\n\nclass TestWaitForSandboxReady:\n    \"\"\"_wait_for_sandbox_ready method tests\"\"\"\n    \n    @pytest.mark.asyncio\n    async def test_wait_for_running_pod_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully wait for Running Pod\n        \n        Purpose: Verify that it returns immediately when Pod enters Running state\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Pod is running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        \n        result = await k8s_service._wait_for_sandbox_ready(\"test-sandbox-id\", timeout_seconds=10)\n        \n        assert result == mock_workload\n    \n    @pytest.mark.asyncio\n    async def test_wait_for_pending_then_running_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully wait from Pending to Allocated to Running\n        \n        Purpose: Verify normal waiting when Pod transitions through Pending -> Allocated -> Running\n        \"\"\"\n        # Mock state transition: Pending -> Allocated -> Running\n        status_sequence = [\n            {\"state\": \"Pending\", \"reason\": \"\", \"message\": \"Pending\", \"last_transition_at\": datetime.now(timezone.utc)},\n            {\"state\": \"Allocated\", \"reason\": \"IP_ASSIGNED\", \"message\": \"IP assigned\", \"last_transition_at\": datetime.now(timezone.utc)},\n            {\"state\": \"Running\", \"reason\": \"\", \"message\": \"Running\", \"last_transition_at\": datetime.now(timezone.utc)},\n        ]\n        \n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.side_effect = status_sequence\n        \n        result = await k8s_service._wait_for_sandbox_ready(\"test-sandbox-id\", timeout_seconds=10, poll_interval_seconds=0.1)\n        \n        assert result == mock_workload\n        assert k8s_service.workload_provider.get_status.call_count == 2\n    \n    @pytest.mark.asyncio\n    async def test_wait_for_allocated_pod_returns_immediately(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Returns immediately when Pod reaches Allocated state (IP assigned)\n        \n        Purpose: Verify that Allocated state (IP assigned) is treated as ready\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Allocated\",\n            \"reason\": \"IP_ASSIGNED\",\n            \"message\": \"Pod has IP assigned\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        \n        result = await k8s_service._wait_for_sandbox_ready(\"test-sandbox-id\", timeout_seconds=10)\n        \n        assert result == mock_workload\n    \n    @pytest.mark.asyncio\n    async def test_wait_timeout_raises_exception(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Raises exception on wait timeout\n        \n        Purpose: Verify that HTTPException is raised when wait times out\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Pending\",\n            \"reason\": \"\",\n            \"message\": \"Still pending\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        \n        with pytest.raises(HTTPException) as exc_info:\n            await k8s_service._wait_for_sandbox_ready(\"test-sandbox-id\", timeout_seconds=1, poll_interval_seconds=0.5)\n        \n        assert exc_info.value.status_code == 504  # Gateway Timeout\n        assert \"timeout\" in exc_info.value.detail[\"message\"].lower()\n\n\nclass TestKubernetesSandboxServiceRenew:\n    def test_renew_expiration_rejects_manual_cleanup_sandbox(self, k8s_service):\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_expiration.return_value = None\n        request = MagicMock(expires_at=datetime.now(timezone.utc) + timedelta(hours=1))\n\n        with pytest.raises(HTTPException) as exc_info:\n            k8s_service.renew_expiration(\"test-sandbox-id\", request)\n\n        assert exc_info.value.status_code == 409\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_EXPIRATION\n        assert (\n            exc_info.value.detail[\"message\"]\n            == \"Sandbox test-sandbox-id does not have automatic expiration enabled.\"\n        )\n\n\nclass TestGetSandbox:\n    \"\"\"get_sandbox method tests\"\"\"\n    \n    def test_get_existing_sandbox_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully get existing sandbox\n        \n        Purpose: Verify that existing sandbox details can be successfully retrieved\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = \"10.0.0.1:8080\"\n        k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)\n        \n        # Use sandbox_id from mock_workload\n        sandbox = k8s_service.get_sandbox(\"test-sandbox-123\")\n        \n        # Sandbox uses 'id' field\n        assert sandbox.id == \"test-sandbox-123\"\n        assert sandbox.status.state == \"Running\"\n    \n    def test_get_nonexistent_sandbox_raises_404(self, k8s_service):\n        \"\"\"\n        Test case: Raises 404 for nonexistent sandbox\n        \n        Purpose: Verify that 404 exception is raised when getting nonexistent sandbox\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = None\n        \n        with pytest.raises(HTTPException) as exc_info:\n            k8s_service.get_sandbox(\"nonexistent-id\")\n        \n        assert exc_info.value.status_code == 404\n        assert \"not found\" in exc_info.value.detail[\"message\"].lower()\n\n\nclass TestDeleteSandbox:\n    \"\"\"delete_sandbox method tests\"\"\"\n    \n    def test_delete_existing_sandbox_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully delete existing sandbox\n        \n        Purpose: Verify that existing sandbox can be successfully deleted\n        \"\"\"\n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.delete_workload.return_value = None\n        \n        k8s_service.delete_sandbox(\"test-sandbox-id\")\n        \n        k8s_service.workload_provider.delete_workload.assert_called_once_with(\n            sandbox_id=\"test-sandbox-id\",\n            namespace=k8s_service.namespace\n        )\n    \n    def test_delete_nonexistent_sandbox_raises_404(self, k8s_service):\n        \"\"\"\n        Test case: Raises 404 when deleting nonexistent sandbox\n        \n        Purpose: Verify that 404 exception is raised when deleting nonexistent sandbox\n        \"\"\"\n        # Mock delete_workload to raise exception containing \"not found\"\n        k8s_service.workload_provider.delete_workload.side_effect = Exception(\"Sandbox not found\")\n        \n        with pytest.raises(HTTPException) as exc_info:\n            k8s_service.delete_sandbox(\"nonexistent-id\")\n        \n        assert exc_info.value.status_code == 404\n\n\nclass TestListSandboxes:\n    \"\"\"list_sandboxes method tests\"\"\"\n    \n    def test_list_all_sandboxes_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully list all sandboxes\n        \n        Purpose: Verify that all sandboxes can be successfully listed\n        \"\"\"\n        k8s_service.workload_provider.list_workloads.return_value = [mock_workload]\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = \"10.0.0.1:8080\"\n        k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)\n        \n        from src.api.schema import PaginationRequest\n        request = ListSandboxesRequest(pagination=PaginationRequest(page=1, page_size=20))\n        response = k8s_service.list_sandboxes(request)\n        \n        # Sandbox in items uses 'id' field\n        assert len(response.items) == 1\n        assert response.items[0].id == \"test-sandbox-123\"\n        assert response.pagination.total_items == 1\n    \n    def test_list_sandboxes_with_pagination(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: List sandboxes with pagination\n        \n        Purpose: Verify that pagination functionality works correctly\n        \"\"\"\n        # Create multiple mock workloads using mock_workload as template\n        workloads = []\n        for i in range(10):\n            workload = {\n                \"metadata\": {\n                    \"name\": f\"sandbox-{i}\",\n                    \"uid\": f\"uid-{i}\",\n                    \"labels\": {\n                        \"opensandbox.io/id\": f\"sandbox-{i}\",\n                    },\n                    \"annotations\": mock_workload[\"metadata\"][\"annotations\"].copy(),\n                    \"creationTimestamp\": datetime.now(timezone.utc).isoformat(),\n                },\n                \"spec\": {},\n                \"status\": {},\n            }\n            workloads.append(workload)\n        \n        k8s_service.workload_provider.list_workloads.return_value = workloads\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = \"10.0.0.1:8080\"\n        k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)\n        \n        from src.api.schema import PaginationRequest\n        request = ListSandboxesRequest(pagination=PaginationRequest(page=1, page_size=5))\n        response = k8s_service.list_sandboxes(request)\n        \n        assert len(response.items) == 5\n        assert response.pagination.page == 1\n        assert response.pagination.page_size == 5\n        assert response.pagination.total_items == 10\n        assert response.pagination.total_pages == 2\n    \n    def test_list_sandboxes_sorted_by_creation_time(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Verify sandboxes are sorted by creation time (newest first)\n        \n        Purpose: Verify that list_sandboxes returns sandboxes sorted by created_at in descending order\n        \"\"\"\n        # Create workloads with different creation times\n        base_time = datetime.now(timezone.utc)\n        workloads = []\n        \n        # Create sandboxes with specific creation times\n        # We'll create them in random order to verify sorting works\n        creation_times = [\n            base_time - timedelta(hours=5),  # Oldest\n            base_time - timedelta(hours=2),\n            base_time - timedelta(hours=1),\n            base_time - timedelta(minutes=30),\n            base_time,  # Newest\n        ]\n        \n        for i, created_at in enumerate(creation_times):\n            workload = {\n                \"metadata\": {\n                    \"name\": f\"sandbox-{i}\",\n                    \"uid\": f\"uid-{i}\",\n                    \"labels\": {\n                        \"opensandbox.io/id\": f\"sandbox-{i}\",\n                    },\n                    \"annotations\": mock_workload[\"metadata\"][\"annotations\"].copy(),\n                    \"creationTimestamp\": created_at.isoformat(),\n                },\n                \"spec\": {},\n                \"status\": {},\n            }\n            workloads.append(workload)\n        \n        k8s_service.workload_provider.list_workloads.return_value = workloads\n        k8s_service.workload_provider.get_status.return_value = {\n            \"state\": \"Running\",\n            \"reason\": \"\",\n            \"message\": \"Running\",\n            \"last_transition_at\": datetime.now(timezone.utc),\n        }\n        k8s_service.workload_provider.get_endpoint_info.return_value = \"10.0.0.1:8080\"\n        k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)\n        \n        from src.api.schema import PaginationRequest\n        request = ListSandboxesRequest(pagination=PaginationRequest(page=1, page_size=10))\n        response = k8s_service.list_sandboxes(request)\n        \n        # Verify all items are returned\n        assert len(response.items) == 5\n        \n        # Verify they are sorted by creation time (newest first)\n        # The order should be: index 4 (newest), 3, 2, 1, 0 (oldest)\n        assert response.items[0].id == \"sandbox-4\"  # Newest\n        assert response.items[1].id == \"sandbox-3\"\n        assert response.items[2].id == \"sandbox-2\"\n        assert response.items[3].id == \"sandbox-1\"\n        assert response.items[4].id == \"sandbox-0\"  # Oldest\n        \n        # Also verify the creation times are in descending order\n        for i in range(len(response.items) - 1):\n            assert response.items[i].created_at >= response.items[i + 1].created_at\n\n\nclass TestRenewExpiration:\n    \"\"\"renew_sandbox_expiration method tests\"\"\"\n    \n    def test_renew_expiration_succeeds(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Successfully renew expiration\n        \n        Purpose: Verify that sandbox expiration can be successfully renewed\n        \"\"\"\n        new_expiration = datetime.now(timezone.utc) + timedelta(hours=2)\n        \n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        k8s_service.workload_provider.update_expiration.return_value = None\n        k8s_service.workload_provider.get_expiration.return_value = new_expiration\n        \n        from src.api.schema import RenewSandboxExpirationRequest\n        request = RenewSandboxExpirationRequest(expires_at=new_expiration)\n        \n        response = k8s_service.renew_expiration(\"test-sandbox-id\", request)\n        \n        assert response.expires_at == new_expiration\n        k8s_service.workload_provider.update_expiration.assert_called_once_with(\n            sandbox_id=\"test-sandbox-id\",\n            namespace=k8s_service.namespace,\n            expires_at=new_expiration\n        )\n    \n    def test_renew_with_past_time_raises_error(self, k8s_service, mock_workload):\n        \"\"\"\n        Test case: Raises exception when renewing with past time\n        \n        Purpose: Verify that HTTPException is raised when renewing with past time\n        \"\"\"\n        past_time = datetime.now(timezone.utc) - timedelta(hours=1)\n        \n        k8s_service.workload_provider.get_workload.return_value = mock_workload\n        \n        from src.api.schema import RenewSandboxExpirationRequest\n        request = RenewSandboxExpirationRequest(expires_at=past_time)\n        \n        with pytest.raises(HTTPException) as exc_info:\n            k8s_service.renew_expiration(\"test-sandbox-id\", request)\n        \n        assert exc_info.value.status_code == 400\n\n    def test_renew_returns_409_when_sandbox_has_no_expiration(self, k8s_service):\n        \"\"\"Renew is rejected with 409 when sandbox has no TTL (manual cleanup).\"\"\"\n        k8s_service.workload_provider.get_workload.return_value = MagicMock()\n        k8s_service.workload_provider.get_expiration.return_value = None\n        from src.api.schema import RenewSandboxExpirationRequest\n        request = RenewSandboxExpirationRequest(\n            expires_at=datetime.now(timezone.utc) + timedelta(hours=1)\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            k8s_service.renew_expiration(\"no-ttl-sandbox\", request)\n\n        assert exc_info.value.status_code == 409\n        assert \"does not have automatic expiration\" in exc_info.value.detail[\"message\"]\n        k8s_service.workload_provider.update_expiration.assert_not_called()\n"
  },
  {
    "path": "server/tests/k8s/test_provider_factory.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for provider_factory.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import patch\n\nfrom src.config import AgentSandboxRuntimeConfig\nfrom src.services.k8s.provider_factory import (\n    register_provider,\n    create_workload_provider,\n    list_available_providers,\n    PROVIDER_TYPE_BATCHSANDBOX,\n    PROVIDER_TYPE_AGENT_SANDBOX,\n)\nfrom src.services.k8s.workload_provider import WorkloadProvider\nfrom src.services.k8s.batchsandbox_provider import BatchSandboxProvider\nfrom src.services.k8s.agent_sandbox_provider import AgentSandboxProvider\n\n\n\n\n\nclass TestProviderFactory:\n    \"\"\"provider_factory unit tests\"\"\"\n    \n    def test_register_and_create_batchsandbox_provider(self, mock_k8s_client, k8s_app_config):\n        \"\"\"Test case: Register and create BatchSandbox provider\n\n        Purpose: Verify that BatchSandbox provider can be created through factory method\n        \"\"\"\n        provider = create_workload_provider(\n            PROVIDER_TYPE_BATCHSANDBOX,\n            mock_k8s_client,\n            k8s_app_config,\n        )\n        \n        assert isinstance(provider, BatchSandboxProvider)\n        assert provider.k8s_client == mock_k8s_client\n\n    def test_register_and_create_agent_sandbox_provider(\n        self,\n        mock_k8s_client,\n        agent_sandbox_app_config,\n        tmp_path,\n    ):\n        \"\"\"Test case: Register and create agent-sandbox provider\n\n        Purpose: Verify that AgentSandbox provider can be created through factory method\n        \"\"\"\n        template_file = tmp_path / \"agent_sandbox_template.yaml\"\n        template_file.write_text(\n            \"\"\"\nmetadata:\n  annotations:\n    managed-by: opensandbox\nspec:\n  podTemplate:\n    spec:\n      nodeSelector:\n        workload: sandbox\n\"\"\"\n        )\n\n        agent_config = AgentSandboxRuntimeConfig(\n            template_file=str(template_file),\n            shutdown_policy=\"Retain\",\n            ingress_enabled=True,\n        )\n        agent_sandbox_app_config.agent_sandbox = agent_config\n        provider = create_workload_provider(\n            PROVIDER_TYPE_AGENT_SANDBOX,\n            mock_k8s_client,\n            agent_sandbox_app_config,\n        )\n\n        assert isinstance(provider, AgentSandboxProvider)\n        assert provider.k8s_client == mock_k8s_client\n        assert provider.shutdown_policy == \"Retain\"\n        assert provider.service_account == agent_sandbox_app_config.kubernetes.service_account\n    \n    def test_create_provider_case_insensitive(self, mock_k8s_client, k8s_app_config):\n        \"\"\"Test case: Case-insensitive provider creation\n\n        Purpose: Verify that provider type name is case-insensitive\n        \"\"\"\n        provider1 = create_workload_provider(\"BatchSandbox\", mock_k8s_client, k8s_app_config)\n        provider2 = create_workload_provider(PROVIDER_TYPE_BATCHSANDBOX, mock_k8s_client, k8s_app_config)\n        provider3 = create_workload_provider(\"BATCHSANDBOX\", mock_k8s_client, k8s_app_config)\n        \n        assert isinstance(provider1, BatchSandboxProvider)\n        assert isinstance(provider2, BatchSandboxProvider)\n        assert isinstance(provider3, BatchSandboxProvider)\n    \n    def test_create_provider_with_none_type_uses_default(self, mock_k8s_client, k8s_app_config):\n        \"\"\"Test case: None type uses default provider\n\n        Purpose: Verify that the first registered provider is used when provider_type is None\n        \"\"\"\n        provider = create_workload_provider(None, mock_k8s_client, k8s_app_config)\n        \n        # Should use the first registered provider (batchsandbox)\n        assert isinstance(provider, BatchSandboxProvider)\n    \n    def test_create_provider_with_invalid_type_raises_error(self, mock_k8s_client):\n        \"\"\"\n        Test case: Invalid provider type raises exception\n        \n        Purpose: Verify that ValueError is raised when passing unregistered provider type\n        \"\"\"\n        with pytest.raises(ValueError, match=\"Unsupported workload provider type\"):\n            create_workload_provider(\"invalid\", mock_k8s_client)\n    \n    def test_create_batchsandbox_with_template_file(self, mock_k8s_client, k8s_app_config, tmp_path):\n        \"\"\"Test case: Create BatchSandbox provider with template file\n\n        Purpose: Verify that factory method correctly passes template file path to BatchSandboxProvider\n        \"\"\"\n        template_file = tmp_path / \"test_template.yaml\"\n        template_file.write_text(\"\"\"apiVersion: execution.alibaba-inc.com/v1alpha1\nkind: BatchSandbox\nmetadata:\n  name: test-template\nspec:\n  template:\n    spec:\n      nodeSelector:\n        gpu: \"true\"\n\"\"\")\n\n        k8s_app_config.kubernetes.batchsandbox_template_file = str(template_file)\n\n        with patch.object(BatchSandboxProvider, '__init__', return_value=None) as mock_init:\n            create_workload_provider(PROVIDER_TYPE_BATCHSANDBOX, mock_k8s_client, k8s_app_config)\n            \n            # Verify that app_config carrying the template path was passed\n            mock_init.assert_called_once()\n            call_kwargs = mock_init.call_args.kwargs\n            assert call_kwargs['app_config'].kubernetes.batchsandbox_template_file == str(template_file)\n    \n    def test_list_available_providers(self):\n        \"\"\"\n        Test case: Get registered providers\n        \n        Purpose: Verify that list of all registered provider types can be retrieved\n        \"\"\"\n        providers = list_available_providers()\n\n        assert isinstance(providers, list)\n        assert PROVIDER_TYPE_BATCHSANDBOX in providers\n        assert PROVIDER_TYPE_AGENT_SANDBOX in providers\n    \n    def test_register_custom_provider(self, mock_k8s_client, isolated_registry):\n        \"\"\"\n        Test case: Register custom provider\n        \n        Purpose: Verify that new provider type can be dynamically registered\n        \"\"\"\n        # Create a custom provider class\n        class CustomProvider(WorkloadProvider):\n            def __init__(self, k8s_client):\n                self.k8s_client = k8s_client\n            \n            def create_workload(self, *args, **kwargs):\n                pass\n            \n            def get_workload(self, *args, **kwargs):\n                pass\n            \n            def delete_workload(self, *args, **kwargs):\n                pass\n            \n            def list_workloads(self, *args, **kwargs):\n                pass\n            \n            def update_expiration(self, *args, **kwargs):\n                pass\n            \n            def get_expiration(self, *args, **kwargs):\n                pass\n            \n            def get_status(self, *args, **kwargs):\n                pass\n            \n            def get_endpoint_info(self, *args, **kwargs):\n                pass\n        \n        # Register custom provider\n        register_provider(\"custom\", CustomProvider)\n        \n        # Verify that custom provider can be created\n        provider = create_workload_provider(\"custom\", mock_k8s_client)\n        assert isinstance(provider, CustomProvider)\n        \n        # Verify it's registered\n        assert \"custom\" in list_available_providers()\n    \n    def test_create_batchsandbox_with_config(self, mock_k8s_client, k8s_app_config):\n        \"\"\"Test case: Create BatchSandbox provider with explicit config\n\n        Purpose: Verify that provider creation works when k8s_config is provided\n        \"\"\"\n        provider = create_workload_provider(PROVIDER_TYPE_BATCHSANDBOX, mock_k8s_client, k8s_app_config)\n        \n        assert isinstance(provider, BatchSandboxProvider)\n        assert provider.k8s_client == mock_k8s_client\n    \n    def test_create_provider_with_empty_registry_raises_error(self, mock_k8s_client, isolated_registry):\n        \"\"\"\n        Test case: Creating provider with empty registry raises exception\n        \n        Purpose: Verify that ValueError is raised when no provider is registered and type is None\n        \"\"\"\n        from src.services.k8s import provider_factory\n        \n        # Clear the registry to test empty registry scenario\n        provider_factory._PROVIDER_REGISTRY.clear()\n        \n        # Verify that ValueError is raised when registry is empty and type is None\n        with pytest.raises(ValueError, match=\"No workload providers are registered\"):\n            create_workload_provider(None, mock_k8s_client)\n"
  },
  {
    "path": "server/tests/k8s/test_rate_limiter.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Unit tests for TokenBucketRateLimiter.\"\"\"\n\nimport time\nimport threading\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom src.services.k8s.rate_limiter import TokenBucketRateLimiter\n\n\nclass TestTokenBucketRateLimiter:\n    \"\"\"Tests for the token-bucket rate limiter.\"\"\"\n\n    # ------------------------------------------------------------------\n    # Construction\n    # ------------------------------------------------------------------\n\n    def test_invalid_qps_raises_value_error(self):\n        \"\"\"qps <= 0 must raise ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"qps must be > 0\"):\n            TokenBucketRateLimiter(qps=0)\n\n    def test_negative_qps_raises_value_error(self):\n        \"\"\"Negative qps must raise ValueError.\"\"\"\n        with pytest.raises(ValueError):\n            TokenBucketRateLimiter(qps=-1.0)\n\n    def test_burst_defaults_to_qps_when_zero(self):\n        \"\"\"burst=0 means the bucket capacity equals qps (minimum 1).\"\"\"\n        limiter = TokenBucketRateLimiter(qps=5.0, burst=0)\n        assert limiter._burst == 5.0\n\n    def test_explicit_burst_is_respected(self):\n        \"\"\"Explicit burst value sets bucket capacity independently from qps.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=5.0, burst=20)\n        assert limiter._burst == 20.0\n\n    def test_burst_minimum_is_one_when_qps_below_one(self):\n        \"\"\"burst is clamped to 1 when qps < 1 and burst is not set.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=0.5)\n        assert limiter._burst == 1.0\n\n    def test_low_qps_limiter_can_acquire(self):\n        \"\"\"A limiter with qps < 1 and default burst must be able to issue a token.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=0.5)\n        assert limiter.try_acquire() is True\n\n    # ------------------------------------------------------------------\n    # try_acquire\n    # ------------------------------------------------------------------\n\n    def test_try_acquire_succeeds_when_bucket_full(self):\n        \"\"\"try_acquire returns True when tokens are available.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=10.0, burst=10)\n        assert limiter.try_acquire() is True\n\n    def test_try_acquire_fails_when_bucket_empty(self):\n        \"\"\"try_acquire returns False after exhausting all tokens.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=1.0, burst=1)\n        limiter.try_acquire()  # consume the only token\n        assert limiter.try_acquire() is False\n\n    def test_try_acquire_consumes_token(self):\n        \"\"\"Each successful try_acquire reduces available tokens by one.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=10.0, burst=3)\n        assert limiter.try_acquire() is True\n        assert limiter.try_acquire() is True\n        assert limiter.try_acquire() is True\n        assert limiter.try_acquire() is False\n\n    # ------------------------------------------------------------------\n    # acquire (blocking)\n    # ------------------------------------------------------------------\n\n    def test_acquire_succeeds_immediately_when_tokens_available(self):\n        \"\"\"acquire completes without sleeping when the bucket has tokens.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=100.0, burst=10)\n        start = time.monotonic()\n        limiter.acquire()\n        elapsed = time.monotonic() - start\n        assert elapsed < 0.1  # should be essentially instant\n\n    def test_acquire_blocks_until_token_available(self):\n        \"\"\"acquire blocks and returns only after a token refills.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=10.0, burst=1)\n        limiter.try_acquire()  # drain the bucket\n\n        start = time.monotonic()\n        limiter.acquire()  # should wait ~0.1s for next token\n        elapsed = time.monotonic() - start\n\n        assert elapsed >= 0.05  # some delay occurred\n\n    def test_acquire_minimum_sleep_prevents_busy_loop(self):\n        \"\"\"acquire sleeps at least 1 ms even when wait is near-zero.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=1.0, burst=1)\n        # Manually set tokens to just below 1 to produce a near-zero wait\n        with limiter._lock:\n            limiter._tokens = 1.0 - 1e-10\n\n        with patch(\"src.services.k8s.rate_limiter.time.sleep\") as mock_sleep:\n            # _try_acquire will succeed on first or second call; we only care\n            # that if sleep is called, the argument is >= 0.001.\n            limiter.acquire()\n            for call in mock_sleep.call_args_list:\n                assert call.args[0] >= 0.001\n\n    # ------------------------------------------------------------------\n    # Token refill\n    # ------------------------------------------------------------------\n\n    def test_tokens_refill_over_time(self):\n        \"\"\"Tokens are replenished proportional to elapsed time.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=100.0, burst=10)\n        # Drain all tokens\n        for _ in range(10):\n            limiter.try_acquire()\n        assert limiter.try_acquire() is False\n\n        time.sleep(0.05)  # wait for ~5 tokens to refill at 100 qps\n\n        assert limiter.try_acquire() is True\n\n    def test_tokens_capped_at_burst(self):\n        \"\"\"Token count never exceeds burst capacity.\"\"\"\n        limiter = TokenBucketRateLimiter(qps=10.0, burst=5)\n        time.sleep(0.5)  # wait long enough to overflow if cap not applied\n        # Force a refill by calling _try_acquire internals\n        with limiter._lock:\n            limiter._refill()\n        assert limiter._tokens <= 5.0\n\n    # ------------------------------------------------------------------\n    # Thread safety\n    # ------------------------------------------------------------------\n\n    def test_concurrent_acquires_do_not_exceed_burst(self):\n        \"\"\"Concurrent threads must not collectively acquire more than burst tokens.\"\"\"\n        burst = 5\n        limiter = TokenBucketRateLimiter(qps=1000.0, burst=burst)\n        successes = []\n        lock = threading.Lock()\n\n        # Freeze time so _refill() never adds extra tokens during the test\n        fixed_time = limiter._last_refill\n\n        def worker():\n            with patch(\"src.services.k8s.rate_limiter.time.monotonic\", return_value=fixed_time):\n                if limiter.try_acquire():\n                    with lock:\n                        successes.append(1)\n\n        threads = [threading.Thread(target=worker) for _ in range(20)]\n        for t in threads:\n            t.start()\n        for t in threads:\n            t.join()\n\n        assert len(successes) <= burst\n"
  },
  {
    "path": "server/tests/smoke.sh",
    "content": "#!/bin/bash\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nset -euo pipefail\n\ncolor() {\n  if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then\n    tput setaf \"$1\"\n  fi\n}\n\nreset_color() {\n  if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then\n    tput sgr0\n  fi\n}\n\nSTEP_COLOR=6   # cyan\nINFO_COLOR=2   # green\nWARN_COLOR=3   # yellow\nERR_COLOR=1    # red\n\nstep() {\n  printf \"\\n%s==== %s ====%s\\n\" \"$(color ${STEP_COLOR})\" \"$1\" \"$(reset_color)\"\n}\n\ninfo() {\n  printf \"%s%s%s\\n\" \"$(color ${INFO_COLOR})\" \"$1\" \"$(reset_color)\"\n}\n\nwarn() {\n  printf \"%s%s%s\\n\" \"$(color ${WARN_COLOR})\" \"$1\" \"$(reset_color)\" >&2\n}\n\nerror() {\n  printf \"%s%s%s\\n\" \"$(color ${ERR_COLOR})\" \"$1\" \"$(reset_color)\" >&2\n}\n\nBASE_URL=\"${BASE_URL:-http://localhost:32888}\"\nBASE_API_URL=\"${BASE_URL}/v1\"\ncurl_json() {\n  curl -sfSL \"$@\"\n}\n\ncurl_json_status() {\n  # Returns body + trailing status code line to allow non-2xx handling.\n  curl -sSL -w \"\\n%{http_code}\" \"$@\"\n}\n\nwait_for_running() {\n  local sandbox_id=\"${1:-${SANDBOX_ID}}\"\n  local deadline=$((SECONDS + 30))\n  while true; do\n    local result\n    result=$(curl -sSL -w \"\\n%{http_code}\" \\\n      \"${BASE_API_URL}/sandboxes/${sandbox_id}\" \\\n      | python3 -c '\nimport json, sys\nraw = sys.stdin.read()\nlines = raw.rsplit(\"\\n\", 1)\nhttp_code = lines[-1].strip() if len(lines) > 1 else \"000\"\nbody_text = lines[0] if len(lines) > 1 else \"\"\nif http_code == \"404\":\n    print(\"ERROR:not found (404) — may have failed during provisioning\")\nelif http_code != \"200\":\n    print(f\"RETRY:HTTP {http_code}\")\nelif not body_text.strip():\n    print(\"RETRY:empty body\")\nelse:\n    try:\n        data = json.loads(body_text)\n        state = data.get(\"status\", {}).get(\"state\", \"\")\n        print(f\"STATE:{state}\")\n        print(body_text)\n    except json.JSONDecodeError as exc:\n        print(f\"RETRY:invalid JSON: {exc}\")\n') || true\n\n    local tag=\"${result%%:*}\"\n    local detail=\"${result#*:}\"\n\n    case \"${tag}\" in\n      ERROR)\n        error \"Sandbox ${sandbox_id}: ${detail}\"\n        return 1\n        ;;\n      RETRY)\n        warn \"GET sandbox ${sandbox_id}: ${detail}, retrying...\"\n        if (( SECONDS >= deadline )); then\n          error \"Sandbox ${sandbox_id} did not reach Running within 30s.\"\n          return 1\n        fi\n        sleep 1\n        continue\n        ;;\n      STATE)\n        local state=\"${detail%%$'\\n'*}\"\n        local body=\"${detail#*$'\\n'}\"\n        if [[ \"${state}\" == \"Running\" ]]; then\n          printf '%s' \"${body}\"\n          return 0\n        fi\n        if [[ \"${state}\" == \"Failed\" || \"${state}\" == \"Terminated\" ]]; then\n          error \"Sandbox ${sandbox_id} entered terminal state '${state}'.\"\n          return 1\n        fi\n        if (( SECONDS >= deadline )); then\n          error \"Sandbox ${sandbox_id} did not reach Running within 30s (last: ${state}).\"\n          return 1\n        fi\n        sleep 1\n        ;;\n      *)\n        warn \"GET sandbox ${sandbox_id}: unexpected output, retrying...\"\n        if (( SECONDS >= deadline )); then\n          error \"Sandbox ${sandbox_id} did not reach Running within 30s.\"\n          return 1\n        fi\n        sleep 1\n        ;;\n    esac\n  done\n}\n\nwait_for_expired() {\n  local sandbox_id=$1\n  local deadline=$((SECONDS + 90))\n  while true; do\n    local resp body status\n    resp=$(curl_json_status \"${BASE_API_URL}/sandboxes/${sandbox_id}\")\n    status=\"${resp##*$'\\n'}\"\n    body=\"${resp%$'\\n'*}\"\n    if [[ \"${status}\" == \"404\" ]]; then\n      info \"Sandbox ${sandbox_id} expired as expected.\"\n      return 0\n    fi\n    if (( SECONDS >= deadline )); then\n      error \"Sandbox ${sandbox_id} did not expire within expected window (last status ${status}).\"\n      echo \"${body}\"\n      return 1\n    fi\n    sleep 2\n  done\n}\n\nwait_for_sidecar_gone() {\n  local sandbox_id=$1\n  local deadline=$((SECONDS + 20))\n  while true; do\n    if ! docker ps -a --filter \"label=opensandbox.io/egress-sidecar-for=${sandbox_id}\" -q | grep -q .; then\n      info \"No sidecar remaining for sandbox ${sandbox_id}\"\n      return 0\n    fi\n    if (( SECONDS >= deadline )); then\n      error \"Sidecar for sandbox ${sandbox_id} still present after timeout\"\n      docker ps -a --filter \"label=opensandbox.io/egress-sidecar-for=${sandbox_id}\"\n      return 1\n    fi\n    sleep 2\n  done\n}\n\ndocker pull ubuntu:latest\n\ncreate_payload='{\n  \"image\": { \"uri\": \"ubuntu\" },\n  \"env\": { \"HELLO\": \"WORLD\" },\n  \"metadata\": { \"hello\": \"world\" },\n  \"entrypoint\": [\"tail\", \"-f\", \"/dev/null\"],\n  \"resourceLimits\": { \"cpu\": \"500m\", \"memory\": \"512Mi\" },\n  \"timeout\": 60\n}'\n\nstep \"Create sandbox (60s TTL)\"\ncreate_resp=$(curl_json \\\n  -H 'Content-Type: application/json' \\\n  -d \"${create_payload}\" \\\n  \"${BASE_API_URL}/sandboxes\")\n\nSANDBOX_ID=$(python3 - <<'PY' \"${create_resp}\"\nimport json,sys\ndata=json.loads(sys.argv[1])\nsid=str(data.get(\"id\",\"\")).strip()\nif not sid:\n    raise SystemExit(\"Failed to parse sandbox id from response\")\nprint(sid,end=\"\")\nPY\n)\n\necho \"Sandbox created: id=${SANDBOX_ID}\"\n\nstep \"Wait for sandbox to reach Running\"\nget_resp=$(wait_for_running)\nstate=$(python3 - <<'PY' \"${get_resp}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nprint(body.get(\"status\",{}).get(\"state\"))\nPY\n)\necho \"Sandbox state: ${state}\"\n\npython3 - <<'PY' \"${get_resp}\" \"${SANDBOX_ID}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nexpected=sys.argv[2]\nassert str(body.get(\"id\"))==expected, \"Sandbox ID mismatch in GET response\"\nassert body.get(\"status\",{}).get(\"state\") in {\"Pending\",\"Running\",\"Unknown\",\"Paused\",\"Terminated\",\"Failed\"}, \"Unexpected state\"\nPY\n\nstep \"List sandboxes (metadata filter)\"\nlist_resp=$(curl_json \\\n  -G \\\n  --data-urlencode \"metadata=hello=world\" \\\n  --data-urlencode \"page=1\" \\\n  --data-urlencode \"pageSize=10\" \\\n  \"${BASE_API_URL}/sandboxes\")\n\npython3 - <<'PY' \"${list_resp}\" \"${SANDBOX_ID}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nsid=sys.argv[2]\nids=[item.get(\"id\") for item in body.get(\"items\",[])]\nassert sid in ids, \"Sandbox ID not found in list response\"\nassert body.get(\"pagination\",{}).get(\"page\") == 1, \"Unexpected pagination page\"\nPY\necho \"List check passed (found sandbox, pagination ok)\"\n\nstep \"Renew sandbox expiration (+10m)\"\nnew_expiration=$(python3 - <<'PY'\nfrom datetime import datetime, timedelta, timezone\nprint((datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat())\nPY\n)\n\nrenew_payload=$(cat <<JSON\n{\n  \"expiresAt\": \"${new_expiration}\"\n}\nJSON\n)\n\nrenew_resp=$(curl_json \\\n  -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d \"${renew_payload}\" \\\n  \"${BASE_API_URL}/sandboxes/${SANDBOX_ID}/renew-expiration\")\nrenewed=$(python3 - <<'PY' \"${renew_resp}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nprint(body.get(\"expiresAt\"))\nPY\n)\necho \"Expiration renewed to: ${renewed}\"\n\nstep \"Request endpoint on port 8080\"\nendpoint_resp=$(curl_json \"${BASE_API_URL}/sandboxes/${SANDBOX_ID}/endpoints/8080\")\nendpoint=$(python3 - <<'PY' \"${endpoint_resp}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nprint(body.get(\"endpoint\"))\nPY\n)\necho \"Endpoint: ${endpoint}\"\n\nstep \"Delete sandbox\"\ncurl_json -X DELETE \"${BASE_API_URL}/sandboxes/${SANDBOX_ID}\"\necho \"Sandbox ${SANDBOX_ID} deleted.\"\n\nstep \"Create sandbox with networkPolicy (egress sidecar)\"\negress_payload='{\n  \"image\": { \"uri\": \"ubuntu\" },\n  \"env\": {},\n  \"metadata\": { \"egress\": \"on\" },\n  \"entrypoint\": [\"tail\", \"-f\", \"/dev/null\"],\n  \"resourceLimits\": { \"cpu\": \"500m\", \"memory\": \"512Mi\" },\n  \"timeout\": 60,\n  \"networkPolicy\": {\n    \"defaultAction\": \"deny\",\n    \"egress\": [\n      { \"action\": \"allow\", \"target\": \"pypi.org\" }\n    ]\n  }\n}'\n\ncreate_resp_with_status=$(curl_json_status \\\n  -H 'Content-Type: application/json' \\\n  -d \"${egress_payload}\" \\\n  \"${BASE_API_URL}/sandboxes\")\n\nstatus_code=\"${create_resp_with_status##*$'\\n'}\"\ncreate_resp_body=\"${create_resp_with_status%$'\\n'*}\"\n\nif [[ \"${status_code}\" != \"202\" ]]; then\n  warn \"Skip egress sidecar smoke (status ${status_code}). Body: ${create_resp_body}\"\n  warn \"Likely network_mode=host or egress.image unset.\"\nelse\n  SANDBOX_ID=$(python3 - <<'PY' \"${create_resp_body}\"\nimport json,sys\ndata=json.loads(sys.argv[1])\nsid=str(data.get(\"id\",\"\")).strip()\nif not sid:\n    raise SystemExit(\"Failed to parse sandbox id from response\")\nprint(sid,end=\"\")\nPY\n)\n  echo \"Egress sandbox created: id=${SANDBOX_ID}\"\n\n  step \"Wait for egress sandbox to reach Running\"\n  wait_for_running \"${SANDBOX_ID}\" >/dev/null\n\n  step \"Verify egress sidecar is running\"\n  SIDECAR_ID=$(docker ps -a --filter \"label=opensandbox.io/egress-sidecar-for=${SANDBOX_ID}\" -q | head -n1 || true)\n  if [[ -z \"${SIDECAR_ID}\" ]]; then\n    error \"Expected egress sidecar for sandbox ${SANDBOX_ID}, but none found.\"\n    exit 1\n  fi\n  info \"Sidecar ${SIDECAR_ID} detected for sandbox ${SANDBOX_ID}\"\n\n  step \"Delete egress sandbox and ensure sidecar cleanup\"\n  curl_json -X DELETE \"${BASE_API_URL}/sandboxes/${SANDBOX_ID}\"\n  wait_for_sidecar_gone \"${SANDBOX_ID}\"\nfi\n\nstep \"Create sandbox with host volume mount\"\n# Prepare the host volume test directory\nmkdir -p /tmp/opensandbox-e2e/host-volume-test\necho \"opensandbox-e2e-marker\" > /tmp/opensandbox-e2e/host-volume-test/marker.txt\nchmod -R 755 /tmp/opensandbox-e2e\n\nvolume_payload='{\n  \"image\": { \"uri\": \"ubuntu\" },\n  \"env\": {},\n  \"metadata\": { \"volume\": \"host-test\" },\n  \"entrypoint\": [\"tail\", \"-f\", \"/dev/null\"],\n  \"resourceLimits\": { \"cpu\": \"500m\", \"memory\": \"512Mi\" },\n  \"timeout\": 60,\n  \"volumes\": [\n    {\n      \"name\": \"test-host-vol\",\n      \"host\": { \"path\": \"/tmp/opensandbox-e2e/host-volume-test\" },\n      \"mountPath\": \"/mnt/host-data\",\n      \"readOnly\": false\n    }\n  ]\n}'\n\nvolume_resp_with_status=$(curl_json_status \\\n  -H 'Content-Type: application/json' \\\n  -d \"${volume_payload}\" \\\n  \"${BASE_API_URL}/sandboxes\")\n\nvolume_status=\"${volume_resp_with_status##*$'\\n'}\"\nvolume_body=\"${volume_resp_with_status%$'\\n'*}\"\n\nif [[ \"${volume_status}\" != \"202\" ]]; then\n  warn \"Skip host volume smoke (status ${volume_status}). Body: ${volume_body}\"\n  warn \"Likely host path validation or storage config issue.\"\nelse\n  VOLUME_SANDBOX_ID=$(python3 - <<'PY' \"${volume_body}\"\nimport json,sys\ndata=json.loads(sys.argv[1])\nsid=str(data.get(\"id\",\"\")).strip()\nif not sid:\n    raise SystemExit(\"Failed to parse sandbox id from response\")\nprint(sid,end=\"\")\nPY\n)\n  echo \"Volume sandbox created: id=${VOLUME_SANDBOX_ID}\"\n\n  step \"Wait for volume sandbox to reach Running\"\n  wait_for_running \"${VOLUME_SANDBOX_ID}\" >/dev/null\n\n  # --- Verify the bind mount is actually effective ---\n  # Resolve the Docker container ID from the sandbox API response.\n  volume_sandbox_resp=$(curl_json \"${BASE_API_URL}/sandboxes/${VOLUME_SANDBOX_ID}\")\n  container_id=$(python3 -c '\nimport json, sys\nbody = json.loads(sys.argv[1])\nprint(body.get(\"containerId\", body.get(\"container_id\", \"\")), end=\"\")\n' \"${volume_sandbox_resp}\")\n  # Fallback: if the API doesn't expose container_id, search by label.\n  if [[ -z \"${container_id}\" ]]; then\n    container_id=$(docker ps -qf \"label=sandbox_id=${VOLUME_SANDBOX_ID}\" | head -1)\n  fi\n\n  if [[ -n \"${container_id}\" ]]; then\n    step \"Verify host volume bind mount content inside container\"\n    # 1. Read the marker file written on the host\n    marker_content=$(docker exec \"${container_id}\" cat /mnt/host-data/marker.txt 2>&1) || true\n    if [[ \"${marker_content}\" == \"opensandbox-e2e-marker\" ]]; then\n      info \"PASS: marker.txt content matches expected value.\"\n    else\n      error \"FAIL: marker.txt content='${marker_content}', expected='opensandbox-e2e-marker'\"\n      exit 1\n    fi\n\n    # 2. Write a file from inside the container and verify it on the host\n    docker exec \"${container_id}\" sh -c 'echo \"written-from-sandbox\" > /mnt/host-data/sandbox-output.txt'\n    host_content=$(cat /tmp/opensandbox-e2e/host-volume-test/sandbox-output.txt 2>&1) || true\n    if [[ \"${host_content}\" == \"written-from-sandbox\" ]]; then\n      info \"PASS: file written inside container is visible on host.\"\n    else\n      error \"FAIL: sandbox-output.txt on host='${host_content}', expected='written-from-sandbox'\"\n      exit 1\n    fi\n  else\n    warn \"Skip bind-mount verification: could not resolve container ID for sandbox ${VOLUME_SANDBOX_ID}.\"\n  fi\n\n  step \"Delete volume sandbox\"\n  curl_json -X DELETE \"${BASE_API_URL}/sandboxes/${VOLUME_SANDBOX_ID}\"\n  echo \"Volume sandbox ${VOLUME_SANDBOX_ID} deleted.\"\nfi\n\nstep \"Create short-lived sandbox (60s TTL) for auto-expiration\"\ncreate_payload_short='{\n  \"image\": { \"uri\": \"ubuntu\" },\n  \"env\": {},\n  \"metadata\": { \"lifecycle\": \"short\" },\n  \"entrypoint\": [\"tail\", \"-f\", \"/dev/null\"],\n  \"resourceLimits\": { \"cpu\": \"1\", \"memory\": \"2Gi\" },\n  \"timeout\": 60\n}'\n\ncreate_resp_short=$(curl_json \\\n  -H 'Content-Type: application/json' \\\n  -d \"${create_payload_short}\" \\\n  \"${BASE_API_URL}/sandboxes\")\n\nSANDBOX_ID=$(python3 - <<'PY' \"${create_resp_short}\"\nimport json,sys\ndata=json.loads(sys.argv[1])\nsid=str(data.get(\"id\",\"\")).strip()\nif not sid:\n    raise SystemExit(\"Failed to parse sandbox id from response\")\nprint(sid,end=\"\")\nPY\n)\n\necho \"Short-lived sandbox created: id=${SANDBOX_ID}\"\n\nstep \"Wait for short-lived sandbox to reach Running\"\nget_resp_short=$(wait_for_running \"${SANDBOX_ID}\")\nstate_short=$(python3 - <<'PY' \"${get_resp_short}\"\nimport json,sys\nbody=json.loads(sys.argv[1])\nprint(body.get(\"status\",{}).get(\"state\"))\nPY\n)\necho \"Sandbox state: ${state_short}\"\n\nstep \"Wait for sandbox ${SANDBOX_ID} to auto-expire (expect 404)\"\nwait_for_expired \"${SANDBOX_ID}\"\n\nstep \"server Lifecycle API smoke test completed successfully\"\n"
  },
  {
    "path": "server/tests/test_agent_sandbox_service.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nUnit tests for KubernetesSandboxService with agent-sandbox provider.\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom fastapi import HTTPException\nfrom pydantic import ValidationError\n\nfrom src.api.schema import SandboxStatus\nfrom src.config import (\n    AppConfig,\n    RuntimeConfig,\n    ServerConfig,\n    KubernetesRuntimeConfig,\n    AgentSandboxRuntimeConfig,\n)\nfrom src.services.k8s.kubernetes_service import KubernetesSandboxService\nfrom src.services.constants import SandboxErrorCodes\n\n\n@pytest.fixture\ndef agent_sandbox_runtime_config():\n    \"\"\"Provide agent-sandbox runtime configuration\"\"\"\n    return KubernetesRuntimeConfig(\n        kubeconfig_path=\"/tmp/test-kubeconfig\",\n        namespace=\"test-namespace\",\n        service_account=\"test-sa\",\n        workload_provider=\"agent-sandbox\",\n    )\n\n\n@pytest.fixture\ndef agent_sandbox_app_config(agent_sandbox_runtime_config):\n    \"\"\"Provide complete app configuration (kubernetes + agent-sandbox provider)\"\"\"\n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"kubernetes\",\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=agent_sandbox_runtime_config,\n        agent_sandbox=AgentSandboxRuntimeConfig(\n            template_file=None,\n            shutdown_policy=\"Delete\",\n            ingress_enabled=True,\n        ),\n    )\n\n\n@pytest.fixture\ndef app_config_docker():\n    \"\"\"Provide Docker type app configuration\"\"\"\n    return AppConfig(\n        server=ServerConfig(\n            host=\"0.0.0.0\",\n            port=8080,\n            log_level=\"DEBUG\",\n            api_key=\"test-api-key\",\n        ),\n        runtime=RuntimeConfig(\n            type=\"docker\",\n            execd_image=\"ghcr.io/opensandbox/execd:test\",\n        ),\n        kubernetes=None,\n    )\n\n\nclass TestAgentSandboxServiceInit:\n    \"\"\"KubernetesSandboxService initialization tests (agent-sandbox provider)\"\"\"\n\n    def test_init_with_valid_config_succeeds(self, agent_sandbox_runtime_config):\n        \"\"\"\n        Test case: Successful initialization with valid config\n        \"\"\"\n        config = AppConfig(\n            server=ServerConfig(\n                host=\"0.0.0.0\",\n                port=8080,\n                log_level=\"DEBUG\",\n                api_key=\"test-api-key\",\n            ),\n            runtime=RuntimeConfig(\n                type=\"kubernetes\",\n                execd_image=\"ghcr.io/opensandbox/execd:test\",\n            ),\n            kubernetes=agent_sandbox_runtime_config,\n            agent_sandbox=AgentSandboxRuntimeConfig(\n                template_file=\"/tmp/template.yaml\",\n                shutdown_policy=\"Retain\",\n                ingress_enabled=True,\n            ),\n        )\n\n        with patch(\"src.services.k8s.kubernetes_service.K8sClient\") as mock_k8s_client, patch(\n            \"src.services.k8s.kubernetes_service.create_workload_provider\"\n        ) as mock_provider_factory:\n            mock_provider_factory.return_value = MagicMock()\n\n            service = KubernetesSandboxService(config)\n\n            assert service.namespace == agent_sandbox_runtime_config.namespace\n            assert service.execd_image == config.runtime.execd_image\n            mock_k8s_client.assert_called_once_with(agent_sandbox_runtime_config)\n            mock_provider_factory.assert_called_once()\n            call_kwargs = mock_provider_factory.call_args.kwargs\n            assert call_kwargs[\"provider_type\"] == \"agent-sandbox\"\n            assert call_kwargs[\"app_config\"].agent_sandbox.template_file == \"/tmp/template.yaml\"\n            assert call_kwargs[\"app_config\"].agent_sandbox.shutdown_policy == \"Retain\"\n            assert call_kwargs[\"app_config\"].kubernetes == agent_sandbox_runtime_config\n\n    def test_init_without_kubernetes_config_raises_error(self):\n        \"\"\"\n        Test case: Raises exception when Kubernetes config is missing\n        \"\"\"\n        with pytest.raises(ValidationError, match=\"agent_sandbox block requires kubernetes.workload_provider\"):\n            AppConfig(\n                server=ServerConfig(\n                    host=\"0.0.0.0\",\n                    port=8080,\n                    log_level=\"DEBUG\",\n                    api_key=\"test-api-key\",\n                ),\n                runtime=RuntimeConfig(\n                    type=\"kubernetes\",\n                    execd_image=\"ghcr.io/opensandbox/execd:test\",\n                ),\n                kubernetes=None,\n                agent_sandbox=AgentSandboxRuntimeConfig(),\n            )\n\n\n    def test_init_with_wrong_runtime_type_raises_error(self, app_config_docker):\n        \"\"\"\n        Test case: Raises exception with wrong runtime type\n        \"\"\"\n        with pytest.raises(ValueError, match=\"requires runtime.type = 'kubernetes'\"):\n            KubernetesSandboxService(app_config_docker)\n\n    def test_init_with_k8s_client_failure_raises_http_exception(self, agent_sandbox_app_config):\n        \"\"\"\n        Test case: Raises HTTPException when K8sClient initialization fails\n        \"\"\"\n        with patch(\"src.services.k8s.kubernetes_service.K8sClient\") as mock_k8s_client:\n            mock_k8s_client.side_effect = Exception(\"Failed to load kubeconfig\")\n\n            with pytest.raises(HTTPException) as exc_info:\n                KubernetesSandboxService(agent_sandbox_app_config)\n\n            assert exc_info.value.status_code == 503\n            assert \"code\" in exc_info.value.detail\n            assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.K8S_INITIALIZATION_ERROR\n\n\nclass TestAgentSandboxServiceBuildSandbox:\n    \"\"\"KubernetesSandboxService _build_sandbox_from_workload tests for agent-sandbox CRD\"\"\"\n\n    def test_build_sandbox_from_workload_dict(self):\n        \"\"\"\n        Test case: Verify sandbox fields are built from dict workload\n        \"\"\"\n        service = object.__new__(KubernetesSandboxService)\n        service.workload_provider = MagicMock(\n            get_expiration=MagicMock(return_value=datetime(2025, 12, 31, tzinfo=timezone.utc)),\n            get_status=MagicMock(\n                return_value={\n                    \"state\": \"Running\",\n                    \"reason\": \"Ready\",\n                    \"message\": \"Ready\",\n                    \"last_transition_at\": datetime(2025, 12, 31, tzinfo=timezone.utc),\n                }\n            ),\n        )\n\n        workload = {\n            \"metadata\": {\n                \"labels\": {\n                    \"opensandbox.io/id\": \"sandbox-id\",\n                    \"team\": \"platform\",\n                },\n                \"creationTimestamp\": \"2025-12-31T09:00:00Z\",\n            },\n            \"spec\": {\n                \"podTemplate\": {\n                    \"spec\": {\n                        \"containers\": [\n                            {\n                                \"image\": \"python:3.11\",\n                                \"command\": [\"/bin/bash\"],\n                            }\n                        ]\n                    }\n                }\n            },\n        }\n\n        sandbox = service._build_sandbox_from_workload(workload)\n\n        assert sandbox.id == \"sandbox-id\"\n        assert sandbox.image.uri == \"python:3.11\"\n        assert sandbox.entrypoint == [\"/bin/bash\"]\n        assert sandbox.metadata == {\"team\": \"platform\"}\n        assert isinstance(sandbox.status, SandboxStatus)\n        assert sandbox.status.state == \"Running\"\n"
  },
  {
    "path": "server/tests/test_auth_middleware.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom src.config import AppConfig, IngressConfig, RuntimeConfig, ServerConfig\nfrom src.middleware.auth import AuthMiddleware\n\n\ndef _app_config_with_api_key() -> AppConfig:\n    return AppConfig(\n        server=ServerConfig(api_key=\"secret-key\"),\n        runtime=RuntimeConfig(type=\"docker\", execd_image=\"opensandbox/execd:latest\"),\n        ingress=IngressConfig(mode=\"direct\"),\n    )\n\n\ndef _build_test_app():\n    app = FastAPI()\n    config = _app_config_with_api_key()\n    app.add_middleware(AuthMiddleware, config=config)\n\n    @app.get(\"/secured\")\n    def secured_endpoint():\n        return {\"ok\": True}\n\n    return app\n\n\ndef test_auth_middleware_rejects_missing_key():\n    app = _build_test_app()\n    client = TestClient(app)\n    response = client.get(\"/secured\")\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n\n\ndef test_auth_middleware_accepts_valid_key():\n    app = _build_test_app()\n    client = TestClient(app)\n    response = client.get(\"/secured\", headers={\"OPEN-SANDBOX-API-KEY\": \"secret-key\"})\n    assert response.status_code == 200\n    assert response.json() == {\"ok\": True}\n\n\ndef test_auth_middleware_skips_validation_for_proxy_to_sandbox():\n    \"\"\"Proxy-to-sandbox paths must not require API key; server only forwards to sandbox.\"\"\"\n    app = _build_test_app()\n\n    @app.get(\"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}\")\n    def proxy_echo(sandbox_id: str, port: int, full_path: str):\n        return {\"proxied\": True, \"sandbox_id\": sandbox_id, \"port\": port, \"path\": full_path}\n\n    client = TestClient(app)\n    # No OPEN-SANDBOX-API-KEY header; should still succeed for proxy path\n    response = client.get(\"/sandboxes/abc-123/proxy/8080/foo/bar\")\n    assert response.status_code == 200\n    assert response.json()[\"proxied\"] is True\n    assert response.json()[\"sandbox_id\"] == \"abc-123\"\n    assert response.json()[\"port\"] == 8080\n    assert response.json()[\"path\"] == \"foo/bar\"\n\n\ndef test_auth_middleware_v1_proxy_path_exempt():\n    \"\"\"V1 prefix proxy path is also exempt.\"\"\"\n    app = _build_test_app()\n\n    @app.get(\"/v1/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}\")\n    def proxy_echo(sandbox_id: str, port: int, full_path: str):\n        return {\"proxied\": True}\n\n    client = TestClient(app)\n    response = client.get(\"/v1/sandboxes/sid/proxy/443/\")\n    assert response.status_code == 200\n    assert response.json()[\"proxied\"] is True\n\n\ndef test_auth_middleware_requires_key_for_non_proxy_paths_containing_proxy_and_sandboxes():\n    \"\"\"Paths that contain both 'proxy' and 'sandboxes' but not in proxy-route shape still require auth.\"\"\"\n    app = _build_test_app()\n\n    @app.get(\"/proxy/sandboxes/anything\")\n    def fake_proxy():\n        return {\"reached\": True}\n\n    client = TestClient(app)\n    response = client.get(\"/proxy/sandboxes/anything\")\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n\n\ndef test_auth_middleware_requires_key_for_malformed_proxy_port():\n    \"\"\"Malformed port (non-numeric) must get 401, not 422; limits unauthenticated surface.\"\"\"\n    app = _build_test_app()\n\n    @app.get(\"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}\")\n    def proxy_echo(sandbox_id: str, port: int, full_path: str):\n        return {\"proxied\": True}\n\n    client = TestClient(app)\n    response = client.get(\"/sandboxes/s1/proxy/not-a-port/x\")\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n\n\ndef test_auth_middleware_is_proxy_path_rejects_traversal():\n    \"\"\"Paths containing '..' are never considered proxy (no auth bypass).\"\"\"\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/abc/proxy/8080/../other\") is False\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/../admin/proxy/8080\") is False\n\n\ndef test_auth_middleware_is_proxy_path_accepts_valid_shapes():\n    \"\"\"Only exact proxy route shape (including numeric port) is accepted.\"\"\"\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/id/proxy/8080\") is True\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/id/proxy/8080/\") is True\n    assert AuthMiddleware._is_proxy_path(\"/v1/sandboxes/id/proxy/443/path\") is True\n    assert AuthMiddleware._is_proxy_path(\"/proxy/sandboxes/x\") is False\n    assert AuthMiddleware._is_proxy_path(\"/foo/sandboxes/id/proxy/8080\") is False\n    # Non-numeric port must not skip auth (malformed path → 401, not 422)\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/s1/proxy/not-a-port/x\") is False\n    assert AuthMiddleware._is_proxy_path(\"/sandboxes/s1/proxy/8080x/\") is False\n"
  },
  {
    "path": "server/tests/test_config.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport textwrap\n\nimport pytest\n\nfrom src import config as config_module\nfrom src.config import (\n    AppConfig,\n    EGRESS_MODE_DNS,\n    EGRESS_MODE_DNS_NFT,\n    EgressConfig,\n    GatewayConfig,\n    GatewayRouteModeConfig,\n    IngressConfig,\n    RuntimeConfig,\n    ServerConfig,\n    StorageConfig,\n)\n\n\ndef _reset_config(monkeypatch):\n    monkeypatch.setattr(config_module, \"_config\", None, raising=False)\n    monkeypatch.setattr(config_module, \"_config_path\", None, raising=False)\n\n\ndef test_load_config_from_file(tmp_path, monkeypatch):\n    _reset_config(monkeypatch)\n    toml = textwrap.dedent(\n        \"\"\"\n        [server]\n        host = \"127.0.0.1\"\n        port = 9000\n        log_level = \"DEBUG\"\n        api_key = \"secret\"\n        max_sandbox_timeout_seconds = 172800\n\n        [runtime]\n        type = \"kubernetes\"\n        execd_image = \"opensandbox/execd:test\"\n\n        [ingress]\n        mode = \"gateway\"\n        gateway.address = \"*.opensandbox.io\"\n        gateway.route.mode = \"wildcard\"\n        \"\"\"\n    )\n    config_path = tmp_path / \"config.toml\"\n    config_path.write_text(toml)\n\n    loaded = config_module.load_config(config_path)\n    assert loaded.server.host == \"127.0.0.1\"\n    assert loaded.server.port == 9000\n    assert loaded.server.log_level == \"DEBUG\"\n    assert loaded.server.api_key == \"secret\"\n    assert loaded.server.max_sandbox_timeout_seconds == 172800\n    assert loaded.runtime.type == \"kubernetes\"\n    assert loaded.runtime.execd_image == \"opensandbox/execd:test\"\n    assert loaded.ingress is not None\n    assert loaded.ingress.mode == \"gateway\"\n    assert loaded.ingress.gateway is not None\n    assert loaded.ingress.gateway.address == \"*.opensandbox.io\"\n    assert loaded.ingress.gateway.route.mode == \"wildcard\"\n    assert loaded.kubernetes is not None\n\n\ndef test_docker_runtime_disallows_kubernetes_block():\n    server_cfg = ServerConfig()\n    runtime_cfg = RuntimeConfig(type=\"docker\", execd_image=\"busybox:latest\")\n    kubernetes_cfg = config_module.KubernetesRuntimeConfig(namespace=\"sandbox\")\n    with pytest.raises(ValueError):\n        AppConfig(server=server_cfg, runtime=runtime_cfg, kubernetes=kubernetes_cfg)\n\n\ndef test_server_config_defaults_include_max_sandbox_timeout():\n    server_cfg = ServerConfig()\n    assert server_cfg.max_sandbox_timeout_seconds is None\n\n\ndef test_kubernetes_runtime_fills_missing_block():\n    server_cfg = ServerConfig()\n    runtime_cfg = RuntimeConfig(type=\"kubernetes\", execd_image=\"opensandbox/execd:latest\")\n    app_cfg = AppConfig(server=server_cfg, runtime=runtime_cfg)\n    assert app_cfg.kubernetes is not None\n\n\ndef test_ingress_gateway_requires_gateway_block():\n    with pytest.raises(ValueError):\n        IngressConfig(mode=\"gateway\")\n    cfg = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"gateway.opensandbox.io\",\n            route=GatewayRouteModeConfig(mode=\"uri\"),\n        ),\n    )\n    assert cfg.gateway.route.mode == \"uri\"\n\n\ndef test_gateway_address_validation_for_wildcard_mode():\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"gateway.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n    cfg = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"*.opensandbox.io\",\n            route=GatewayRouteModeConfig(mode=\"wildcard\"),\n        ),\n    )\n    assert cfg.gateway.address == \"*.opensandbox.io\"\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"10.0.0.1\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"http://10.0.0.1:8080\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"10.0.0.1:8080\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"https://*.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n\n\ndef test_gateway_route_mode_allows_wildcard_alias():\n    cfg = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"*.opensandbox.io\",\n            route=GatewayRouteModeConfig(mode=\"wildcard\"),\n        ),\n    )\n    assert cfg.gateway.route.mode == \"wildcard\"\n\n\ndef test_gateway_address_validation_for_non_wildcard_mode():\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"*.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"not a host\",\n                route=GatewayRouteModeConfig(mode=\"uri\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"gateway.opensandbox.io:8080\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"10.0.0.1:70000\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"ftp://gateway.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"http://\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"http://user:pass@gateway.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"http://gateway.opensandbox.io:8080\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"10.0.0.1:0\",\n                route=GatewayRouteModeConfig(mode=\"uri\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"10.0.0.1:abc\",\n                route=GatewayRouteModeConfig(mode=\"uri\"),\n            ),\n        )\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"http://[::1]\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n    cfg = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"gateway.opensandbox.io\",\n            route=GatewayRouteModeConfig(mode=\"uri\"),\n        ),\n    )\n    assert cfg.gateway.address == \"gateway.opensandbox.io\"\n    cfg_ip = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"10.0.0.1\",\n            route=GatewayRouteModeConfig(mode=\"header\"),\n        ),\n    )\n    assert cfg_ip.gateway.address == \"10.0.0.1\"\n    cfg_ip_port = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"10.0.0.1:8080\",\n            route=GatewayRouteModeConfig(mode=\"header\"),\n        ),\n    )\n    assert cfg_ip_port.gateway.address == \"10.0.0.1:8080\"\n\n\ndef test_gateway_address_allows_scheme_less_defaults():\n    cfg = IngressConfig(\n        mode=\"gateway\",\n        gateway=GatewayConfig(\n            address=\"*.example.com\",\n            route=GatewayRouteModeConfig(mode=\"wildcard\"),\n        ),\n    )\n    assert cfg.gateway.address == \"*.example.com\"\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"gateway\",\n            gateway=GatewayConfig(\n                address=\"https://*.example.com\",\n                route=GatewayRouteModeConfig(mode=\"wildcard\"),\n            ),\n        )\n\n\ndef test_direct_mode_rejects_gateway_block():\n    with pytest.raises(ValueError):\n        IngressConfig(\n            mode=\"direct\",\n            gateway=GatewayConfig(\n                address=\"gateway.opensandbox.io\",\n                route=GatewayRouteModeConfig(mode=\"header\"),\n            ),\n        )\n\n\ndef test_docker_runtime_rejects_gateway_ingress():\n    server_cfg = ServerConfig()\n    runtime_cfg = RuntimeConfig(type=\"docker\", execd_image=\"busybox:latest\")\n    with pytest.raises(ValueError):\n        AppConfig(\n            server=server_cfg,\n            runtime=runtime_cfg,\n            ingress=IngressConfig(\n                mode=\"gateway\",\n                gateway=GatewayConfig(\n                    address=\"gateway.opensandbox.io\",\n                    route=GatewayRouteModeConfig(mode=\"header\"),\n                ),\n            ),\n        )\n    # direct remains valid\n    app_cfg = AppConfig(\n        server=server_cfg,\n        runtime=runtime_cfg,\n        ingress=IngressConfig(mode=\"direct\"),\n    )\n    assert app_cfg.ingress.mode == \"direct\"\n\n\n# ============================================================================\n# StorageConfig Tests\n# ============================================================================\n\n\ndef test_storage_config_defaults():\n    \"\"\"StorageConfig should default to empty allowed_host_paths list.\"\"\"\n    cfg = StorageConfig()\n    assert cfg.allowed_host_paths == []\n\n\ndef test_storage_config_with_paths():\n    \"\"\"StorageConfig should accept explicit allowed_host_paths.\"\"\"\n    cfg = StorageConfig(allowed_host_paths=[\"/data/opensandbox\", \"/tmp/sandbox\"])\n    assert cfg.allowed_host_paths == [\"/data/opensandbox\", \"/tmp/sandbox\"]\n\n\ndef test_app_config_default_storage():\n    \"\"\"AppConfig should include default StorageConfig when not specified.\"\"\"\n    server_cfg = ServerConfig()\n    runtime_cfg = RuntimeConfig(type=\"docker\", execd_image=\"busybox:latest\")\n    app_cfg = AppConfig(server=server_cfg, runtime=runtime_cfg)\n    assert app_cfg.storage is not None\n    assert app_cfg.storage.allowed_host_paths == []\n\n\ndef test_load_config_with_storage_block(tmp_path, monkeypatch):\n    \"\"\"StorageConfig should be loaded from [storage] TOML block.\"\"\"\n    _reset_config(monkeypatch)\n    toml = textwrap.dedent(\n        \"\"\"\n        [server]\n        host = \"127.0.0.1\"\n        port = 9000\n\n        [runtime]\n        type = \"docker\"\n        execd_image = \"ghcr.io/opensandbox/platform:test\"\n\n        [router]\n        domain = \"opensandbox.io\"\n\n        [storage]\n        allowed_host_paths = [\"/data/opensandbox\", \"/tmp/sandbox\"]\n        \"\"\"\n    )\n    config_path = tmp_path / \"config.toml\"\n    config_path.write_text(toml)\n\n    loaded = config_module.load_config(config_path)\n    assert loaded.storage is not None\n    assert loaded.storage.allowed_host_paths == [\"/data/opensandbox\", \"/tmp/sandbox\"]\n\n\ndef test_load_config_without_storage_block_uses_defaults(tmp_path, monkeypatch):\n    \"\"\"AppConfig should use default StorageConfig when [storage] is not in TOML.\"\"\"\n    _reset_config(monkeypatch)\n    toml = textwrap.dedent(\n        \"\"\"\n        [server]\n        host = \"127.0.0.1\"\n        port = 9000\n\n        [runtime]\n        type = \"docker\"\n        execd_image = \"ghcr.io/opensandbox/platform:test\"\n\n        [router]\n        domain = \"opensandbox.io\"\n        \"\"\"\n    )\n    config_path = tmp_path / \"config.toml\"\n    config_path.write_text(toml)\n\n    loaded = config_module.load_config(config_path)\n    assert loaded.storage is not None\n    assert loaded.storage.allowed_host_paths == []\n\n\n# ============================================================================\n# SecureRuntimeConfig Tests\n# ============================================================================\n\n\ndef test_secure_runtime_empty_type_is_valid():\n    \"\"\"Empty type (default runc) should be valid.\"\"\"\n    cfg = config_module.SecureRuntimeConfig(type=\"\")\n    assert cfg.type == \"\"\n    assert cfg.docker_runtime is None\n    assert cfg.k8s_runtime_class is None\n\n\ndef test_secure_runtime_gvisor_with_docker_runtime_is_valid():\n    \"\"\"gVisor with docker_runtime should be valid.\"\"\"\n    cfg = config_module.SecureRuntimeConfig(\n        type=\"gvisor\",\n        docker_runtime=\"runsc\",\n        k8s_runtime_class=\"gvisor\",\n    )\n    assert cfg.type == \"gvisor\"\n    assert cfg.docker_runtime == \"runsc\"\n    assert cfg.k8s_runtime_class == \"gvisor\"\n\n\ndef test_secure_runtime_gvisor_with_k8s_runtime_class_is_valid():\n    \"\"\"gVisor with only k8s_runtime_class should be valid.\"\"\"\n    cfg = config_module.SecureRuntimeConfig(\n        type=\"gvisor\",\n        docker_runtime=None,\n        k8s_runtime_class=\"gvisor\",\n    )\n    assert cfg.type == \"gvisor\"\n    assert cfg.docker_runtime is None\n    assert cfg.k8s_runtime_class == \"gvisor\"\n\n\ndef test_secure_runtime_kata_with_runtimes_is_valid():\n    \"\"\"Kata with both runtimes should be valid.\"\"\"\n    cfg = config_module.SecureRuntimeConfig(\n        type=\"kata\",\n        docker_runtime=\"kata-runtime\",\n        k8s_runtime_class=\"kata-qemu\",\n    )\n    assert cfg.type == \"kata\"\n    assert cfg.docker_runtime == \"kata-runtime\"\n    assert cfg.k8s_runtime_class == \"kata-qemu\"\n\n\ndef test_secure_runtime_firecracker_with_k8s_runtime_is_valid():\n    \"\"\"Firecracker with k8s_runtime_class should be valid.\"\"\"\n    cfg = config_module.SecureRuntimeConfig(\n        type=\"firecracker\",\n        docker_runtime=\"\",\n        k8s_runtime_class=\"kata-fc\",\n    )\n    assert cfg.type == \"firecracker\"\n    assert cfg.docker_runtime == \"\"\n    assert cfg.k8s_runtime_class == \"kata-fc\"\n\n\ndef test_secure_runtime_firecracker_without_k8s_runtime_raises_error():\n    \"\"\"Firecracker without k8s_runtime_class should raise error.\"\"\"\n    with pytest.raises(ValueError) as exc:\n        config_module.SecureRuntimeConfig(\n            type=\"firecracker\",\n            docker_runtime=\"\",\n            k8s_runtime_class=None,\n        )\n    assert \"k8s_runtime_class\" in str(exc.value).lower()\n\n\ndef test_secure_runtime_gvisor_without_any_runtime_raises_error():\n    \"\"\"gVisor without any runtime configured should raise error.\"\"\"\n    with pytest.raises(ValueError) as exc:\n        config_module.SecureRuntimeConfig(\n            type=\"gvisor\",\n            docker_runtime=None,\n            k8s_runtime_class=None,\n        )\n    assert \"docker_runtime\" in str(exc.value).lower() or \"k8s_runtime_class\" in str(exc.value).lower()\n\n\ndef test_secure_runtime_kata_without_any_runtime_raises_error():\n    \"\"\"Kata without any runtime configured should raise error.\"\"\"\n    with pytest.raises(ValueError) as exc:\n        config_module.SecureRuntimeConfig(\n            type=\"kata\",\n            docker_runtime=None,\n            k8s_runtime_class=None,\n        )\n    assert \"docker_runtime\" in str(exc.value).lower() or \"k8s_runtime_class\" in str(exc.value).lower()\n\n\ndef test_secure_runtime_invalid_type_raises_error():\n    \"\"\"Invalid type should raise ValidationError.\"\"\"\n    with pytest.raises(Exception):\n        config_module.SecureRuntimeConfig(type=\"invalid_runtime\")\n\n\ndef test_app_config_with_secure_runtime():\n    \"\"\"AppConfig should parse secure_runtime section.\"\"\"\n    cfg = AppConfig(\n        runtime={\"type\": \"docker\", \"execd_image\": \"execd:v1\"},\n        secure_runtime={\n            \"type\": \"gvisor\",\n            \"docker_runtime\": \"runsc\",\n            \"k8s_runtime_class\": \"gvisor\",\n        },\n    )\n    assert cfg.secure_runtime is not None\n    assert cfg.secure_runtime.type == \"gvisor\"\n    assert cfg.secure_runtime.docker_runtime == \"runsc\"\n\n\ndef test_app_config_without_secure_runtime():\n    \"\"\"AppConfig without secure_runtime should have None.\"\"\"\n    cfg = AppConfig(\n        runtime={\"type\": \"docker\", \"execd_image\": \"execd:v1\"},\n    )\n    assert cfg.secure_runtime is None\n\n\ndef test_load_config_with_secure_runtime(tmp_path, monkeypatch):\n    \"\"\"SecureRuntimeConfig should be loaded from [secure_runtime] TOML block.\"\"\"\n    _reset_config(monkeypatch)\n    toml = textwrap.dedent(\n        \"\"\"\n        [server]\n        host = \"127.0.0.1\"\n        port = 9000\n\n        [runtime]\n        type = \"docker\"\n        execd_image = \"ghcr.io/opensandbox/platform:test\"\n\n        [secure_runtime]\n        type = \"gvisor\"\n        docker_runtime = \"runsc\"\n        k8s_runtime_class = \"gvisor\"\n        \"\"\"\n    )\n    config_path = tmp_path / \"config.toml\"\n    config_path.write_text(toml)\n\n    loaded = config_module.load_config(config_path)\n    assert loaded.secure_runtime is not None\n    assert loaded.secure_runtime.type == \"gvisor\"\n    assert loaded.secure_runtime.docker_runtime == \"runsc\"\n    assert loaded.secure_runtime.k8s_runtime_class == \"gvisor\"\n\n\ndef test_docker_runtime_with_firecracker_raises_error():\n    \"\"\"Docker runtime with Firecracker secure runtime should raise error.\n\n    Firecracker (kata-fc) is only available as a Kubernetes RuntimeClass,\n    not as a Docker OCI runtime. This test prevents the silent fallback\n    to runc which would bypass the intended microVM isolation.\n    \"\"\"\n    with pytest.raises(ValueError) as exc:\n        AppConfig(\n            runtime={\"type\": \"docker\", \"execd_image\": \"execd:v1\"},\n            secure_runtime={\n                \"type\": \"firecracker\",\n                \"k8s_runtime_class\": \"kata-fc\",\n            },\n        )\n    assert \"firecracker\" in str(exc.value).lower()\n    assert \"kubernetes\" in str(exc.value).lower()\n\n\ndef test_kubernetes_runtime_with_firecracker_is_valid():\n    \"\"\"Kubernetes runtime with Firecracker should be valid.\"\"\"\n    cfg = AppConfig(\n        runtime={\"type\": \"kubernetes\", \"execd_image\": \"execd:v1\"},\n        kubernetes={\"namespace\": \"default\"},\n        secure_runtime={\n            \"type\": \"firecracker\",\n            \"k8s_runtime_class\": \"kata-fc\",\n        },\n    )\n    assert cfg.runtime.type == \"kubernetes\"\n    assert cfg.secure_runtime is not None\n    assert cfg.secure_runtime.type == \"firecracker\"\n    assert cfg.secure_runtime.k8s_runtime_class == \"kata-fc\"\n\n\ndef test_egress_config_mode_literal():\n    assert EgressConfig(image=\"opensandbox/egress:v1\").mode == EGRESS_MODE_DNS\n    cfg = EgressConfig(image=\"opensandbox/egress:v1\", mode=EGRESS_MODE_DNS_NFT)\n    assert cfg.mode == EGRESS_MODE_DNS_NFT\n"
  },
  {
    "path": "server/tests/test_docker_endpoint.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport pytest\nfrom unittest.mock import MagicMock, patch\n\nfrom src.services.constants import (\n    OPEN_SANDBOX_EGRESS_AUTH_HEADER,\n    SANDBOX_EMBEDDING_PROXY_PORT_LABEL,\n    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,\n)\nfrom src.services.docker import DockerSandboxService\nfrom src.config import AppConfig, RuntimeConfig, DockerConfig, ServerConfig\n\n@pytest.fixture\ndef mock_docker_service():\n    \"\"\"Create a DockerSandboxService with mocked docker client.\"\"\"\n    # Setup base config\n    config = AppConfig(\n        server=ServerConfig(port=8080, host=\"0.0.0.0\"),\n        runtime=RuntimeConfig(type=\"docker\", execd_image=\"test/execd:latest\"),\n        router=None,\n        docker=DockerConfig(network_mode=\"bridge\"),\n    )\n\n    with patch(\"docker.from_env\") as mock_docker:\n        mock_client = MagicMock()\n        mock_docker.return_value = mock_client\n\n        # Initialize service\n        service = DockerSandboxService(config=config)\n        # Inject the mock client directly to ensure we control it\n        service.docker_client = mock_client\n\n        yield service, mock_client\n\ndef test_get_endpoint_host_mode(mock_docker_service):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"host\"\n    service.network_mode = \"host\"\n\n    mock_container = MagicMock()\n    mock_container.attrs = {\"State\": {\"Running\": True}}\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"10.0.0.1\"):\n        endpoint = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=False)\n        assert endpoint.endpoint == \"10.0.0.1:8080\"\n\n    endpoint_internal = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=True)\n    assert endpoint_internal.endpoint == \"127.0.0.1:8080\"\n\n\ndef test_get_endpoint_bridge_http_port(mock_docker_service):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        \"opensandbox.io/embedding-proxy-port\": \"50002\",\n        \"opensandbox.io/http-port\": \"50001\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"172.17.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"192.168.1.100\"):\n        endpoint = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=False)\n\n    assert endpoint.endpoint == \"192.168.1.100:50001\"\n\n\ndef test_get_endpoint_bridge_other_port_via_execd(mock_docker_service):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        \"opensandbox.io/embedding-proxy-port\": \"50002\",\n        \"opensandbox.io/http-port\": \"50001\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"172.17.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"192.168.1.100\"):\n        endpoint = service.get_endpoint(\"sbx-123\", 6000, resolve_internal=False)\n\n    assert endpoint.endpoint == \"192.168.1.100:50002/proxy/6000\"\n\n\ndef test_get_endpoint_bridge_egress_port_includes_auth_header(mock_docker_service):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        \"opensandbox.io/embedding-proxy-port\": \"50002\",\n        \"opensandbox.io/http-port\": \"50001\",\n        \"opensandbox.io/egress-auth-token\": \"egress-token\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"172.17.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"192.168.1.100\"):\n        endpoint = service.get_endpoint(\"sbx-123\", 18080, resolve_internal=False)\n\n    assert endpoint.endpoint == \"192.168.1.100:50002/proxy/18080\"\n    assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\n\ndef test_get_endpoint_bridge_non_egress_port_still_includes_instance_auth_header(\n    mock_docker_service,\n):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        SANDBOX_EMBEDDING_PROXY_PORT_LABEL: \"50002\",\n        SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"172.17.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"192.168.1.100\"):\n        endpoint = service.get_endpoint(\"sbx-123\", 44772, resolve_internal=False)\n\n    assert endpoint.endpoint == \"192.168.1.100:50002/proxy/44772\"\n    assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\ndef test_get_endpoint_bridge_internal_resolution(mock_docker_service):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"NetworkSettings\": {\"IPAddress\": \"10.0.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    endpoint = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=True)\n    assert endpoint.endpoint == \"10.0.0.5:8080\"\n\n\ndef test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_falls_back_to_host_mapped_endpoint(\n    mock_docker_service,\n):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        SANDBOX_EMBEDDING_PROXY_PORT_LABEL: \"50002\",\n        SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    endpoint = service.get_endpoint(\"sbx-123\", 18080, resolve_internal=True)\n\n    assert endpoint.endpoint == \"127.0.0.1:50002/proxy/18080\"\n    assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\n\ndef test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_ignores_container_ip(\n    mock_docker_service,\n):\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        SANDBOX_EMBEDDING_PROXY_PORT_LABEL: \"50002\",\n        SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"10.0.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    endpoint = service.get_endpoint(\"sbx-123\", 18080, resolve_internal=True)\n\n    assert endpoint.endpoint == \"127.0.0.1:50002/proxy/18080\"\n    assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\n\ndef test_get_endpoint_bridge_internal_resolution_with_egress_sidecar_uses_proxy_host_not_eip(\n    mock_docker_service,\n):\n    service, mock_client = mock_docker_service\n    service.app_config.server.host = \"0.0.0.0\"\n    service.app_config.server.eip = \"203.0.113.10\"\n    service.app_config.docker.network_mode = \"bridge\"\n    service.network_mode = \"bridge\"\n\n    labels = {\n        SANDBOX_EMBEDDING_PROXY_PORT_LABEL: \"50002\",\n        SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY: \"egress-token\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    endpoint = service.get_endpoint(\"sbx-123\", 18080, resolve_internal=True)\n\n    assert endpoint.endpoint == \"127.0.0.1:50002/proxy/18080\"\n    assert endpoint.headers == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\n\ndef test_get_endpoint_bridge_uses_docker_host_ip_when_server_in_container():\n    \"\"\"When server runs in container (host=0.0.0.0), endpoint uses [docker].host_ip.\"\"\"\n    config = AppConfig(\n        server=ServerConfig(port=8080, host=\"0.0.0.0\"),\n        runtime=RuntimeConfig(type=\"docker\", execd_image=\"test/execd:latest\"),\n        router=None,\n        docker=DockerConfig(network_mode=\"bridge\", host_ip=\"10.57.1.91\"),\n    )\n    with patch(\"docker.from_env\") as mock_docker:\n        mock_client = MagicMock()\n        mock_docker.return_value = mock_client\n        service = DockerSandboxService(config=config)\n        service.docker_client = mock_client\n\n    labels = {\n        \"opensandbox.io/embedding-proxy-port\": \"40109\",\n        \"opensandbox.io/http-port\": \"50001\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\"IPAddress\": \"172.17.0.5\"},\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.docker._running_inside_docker_container\", return_value=True):\n        endpoint = service.get_endpoint(\"sbx-123\", 44772, resolve_internal=False)\n\n    assert endpoint.endpoint == \"10.57.1.91:40109/proxy/44772\"\n\n\n# ---------------------------------------------------------------------------\n# User-defined network endpoint tests\n# ---------------------------------------------------------------------------\n\ndef test_get_endpoint_user_defined_network_external(mock_docker_service):\n    \"\"\"External endpoint for a user-defined network uses host port bindings, same as bridge.\"\"\"\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"my-app-net\"\n    service.network_mode = \"my-app-net\"\n\n    labels = {\n        \"opensandbox.io/embedding-proxy-port\": \"51000\",\n        \"opensandbox.io/http-port\": \"51001\",\n    }\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"Config\": {\"Labels\": labels},\n        \"NetworkSettings\": {\n            \"IPAddress\": \"\",\n            \"Networks\": {\"my-app-net\": {\"IPAddress\": \"192.168.100.5\"}},\n        },\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    with patch(\"src.services.sandbox_service.SandboxService._resolve_bind_ip\", return_value=\"10.0.1.1\"):\n        ep_http = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=False)\n        ep_proxy = service.get_endpoint(\"sbx-123\", 5000, resolve_internal=False)\n\n    assert ep_http.endpoint == \"10.0.1.1:51001\"\n    assert ep_proxy.endpoint == \"10.0.1.1:51000/proxy/5000\"\n\n\ndef test_get_endpoint_user_defined_network_internal_prefers_configured_network(mock_docker_service):\n    \"\"\"resolve_internal=True on a user-defined network returns the IP from that specific network.\"\"\"\n    service, mock_client = mock_docker_service\n    service.app_config.docker.network_mode = \"my-app-net\"\n    service.network_mode = \"my-app-net\"\n\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"State\": {\"Running\": True},\n        \"NetworkSettings\": {\n            # top-level IPAddress is empty for user-defined networks\n            \"IPAddress\": \"\",\n            \"Networks\": {\n                \"bridge\": {\"IPAddress\": \"172.17.0.3\"},\n                \"my-app-net\": {\"IPAddress\": \"192.168.100.5\"},\n            },\n        },\n    }\n    mock_client.containers.list.return_value = [mock_container]\n\n    endpoint = service.get_endpoint(\"sbx-123\", 8080, resolve_internal=True)\n\n    # Must use the IP from the configured network, not the default bridge entry\n    assert endpoint.endpoint == \"192.168.100.5:8080\"\n\n\ndef test_extract_bridge_ip_falls_back_when_named_network_ip_missing(mock_docker_service):\n    \"\"\"_extract_bridge_ip falls back to any available network IP when the named entry is empty.\"\"\"\n    service, _ = mock_docker_service\n    service.network_mode = \"my-app-net\"\n\n    mock_container = MagicMock()\n    mock_container.attrs = {\n        \"NetworkSettings\": {\n            \"IPAddress\": \"\",\n            \"Networks\": {\n                \"my-app-net\": {\"IPAddress\": \"\"},   # empty — simulate container still attaching\n                \"bridge\": {\"IPAddress\": \"172.17.0.9\"},\n            },\n        },\n    }\n\n    ip = service._extract_bridge_ip(mock_container)\n    assert ip == \"172.17.0.9\"\n"
  },
  {
    "path": "server/tests/test_docker_path_fix.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport posixpath\nfrom unittest.mock import MagicMock, patch\nfrom src.services.docker import DockerSandboxService, EXECED_INSTALL_PATH, BOOTSTRAP_PATH\nfrom src.config import AppConfig, RuntimeConfig, ServerConfig\n\ndef _app_config() -> AppConfig:\n    return AppConfig(\n        server=ServerConfig(),\n        runtime=RuntimeConfig(type=\"docker\", execd_image=\"ghcr.io/opensandbox/platform:latest\"),\n    )\n\ndef test_container_internal_paths_use_posix_style():\n    \"\"\"Verify that container internal paths always use forward slashes.\"\"\"\n    assert \"\\\\\" not in EXECED_INSTALL_PATH\n    assert \"/\" in EXECED_INSTALL_PATH\n    assert \"\\\\\" not in BOOTSTRAP_PATH\n    assert \"/\" in BOOTSTRAP_PATH\n    assert EXECED_INSTALL_PATH == \"/opt/opensandbox/execd\"\n    assert BOOTSTRAP_PATH == \"/opt/opensandbox/bootstrap.sh\"\n\n@patch(\"src.services.docker.docker\")\ndef test_copy_execd_to_container_uses_posix_dirname(mock_docker):\n    \"\"\"Verify _copy_execd_to_container uses posixpath for target directory.\"\"\"\n    service = DockerSandboxService(config=_app_config())\n    mock_container = MagicMock()\n    \n    # Mock _fetch_execd_archive and _ensure_directory\n    with patch.object(service, \"_fetch_execd_archive\", return_value=b\"fake-archive\"), \\\n         patch.object(service, \"_ensure_directory\") as mock_ensure_dir, \\\n         patch.object(service, \"_docker_operation\"):\n        \n        service._copy_execd_to_container(mock_container, \"test-sandbox\")\n        \n        # The target_parent should be posixpath.dirname(EXECED_INSTALL_PATH)\n        expected_parent = posixpath.dirname(EXECED_INSTALL_PATH.rstrip(\"/\")) or \"/\"\n        mock_ensure_dir.assert_called_once_with(mock_container, expected_parent, \"test-sandbox\")\n\n@patch(\"src.services.docker.docker\")\ndef test_install_bootstrap_script_uses_posix_dirname(mock_docker):\n    \"\"\"Verify _install_bootstrap_script uses posixpath for script directory.\"\"\"\n    service = DockerSandboxService(config=_app_config())\n    mock_container = MagicMock()\n    \n    with patch.object(service, \"_ensure_directory\") as mock_ensure_dir, \\\n         patch.object(service, \"_docker_operation\"):\n        \n        service._install_bootstrap_script(mock_container, \"test-sandbox\")\n        \n        # The script_dir should be posixpath.dirname(BOOTSTRAP_PATH)\n        expected_dir = posixpath.dirname(BOOTSTRAP_PATH)\n        mock_ensure_dir.assert_called_once_with(mock_container, expected_dir, \"test-sandbox\")\n"
  },
  {
    "path": "server/tests/test_docker_service.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, cast\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nfrom docker.errors import DockerException, NotFound as DockerNotFound\nimport pytest\nfrom fastapi import HTTPException, status\nfrom pydantic import ValidationError\n\nfrom src.config import (\n    AppConfig,\n    EGRESS_MODE_DNS,\n    EgressConfig,\n    RuntimeConfig,\n    ServerConfig,\n    StorageConfig,\n    IngressConfig,\n)\nfrom src.services.constants import EGRESS_MODE_ENV, OPENSANDBOX_EGRESS_TOKEN\nfrom src.services.constants import (\n    SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,\n    SANDBOX_EXPIRES_AT_LABEL,\n    SANDBOX_ID_LABEL,\n    SANDBOX_MANUAL_CLEANUP_LABEL,\n    SANDBOX_OSSFS_MOUNTS_LABEL,\n    SandboxErrorCodes,\n)\nfrom src.services.docker import DockerSandboxService, PendingSandbox\nfrom src.services.helpers import parse_memory_limit, parse_nano_cpus, parse_timestamp\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    CreateSandboxResponse,\n    Host,\n    ImageSpec,\n    NetworkPolicy,\n    ListSandboxesRequest,\n    OSSFS,\n    PVC,\n    ResourceLimits,\n    Sandbox,\n    SandboxFilter,\n    SandboxStatus,\n    Volume,\n)\n\n\ndef _app_config() -> AppConfig:\n    return AppConfig(\n        server=ServerConfig(),\n        runtime=RuntimeConfig(type=\"docker\", execd_image=\"ghcr.io/opensandbox/platform:latest\"),\n        ingress=IngressConfig(mode=\"direct\"),\n    )\n\n\ndef test_parse_memory_limit_handles_units():\n    assert parse_memory_limit(\"512Mi\") == 512 * 1024 * 1024\n    assert parse_memory_limit(\"1G\") == 1_000_000_000\n    assert parse_memory_limit(\"2gi\") == 2 * 1024**3\n    assert parse_memory_limit(\"invalid\") is None\n\n\ndef test_parse_nano_cpus():\n    assert parse_nano_cpus(\"500m\") == 500_000_000\n    assert parse_nano_cpus(\"2\") == 2_000_000_000\n    assert parse_nano_cpus(\"bad\") is None\n\n\ndef test_parse_timestamp_defaults_on_invalid():\n    ts = parse_timestamp(\"0001-01-01T00:00:00Z\")\n    assert ts.tzinfo is not None\n    future = parse_timestamp(\"2024-01-01T00:00:00Z\")\n    assert future.year == 2024\n\n\ndef test_env_allows_empty_string_and_skips_none():\n    # Use base config helper\n    DockerSandboxService(config=_app_config())\n    # Build request with mixed env values\n    req = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={\"FOO\": \"bar\", \"EMPTY\": \"\", \"NONE\": None},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n    # Validate env handling\n    env_dict = req.env or {}\n    environment = []\n    for key, value in env_dict.items():\n        if value is None:\n            continue\n        environment.append(f\"{key}={value}\")\n\n    assert \"FOO=bar\" in environment\n    assert \"EMPTY=\" in environment  # empty string preserved\n    # None should be skipped\n    assert all(not item.startswith(\"NONE=\") for item in environment)\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_applies_security_defaults(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_client.api.create_host_config.return_value = {\n        \"security_opt\": [\"no-new-privileges:true\"],\n        \"cap_drop\": _app_config().docker.drop_capabilities,\n        \"pids_limit\": _app_config().docker.pids_limit,\n    }\n    mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n    mock_client.containers.get.return_value = MagicMock()\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_prepare_sandbox_runtime\"),\n    ):\n        await service.create_sandbox(request)\n\n    host_config = mock_client.api.create_container.call_args.kwargs[\"host_config\"]\n    assert \"no-new-privileges:true\" in host_config.get(\"security_opt\", [])\n    assert host_config.get(\"cap_drop\") == service.app_config.docker.drop_capabilities\n    assert host_config.get(\"pids_limit\") == service.app_config.docker.pids_limit\n\n\n@pytest.mark.parametrize(\n    \"runtime_exc, expected_status, expect_wrapped_error\",\n    [\n        (\n            RuntimeError(\"tarfile error\"),\n            status.HTTP_500_INTERNAL_SERVER_ERROR,\n            True,\n        ),\n        (\n            HTTPException(\n                status_code=status.HTTP_409_CONFLICT,\n                detail={\"code\": \"CONFLICT\", \"message\": \"conflict error\"},\n            ),\n            status.HTTP_409_CONFLICT,\n            False,\n        ),\n    ],\n)\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_prepare_runtime_failure_triggers_cleanup(\n    mock_docker, runtime_exc, expected_status, expect_wrapped_error\n):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n    mock_container = MagicMock()\n    mock_client.containers.get.return_value = mock_container\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_prepare_sandbox_runtime\", side_effect=runtime_exc),\n    ):\n        with pytest.raises(HTTPException) as exc:\n            await service.create_sandbox(request)\n\n    mock_container.remove.assert_called_with(force=True)\n\n    assert exc.value.status_code == expected_status\n\n    if expect_wrapped_error:\n        assert str(runtime_exc) in str(exc.value.detail[\"message\"])\n    else:\n        assert exc.value.detail[\"message\"] == runtime_exc.detail[\"message\"]\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_rejects_invalid_metadata(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={\"Bad Key\": \"ok\"},  # space is invalid for label key\n        entrypoint=[\"python\"],\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n    mock_client.containers.create.assert_not_called()\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    config = _app_config()\n    config.server.max_sandbox_timeout_seconds = 3600\n    service = DockerSandboxService(config=config)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=7200,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n    assert \"configured maximum of 3600s\" in exc.value.detail[\"message\"]\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_requires_entrypoint(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n    request.entrypoint = []\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_ENTRYPOINT\n    mock_client.containers.create.assert_not_called()\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_network_policy_rejected_on_host_mode(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"host\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n        networkPolicy=NetworkPolicy(default_action=\"deny\", egress=[]),\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_network_policy_requires_egress_image(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"bridge\"\n    cfg.egress = None\n    service = DockerSandboxService(config=cfg)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n        networkPolicy=NetworkPolicy(default_action=\"deny\", egress=[]),\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_egress_sidecar_injection_and_capabilities(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n\n    def host_cfg_side_effect(**kwargs):\n        return kwargs\n\n    mock_client.api.create_host_config.side_effect = host_cfg_side_effect\n    mock_client.api.create_container.side_effect = [\n        {\"Id\": \"sidecar-id\"},\n        {\"Id\": \"main-id\"},\n    ]\n    mock_client.containers.get.side_effect = [MagicMock(id=\"sidecar-id\"), MagicMock(id=\"main-id\")]\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"bridge\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    req = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n        networkPolicy=NetworkPolicy(default_action=\"deny\", egress=[]),\n    )\n\n    with (\n        patch(\"src.services.docker.generate_egress_token\", return_value=\"egress-token\"),\n        patch.object(service, \"_allocate_distinct_host_ports\", return_value=(44772, 8080)),\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_prepare_sandbox_runtime\"),\n    ):\n        await service.create_sandbox(req)\n\n    assert len(mock_client.api.create_container.call_args_list) == 2\n    sidecar_call = mock_client.api.create_container.call_args_list[0]\n    main_call = mock_client.api.create_container.call_args_list[1]\n    sidecar_kwargs = sidecar_call.kwargs\n    main_kwargs = main_call.kwargs\n\n    # Sidecar host config should have NET_ADMIN and port bindings\n    assert \"NET_ADMIN\" in sidecar_kwargs[\"host_config\"][\"cap_add\"]\n    assert \"44772\" in sidecar_kwargs[\"host_config\"][\"port_bindings\"]\n    assert \"8080\" in sidecar_kwargs[\"host_config\"][\"port_bindings\"]\n\n    # Main container should share sidecar netns, drop NET_ADMIN, and have no port bindings\n    assert main_kwargs[\"host_config\"][\"network_mode\"] == \"container:sidecar-id\"\n    assert \"NET_ADMIN\" in set(main_kwargs[\"host_config\"].get(\"cap_drop\") or [])\n    assert \"port_bindings\" not in main_kwargs[\"host_config\"]\n\n    # Main container labels should carry host port info\n    labels = main_kwargs[\"labels\"]\n    assert labels.get(\"opensandbox.io/embedding-proxy-port\")\n    assert labels.get(\"opensandbox.io/http-port\")\n    assert labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] == \"egress-token\"\n\n    sidecar_env = sidecar_kwargs[\"environment\"]\n    assert f\"{OPENSANDBOX_EGRESS_TOKEN}=egress-token\" in sidecar_env\n    assert f\"{EGRESS_MODE_ENV}={EGRESS_MODE_DNS}\" in sidecar_env\n\n\n# ---------------------------------------------------------------------------\n# User-defined network tests\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_network_policy_rejected_on_user_defined_network(mock_docker):\n    \"\"\"networkPolicy must be rejected when network_mode is a user-defined named network.\"\"\"\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"my-custom-net\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n        networkPolicy=NetworkPolicy(default_action=\"deny\", egress=[]),\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n    assert \"my-custom-net\" in exc.value.detail[\"message\"]\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_fails_when_user_defined_network_not_found(mock_docker):\n    \"\"\"create_sandbox raises 400 with a clear message when the named network does not exist.\"\"\"\n    from docker.errors import NotFound as DockerNotFound\n\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_client.networks.get.side_effect = DockerNotFound(\"network not found\")\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"missing-net\"\n    service = DockerSandboxService(config=cfg)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    with pytest.raises(HTTPException) as exc:\n        await service.create_sandbox(request)\n\n    assert exc.value.status_code == status.HTTP_400_BAD_REQUEST\n    assert exc.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n    assert \"missing-net\" in exc.value.detail[\"message\"]\n    assert \"docker network create\" in exc.value.detail[\"message\"]\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_user_defined_network_uses_correct_network_mode(mock_docker):\n    \"\"\"Containers created on a user-defined network use the network name as network_mode.\"\"\"\n\n    def host_cfg_side_effect(**kwargs):\n        return kwargs\n\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_client.networks.get.return_value = MagicMock()  # network exists\n    mock_client.api.create_host_config.side_effect = host_cfg_side_effect\n    mock_client.api.create_container.return_value = {\"Id\": \"main-id\"}\n    mock_client.containers.get.return_value = MagicMock(id=\"main-id\")\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"my-app-net\"\n    service = DockerSandboxService(config=cfg)\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_prepare_sandbox_runtime\"),\n    ):\n        await service.create_sandbox(request)\n\n    call_kwargs = mock_client.api.create_container.call_args.kwargs\n    assert call_kwargs[\"host_config\"][\"network_mode\"] == \"my-app-net\"\n\n\n@patch(\"src.services.docker.docker\")\ndef test_validate_network_skipped_for_builtin_modes(mock_docker):\n    \"\"\"_validate_network_exists does NOT call the Docker API for host or bridge modes.\"\"\"\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    for mode in (\"host\", \"bridge\", \"none\"):\n        mock_client.networks.get.reset_mock()\n        cfg = _app_config()\n        cfg.docker.network_mode = mode\n        service = DockerSandboxService(config=cfg)\n        service._validate_network_exists()\n        mock_client.networks.get.assert_not_called()\n\n\n@patch(\"src.services.docker.docker\")\ndef test_egress_sidecar_cleanup_uses_api_remove_when_lookup_fails(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n\n    def host_cfg_side_effect(**kwargs):\n        return kwargs\n\n    mock_client.api.create_host_config.side_effect = host_cfg_side_effect\n    mock_client.api.create_container.return_value = {\"Id\": \"sidecar-id\"}\n    mock_client.containers.get.side_effect = DockerException(\"lookup failed\")\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"bridge\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_docker_operation\") as mock_op,\n    ):\n        mock_op.return_value.__enter__.return_value = None\n        mock_op.return_value.__exit__.return_value = None\n\n        with pytest.raises(HTTPException) as exc:\n            service._start_egress_sidecar(\n                \"sandbox-id\",\n                NetworkPolicy(defaultAction=\"deny\", egress=[]),\n                egress_token=\"egress-token\",\n                host_execd_port=44772,\n                host_http_port=8080,\n            )\n\n    detail = exc.value.detail\n    assert isinstance(detail, dict)\n    typed_detail = cast(dict[str, Any], detail)\n    assert exc.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n    assert typed_detail[\"message\"] == \"Egress sidecar container failed to start.\"\n    mock_client.api.remove_container.assert_called_once_with(\"sidecar-id\", force=True)\n\n\n@patch(\"src.services.docker.docker\")\ndef test_egress_sidecar_missing_id_preserves_specific_error(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n\n    def host_cfg_side_effect(**kwargs):\n        return kwargs\n\n    mock_client.api.create_host_config.side_effect = host_cfg_side_effect\n    mock_client.api.create_container.return_value = {}\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"bridge\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_docker_operation\") as mock_op,\n    ):\n        mock_op.return_value.__enter__.return_value = None\n        mock_op.return_value.__exit__.return_value = None\n\n        with pytest.raises(HTTPException) as exc:\n            service._start_egress_sidecar(\n                \"sandbox-id\",\n                NetworkPolicy(defaultAction=\"deny\", egress=[]),\n                egress_token=\"egress-token\",\n                host_execd_port=44772,\n                host_http_port=8080,\n            )\n\n    detail = exc.value.detail\n    assert isinstance(detail, dict)\n    typed_detail = cast(dict[str, Any], detail)\n    assert exc.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n    assert typed_detail[\"message\"] == \"Docker did not return an egress sidecar container ID.\"\n    mock_client.containers.get.assert_not_called()\n    mock_client.api.remove_container.assert_not_called()\n\n\n@patch(\"src.services.docker.docker\")\ndef test_egress_sidecar_cleanup_wraps_unexpected_lookup_error(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n\n    def host_cfg_side_effect(**kwargs):\n        return kwargs\n\n    mock_client.api.create_host_config.side_effect = host_cfg_side_effect\n    mock_client.api.create_container.return_value = {\"Id\": \"sidecar-id\"}\n    mock_client.containers.get.side_effect = RuntimeError(\"lookup failed\")\n    mock_docker.from_env.return_value = mock_client\n\n    cfg = _app_config()\n    cfg.docker.network_mode = \"bridge\"\n    cfg.egress = EgressConfig(image=\"egress:latest\")\n    service = DockerSandboxService(config=cfg)\n\n    with (\n        patch.object(service, \"_ensure_image_available\"),\n        patch.object(service, \"_docker_operation\") as mock_op,\n    ):\n        mock_op.return_value.__enter__.return_value = None\n        mock_op.return_value.__exit__.return_value = None\n\n        with pytest.raises(HTTPException) as exc:\n            service._start_egress_sidecar(\n                \"sandbox-id\",\n                NetworkPolicy(defaultAction=\"deny\", egress=[]),\n                egress_token=\"egress-token\",\n                host_execd_port=44772,\n                host_http_port=8080,\n            )\n\n    detail = exc.value.detail\n    assert isinstance(detail, dict)\n    typed_detail = cast(dict[str, Any], detail)\n    assert exc.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n    assert typed_detail[\"code\"] == SandboxErrorCodes.CONTAINER_START_FAILED\n    assert typed_detail[\"message\"] == \"Egress sidecar container failed to start.\"\n    mock_client.api.remove_container.assert_called_once_with(\"sidecar-id\", force=True)\n\n\ndef test_expire_cleans_sidecar():\n    service = DockerSandboxService(config=_app_config())\n    mock_container = MagicMock()\n    mock_container.attrs = {\"State\": {\"Running\": False}, \"Config\": {\"Labels\": {}}}\n    mock_container.kill = MagicMock()\n    mock_container.remove = MagicMock()\n\n    with (\n        patch.object(service, \"_get_container_by_sandbox_id\", return_value=mock_container),\n        patch.object(service, \"_remove_expiration_tracking\") as mock_remove,\n        patch.object(service, \"_cleanup_egress_sidecar\") as mock_cleanup,\n        patch.object(service, \"_docker_operation\") as mock_op,\n    ):\n        mock_op.return_value.__enter__.return_value = None\n        mock_op.return_value.__exit__.return_value = None\n        service._expire_sandbox(\"sandbox-id\")\n\n    mock_cleanup.assert_called_once_with(\"sandbox-id\")\n    mock_remove.assert_called_once()\n\n\ndef test_restore_cleans_orphan_sidecar():\n    cfg = _app_config()\n    service = DockerSandboxService(config=cfg)\n\n    orphan_sidecar = MagicMock()\n    orphan_sidecar.attrs = {\n        \"Config\": {\"Labels\": {\"opensandbox.io/egress-sidecar-for\": \"orphan-id\"}}\n    }\n\n    with (\n        patch.object(service.docker_client.containers, \"list\", return_value=[orphan_sidecar]),\n        patch.object(service, \"_get_container_by_sandbox_id\") as mock_get,\n        patch.object(service, \"_cleanup_egress_sidecar\") as mock_cleanup,\n    ):\n        mock_get.side_effect = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={})\n        service._restore_existing_sandboxes()\n\n    mock_cleanup.assert_called_once_with(\"orphan-id\")\n\n\ndef test_prepare_creation_context_allows_manual_cleanup():\n    service = DockerSandboxService(config=_app_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\"],\n    )\n\n    _, _, expires_at = service._prepare_creation_context(request)\n\n    assert expires_at is None\n\n\ndef test_build_labels_marks_manual_cleanup_without_expiration():\n    service = DockerSandboxService(config=_app_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={\"team\": \"manual\"},\n        entrypoint=[\"python\"],\n    )\n\n    labels, _ = service._build_labels_and_env(\"sandbox-manual\", request, None)\n\n    assert labels[SANDBOX_ID_LABEL] == \"sandbox-manual\"\n    assert labels[SANDBOX_MANUAL_CLEANUP_LABEL] == \"true\"\n    assert \"opensandbox.io/expires-at\" not in labels\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_with_manual_cleanup_completes_full_create_path(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        resourceLimits=ResourceLimits(root={}),\n        env={\"DEBUG\": \"1\"},\n        metadata={\"team\": \"manual\"},\n        entrypoint=[\"python\"],\n    )\n\n    with (\n        patch.object(service, \"_create_and_start_container\") as mock_create,\n        patch.object(service, \"_schedule_expiration\") as mock_schedule,\n    ):\n        response = await service.create_sandbox(request)\n\n    assert response.expires_at is None\n    assert response.metadata == {\"team\": \"manual\"}\n    assert response.entrypoint == [\"python\"]\n    mock_create.assert_called_once()\n    mock_schedule.assert_not_called()\n\n\ndef test_restore_existing_sandboxes_ignores_manual_cleanup_without_warning():\n    service = DockerSandboxService(config=_app_config())\n    manual_container = MagicMock()\n    manual_container.attrs = {\n        \"Config\": {\n            \"Labels\": {\n                SANDBOX_ID_LABEL: \"manual-id\",\n                SANDBOX_MANUAL_CLEANUP_LABEL: \"true\",\n            }\n        }\n    }\n\n    with (\n        patch.object(service.docker_client.containers, \"list\", return_value=[manual_container]),\n        patch(\"src.services.docker.logger.warning\") as mock_warning,\n        patch.object(service, \"_schedule_expiration\") as mock_schedule,\n    ):\n        service._restore_existing_sandboxes()\n\n    mock_schedule.assert_not_called()\n    mock_warning.assert_not_called()\n\n\ndef test_renew_expiration_rejects_manual_cleanup_sandbox():\n    service = DockerSandboxService(config=_app_config())\n    container = MagicMock()\n    container.attrs = {\n        \"Config\": {\n            \"Labels\": {\n                SANDBOX_ID_LABEL: \"manual-id\",\n                SANDBOX_MANUAL_CLEANUP_LABEL: \"true\",\n            }\n        }\n    }\n    request = MagicMock(expires_at=datetime.now(timezone.utc) + timedelta(hours=1))\n\n    with patch.object(service, \"_get_container_by_sandbox_id\", return_value=container):\n        with pytest.raises(HTTPException) as exc_info:\n            service.renew_expiration(\"manual-id\", request)\n\n    assert exc_info.value.status_code == status.HTTP_409_CONFLICT\n    assert exc_info.value.detail[\"message\"] == \"Sandbox manual-id does not have automatic expiration enabled.\"\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_create_sandbox_async_returns_provisioning(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={\"team\": \"async\"},\n        entrypoint=[\"python\", \"app.py\"],\n    )\n\n    with patch.object(service, \"create_sandbox\", new_callable=AsyncMock) as mock_sync:\n        mock_sync.return_value = CreateSandboxResponse(\n            id=\"sandbox-sync\",\n            status=SandboxStatus(\n                state=\"Running\",\n                reason=\"CONTAINER_RUNNING\",\n                message=\"started\",\n                last_transition_at=datetime.now(timezone.utc),\n            ),\n            metadata={\"team\": \"async\"},\n            expiresAt=datetime.now(timezone.utc),\n            createdAt=datetime.now(timezone.utc),\n            entrypoint=[\"python\", \"app.py\"],\n        )\n        response = await service.create_sandbox(request)\n\n    assert response.status.state == \"Running\"\n    assert response.metadata == {\"team\": \"async\"}\n    mock_sync.assert_called_once()\n\n\n@pytest.mark.asyncio\n@patch(\"src.services.docker.docker\")\nasync def test_get_sandbox_returns_pending_state(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n\n    request = CreateSandboxRequest(\n        image=ImageSpec(uri=\"python:3.11\"),\n        timeout=120,\n        resourceLimits=ResourceLimits(root={}),\n        env={},\n        metadata={},\n        entrypoint=[\"python\", \"app.py\"],\n    )\n\n    with patch.object(service, \"create_sandbox\", new_callable=AsyncMock) as mock_sync:\n        mock_sync.return_value = CreateSandboxResponse(\n            id=\"sandbox-sync\",\n            status=SandboxStatus(\n                state=\"Running\",\n                reason=\"CONTAINER_RUNNING\",\n                message=\"started\",\n                last_transition_at=datetime.now(timezone.utc),\n            ),\n            metadata={},\n            expiresAt=datetime.now(timezone.utc),\n            createdAt=datetime.now(timezone.utc),\n            entrypoint=[\"python\", \"app.py\"],\n        )\n        response = await service.create_sandbox(request)\n\n    assert response.status.state == \"Running\"\n    assert response.entrypoint == [\"python\", \"app.py\"]\n\n\n@patch(\"src.services.docker.docker\")\ndef test_list_sandboxes_deduplicates_container_and_pending(mock_docker):\n    # Build a realistic container mock to avoid parse_timestamp errors.\n    container = MagicMock()\n    container.attrs = {\n        \"Config\": {\"Labels\": {SANDBOX_ID_LABEL: \"sandbox-123\"}},\n        \"Created\": \"2025-01-01T00:00:00Z\",\n        \"State\": {\n            \"Status\": \"running\",\n            \"Running\": True,\n            \"FinishedAt\": \"0001-01-01T00:00:00Z\",\n            \"ExitCode\": 0,\n        },\n    }\n    container.image = MagicMock(tags=[\"image:latest\"], short_id=\"sha-image\")\n\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = [container]\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    sandbox_id = \"sandbox-123\"\n\n    # Prepare container and pending representations\n    container_sandbox = Sandbox(\n        id=sandbox_id,\n        image=ImageSpec(uri=\"image:latest\"),\n        status=SandboxStatus(\n            state=\"Running\",\n            reason=\"CONTAINER_RUNNING\",\n            message=\"running\",\n            last_transition_at=datetime.now(timezone.utc),\n        ),\n        metadata={\"team\": \"c\"},\n        entrypoint=[\"/bin/sh\"],\n        expiresAt=datetime.now(timezone.utc),\n        createdAt=datetime.now(timezone.utc),\n    )\n    # Force container state to be returned\n    service._container_to_sandbox = MagicMock(return_value=container_sandbox)\n\n    response = service.list_sandboxes(ListSandboxesRequest(filter=SandboxFilter(), pagination=None))\n\n    assert len(response.items) == 1\n    assert response.items[0].status.state == \"Running\"\n    assert response.items[0].metadata == {\"team\": \"c\"}\n\n\n@patch(\"src.services.docker.docker\")\ndef test_get_sandbox_prefers_container_over_pending(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    sandbox_id = \"sandbox-abc\"\n\n    pending_status = SandboxStatus(\n        state=\"Pending\",\n        reason=\"SANDBOX_SCHEDULED\",\n        message=\"pending\",\n        last_transition_at=datetime.now(timezone.utc),\n    )\n    service._pending_sandboxes[sandbox_id] = PendingSandbox(\n        request=MagicMock(metadata={}, entrypoint=[\"/bin/sh\"], image=ImageSpec(uri=\"image:latest\")),\n        created_at=datetime.now(timezone.utc),\n        expires_at=datetime.now(timezone.utc),\n        status=pending_status,\n    )\n\n    container_sandbox = Sandbox(\n        id=sandbox_id,\n        image=ImageSpec(uri=\"image:latest\"),\n        status=SandboxStatus(\n            state=\"Running\",\n            reason=\"CONTAINER_RUNNING\",\n            message=\"running\",\n            last_transition_at=datetime.now(timezone.utc),\n        ),\n        metadata={},\n        entrypoint=[\"/bin/sh\"],\n        expiresAt=datetime.now(timezone.utc),\n        createdAt=datetime.now(timezone.utc),\n    )\n\n    service._get_container_by_sandbox_id = MagicMock(return_value=MagicMock())\n    service._container_to_sandbox = MagicMock(return_value=container_sandbox)\n\n    sandbox = service.get_sandbox(sandbox_id)\n    assert sandbox.status.state == \"Running\"\n    assert sandbox.entrypoint == [\"/bin/sh\"]\n\n\n@patch(\"src.services.docker.docker\")\ndef test_async_worker_cleans_up_leftover_container_on_failure(mock_docker):\n    mock_client = MagicMock()\n    mock_client.containers.list.return_value = []\n    mock_docker.from_env.return_value = mock_client\n\n    service = DockerSandboxService(config=_app_config())\n    sandbox_id = \"sandbox-fail\"\n    created_at = datetime.now(timezone.utc)\n    expires_at = created_at\n\n    pending_status = SandboxStatus(\n        state=\"Pending\",\n        reason=\"SANDBOX_SCHEDULED\",\n        message=\"pending\",\n        last_transition_at=created_at,\n    )\n    service._pending_sandboxes[sandbox_id] = PendingSandbox(\n        request=MagicMock(metadata={}, entrypoint=[\"/bin/sh\"], image=ImageSpec(uri=\"image:latest\")),\n        created_at=created_at,\n        expires_at=expires_at,\n        status=pending_status,\n    )\n\n    service._provision_sandbox = MagicMock(\n        side_effect=HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail={\"message\": \"boom\"},\n        )\n    )\n    service._cleanup_failed_containers = MagicMock()\n\n    service._async_provision_worker(\n        sandbox_id,\n        MagicMock(),\n        created_at,\n        expires_at,\n    )\n\n    service._cleanup_failed_containers.assert_called_once_with(sandbox_id)\n    assert service._pending_sandboxes[sandbox_id].status.state == \"Failed\"\n\n\n# ============================================================================\n# Volume Support Tests\n# ============================================================================\n\n\n@patch(\"src.services.docker.docker\")\nclass TestBuildVolumeBinds:\n    \"\"\"Tests for DockerSandboxService._build_volume_binds instance method.\"\"\"\n\n    def test_none_volumes_returns_empty(self, mock_docker):\n        \"\"\"None volumes should produce empty binds list.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        assert service._build_volume_binds(None) == []\n\n    def test_empty_volumes_returns_empty(self, mock_docker):\n        \"\"\"Empty volumes list should produce empty binds list.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        assert service._build_volume_binds([]) == []\n\n    def test_single_host_volume_rw(self, mock_docker):\n        \"\"\"Single host volume with read-write should produce correct bind string.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox/user-a\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n        )\n        binds = service._build_volume_binds([volume])\n        assert binds == [\"/data/opensandbox/user-a:/mnt/work:rw\"]\n\n    def test_single_host_volume_ro(self, mock_docker):\n        \"\"\"Single host volume with read-only should produce correct bind string.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox/user-a\"),\n            mount_path=\"/mnt/work\",\n            read_only=True,\n        )\n        binds = service._build_volume_binds([volume])\n        assert binds == [\"/data/opensandbox/user-a:/mnt/work:ro\"]\n\n    def test_host_volume_with_subpath(self, mock_docker):\n        \"\"\"Host volume with subPath should resolve the full host path.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox/user-a\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        binds = service._build_volume_binds([volume])\n        expected_host = os.path.normpath(\"/data/opensandbox/user-a/task-001\")\n        assert binds == [f\"{expected_host}:/mnt/work:rw\"]\n\n    def test_multiple_host_volumes(self, mock_docker):\n        \"\"\"Multiple host volumes should produce multiple bind strings.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volumes = [\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/work\"),\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            ),\n            Volume(\n                name=\"data\",\n                host=Host(path=\"/data/shared\"),\n                mount_path=\"/mnt/data\",\n                read_only=True,\n            ),\n        ]\n        binds = service._build_volume_binds(volumes)\n        assert len(binds) == 2\n        assert \"/data/work:/mnt/work:rw\" in binds\n        assert \"/data/shared:/mnt/data:ro\" in binds\n\n    def test_single_pvc_volume_rw(self, mock_docker):\n        \"\"\"Single PVC volume with read-write (no subPath) should produce named volume bind string.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"shared-data\",\n            pvc=PVC(claim_name=\"my-shared-volume\"),\n            mount_path=\"/mnt/data\",\n            read_only=False,\n        )\n        binds = service._build_volume_binds([volume])\n        assert binds == [\"my-shared-volume:/mnt/data:rw\"]\n\n    def test_single_pvc_volume_ro(self, mock_docker):\n        \"\"\"Single PVC volume with read-only (no subPath) should produce named volume bind string.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"models\",\n            pvc=PVC(claim_name=\"shared-models-pvc\"),\n            mount_path=\"/mnt/models\",\n            read_only=True,\n        )\n        binds = service._build_volume_binds([volume])\n        assert binds == [\"shared-models-pvc:/mnt/models:ro\"]\n\n    def test_pvc_volume_with_subpath(self, mock_docker):\n        \"\"\"PVC volume with subPath should resolve via cached Mountpoint and produce bind mount.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"datasets\",\n            pvc=PVC(claim_name=\"my-vol\"),\n            mount_path=\"/mnt/train\",\n            read_only=False,\n            sub_path=\"datasets/train\",\n        )\n        cache = {\n            \"my-vol\": {\n                \"Name\": \"my-vol\",\n                \"Driver\": \"local\",\n                \"Mountpoint\": \"/var/lib/docker/volumes/my-vol/_data\",\n            }\n        }\n        binds = service._build_volume_binds([volume], pvc_inspect_cache=cache)\n        assert binds == [\"/var/lib/docker/volumes/my-vol/_data/datasets/train:/mnt/train:rw\"]\n\n    def test_pvc_volume_with_subpath_readonly(self, mock_docker):\n        \"\"\"PVC volume with subPath and readOnly should produce ':ro' bind mount.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"datasets\",\n            pvc=PVC(claim_name=\"my-vol\"),\n            mount_path=\"/mnt/eval\",\n            read_only=True,\n            sub_path=\"datasets/eval\",\n        )\n        cache = {\n            \"my-vol\": {\n                \"Name\": \"my-vol\",\n                \"Driver\": \"local\",\n                \"Mountpoint\": \"/var/lib/docker/volumes/my-vol/_data\",\n            }\n        }\n        binds = service._build_volume_binds([volume], pvc_inspect_cache=cache)\n        assert binds == [\"/var/lib/docker/volumes/my-vol/_data/datasets/eval:/mnt/eval:ro\"]\n\n    def test_mixed_host_and_pvc_volumes(self, mock_docker):\n        \"\"\"Mixed host and PVC volumes should both produce bind strings.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volumes = [\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/work\"),\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            ),\n            Volume(\n                name=\"shared-data\",\n                pvc=PVC(claim_name=\"my-shared-volume\"),\n                mount_path=\"/mnt/data\",\n                read_only=True,\n            ),\n        ]\n        binds = service._build_volume_binds(volumes)\n        assert len(binds) == 2\n        assert \"/data/work:/mnt/work:rw\" in binds\n        assert \"my-shared-volume:/mnt/data:ro\" in binds\n\n    def test_ossfs_volume_with_subpath(self, mock_docker):\n        \"\"\"OSSFS volume should resolve host path using subPath as OSS prefix.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        binds = service._build_volume_binds([volume])\n        assert binds == [\"/mnt/ossfs/bucket-test-3/task-001:/mnt/data:rw\"]\n\n\n@patch(\"src.services.docker.docker\")\nclass TestDockerVolumeValidation:\n    \"\"\"Tests for volume validation in DockerSandboxService.create_sandbox.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_pvc_volume_not_found_rejected(self, mock_docker):\n        \"\"\"PVC backend with non-existent Docker named volume should be rejected.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.side_effect = DockerNotFound(\"volume not found\")\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"models\",\n                    pvc=PVC(claim_name=\"nonexistent-volume\"),\n                    mount_path=\"/mnt/models\",\n                    read_only=True,\n                )\n            ],\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.PVC_VOLUME_NOT_FOUND\n\n    def test_ossfs_inline_credentials_missing_rejected(self, mock_docker):\n        \"\"\"OSSFS with missing inline credentials should be rejected at schema validation.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        with pytest.raises(ValidationError):\n            OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                access_key_id=None,\n                access_key_secret=None,\n            )\n\n    @pytest.mark.asyncio\n    async def test_ossfs_mount_failure_rejected(self, mock_docker):\n        \"\"\"OSSFS mount failure should be rejected.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"oss-data\",\n                    ossfs=OSSFS(\n                        bucket=\"bucket-test-3\",\n                        endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                        access_key_id=\"AKIDEXAMPLE\",\n                        access_key_secret=\"SECRETEXAMPLE\",\n                    ),\n                    mount_path=\"/mnt/data\",\n                    sub_path=\"task-001\",\n                )\n            ],\n        )\n\n        with patch(\"src.services.ossfs_mixin.os.name\", \"posix\"):\n            with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=False):\n                with patch(\"src.services.ossfs_mixin.os.makedirs\"):\n                    with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                        mock_run.return_value = MagicMock(returncode=1, stderr=\"mount failed\")\n                        with pytest.raises(HTTPException) as exc_info:\n                            await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.OSSFS_MOUNT_FAILED\n\n    def test_ossfs_windows_host_not_supported(self, mock_docker):\n        \"\"\"OSSFS backend should be rejected when server host is Windows.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n        )\n\n        with patch(\"src.services.ossfs_mixin.os.name\", \"nt\"):\n            with pytest.raises(HTTPException) as exc_info:\n                service._validate_ossfs_volume(volume)\n        assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n\n    def test_ossfs_v1_mount_command_uses_o_options(self, mock_docker):\n        \"\"\"OSSFS 1.0 should build mount command with -o style options.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"1.0\",\n                options=[\"allow_other\", \"umask=0022\"],\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            sub_path=\"task-001\",\n        )\n        backend_path = \"/mnt/ossfs/bucket-test-3/task-001\"\n\n        with patch(\"src.services.ossfs_mixin.os.makedirs\"):\n            with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n                service._mount_ossfs_backend_path(volume, backend_path)\n\n        cmd = mock_run.call_args.args[0]\n        assert \"bucket-test-3:/task-001\" in cmd\n        assert \"-o\" in cmd\n        assert \"allow_other\" in cmd\n        assert \"umask=0022\" in cmd\n        assert \"--allow_other\" not in cmd\n        assert \"sigv4\" not in cmd\n        assert not any(str(part).startswith(\"region=\") for part in cmd)\n\n    def test_ossfs_v2_mount_command_uses_config_file(self, mock_docker):\n        \"\"\"OSSFS 2.0 should mount by ossfs2 config file.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"2.0\",\n                options=[\"allow_other\", \"umask=0022\"],\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            sub_path=\"task-001\",\n        )\n        backend_path = \"/mnt/ossfs/bucket-test-3/task-001\"\n\n        with patch(\"src.services.ossfs_mixin.os.makedirs\"):\n            with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n                service._mount_ossfs_backend_path(volume, backend_path)\n\n        cmd = mock_run.call_args.args[0]\n        assert cmd[0] == \"ossfs2\"\n        assert cmd[1] == \"mount\"\n        assert cmd[2] == backend_path\n        assert cmd[3] == \"-c\"\n        assert cmd[4].endswith(\".conf\")\n\n    def test_ossfs_v2_config_contains_required_lines(self, mock_docker):\n        \"\"\"OSSFS 2.0 config should encode endpoint/bucket/creds/options/prefix.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"2.0\",\n                options=[\"allow_other\", \"umask=0022\"],\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            sub_path=\"task-001\",\n        )\n\n        conf_lines = service._build_ossfs_v2_config_lines(\n            volume=volume,\n            endpoint_url=\"http://oss-cn-hangzhou.aliyuncs.com\",\n            prefix=\"task-001\",\n        )\n        assert \"--oss_endpoint=http://oss-cn-hangzhou.aliyuncs.com\" in conf_lines\n        assert \"--oss_bucket=bucket-test-3\" in conf_lines\n        assert \"--oss_access_key_id=AKIDEXAMPLE\" in conf_lines\n        assert \"--oss_access_key_secret=SECRETEXAMPLE\" in conf_lines\n        assert \"--oss_bucket_prefix=task-001/\" in conf_lines\n        assert \"--allow_other\" in conf_lines\n        assert \"--umask=0022\" in conf_lines\n\n    @pytest.mark.asyncio\n    async def test_ossfs_volume_binds_passed_to_docker(self, mock_docker):\n        \"\"\"OSSFS volume should be converted to host bind path and passed to Docker.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"oss-data\",\n                    ossfs=OSSFS(\n                        bucket=\"bucket-test-3\",\n                        endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                        access_key_id=\"AKIDEXAMPLE\",\n                        access_key_secret=\"SECRETEXAMPLE\",\n                    ),\n                    mount_path=\"/mnt/data\",\n                    read_only=True,\n                    sub_path=\"task-001\",\n                )\n            ],\n        )\n\n        with patch(\"src.services.ossfs_mixin.os.name\", \"posix\"):\n            with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=False):\n                with patch(\"src.services.ossfs_mixin.os.makedirs\"):\n                    with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                        mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n                        with patch.object(service, \"_ensure_image_available\"), patch.object(\n                            service, \"_prepare_sandbox_runtime\"\n                        ):\n                            response = await service.create_sandbox(request)\n\n        assert response.status.state == \"Running\"\n        assert mock_run.called\n        host_config_call = mock_client.api.create_host_config.call_args\n        binds = host_config_call.kwargs[\"binds\"]\n        assert binds[0] == \"/mnt/ossfs/bucket-test-3/task-001:/mnt/data:ro\"\n        create_call = mock_client.api.create_container.call_args\n        labels = create_call.kwargs[\"labels\"]\n        assert SANDBOX_OSSFS_MOUNTS_LABEL in labels\n        assert labels[SANDBOX_OSSFS_MOUNTS_LABEL] == '[\"/mnt/ossfs/bucket-test-3/task-001\"]'\n\n    def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker):\n        \"\"\"Two OSSFS volumes on same base path should mount once and share refs.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volumes = [\n            Volume(\n                name=\"oss-data-a\",\n                ossfs=OSSFS(\n                    bucket=\"bucket-test-3\",\n                    endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                    access_key_secret=\"SECRETEXAMPLE\",\n                ),\n                mount_path=\"/mnt/data-a\",\n                sub_path=\"task-001\",\n            ),\n            Volume(\n                name=\"oss-data-b\",\n                ossfs=OSSFS(\n                    bucket=\"bucket-test-3\",\n                    endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                    access_key_secret=\"SECRETEXAMPLE\",\n                ),\n                mount_path=\"/mnt/data-b\",\n                sub_path=\"task-001\",\n            ),\n        ]\n\n        with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=False):\n            with patch(\"src.services.ossfs_mixin.os.makedirs\"):\n                with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                    mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n                    mount_keys = service._prepare_ossfs_mounts(volumes)\n\n        mount_key = \"/mnt/ossfs/bucket-test-3/task-001\"\n        assert mount_keys == [mount_key]\n        assert service._ossfs_mount_ref_counts[mount_key] == 1\n        assert mock_run.call_count == 1\n\n    def test_prepare_ossfs_mounts_rolls_back_on_partial_failure(self, mock_docker):\n        \"\"\"If one OSSFS mount fails, already prepared mounts should be rolled back.\"\"\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n        volumes = [\n            Volume(\n                name=\"oss-data-a\",\n                ossfs=OSSFS(\n                    bucket=\"bucket-a\",\n                    endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                    access_key_secret=\"SECRETEXAMPLE\",\n                ),\n                mount_path=\"/mnt/data-a\",\n            ),\n            Volume(\n                name=\"oss-data-b\",\n                ossfs=OSSFS(\n                    bucket=\"bucket-b\",\n                    endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                    access_key_secret=\"SECRETEXAMPLE\",\n                ),\n                mount_path=\"/mnt/data-b\",\n            ),\n        ]\n\n        mount_key_a = \"/mnt/ossfs/bucket-a\"\n        mount_key_b = \"/mnt/ossfs/bucket-b\"\n\n        with patch.object(\n            service,\n            \"_ensure_ossfs_mounted\",\n            side_effect=[mount_key_a, HTTPException(status_code=500, detail={\"code\": \"E\", \"message\": \"boom\"})],\n        ) as ensure_mock:\n            with patch.object(service, \"_release_ossfs_mounts\") as release_mock:\n                with pytest.raises(HTTPException):\n                    service._prepare_ossfs_mounts(volumes)\n\n        assert ensure_mock.call_count == 2\n        release_mock.assert_called_once_with([mount_key_a])\n        assert mount_key_b not in release_mock.call_args.args[0]\n\n    def test_delete_sandbox_releases_ossfs_mount(self, mock_docker):\n        \"\"\"Deleting sandbox should release and unmount tracked OSSFS mount.\"\"\"\n        mount_key = \"/mnt/ossfs/bucket-test-3/task-001\"\n        mock_container = MagicMock()\n        mock_container.attrs = {\n            \"Config\": {\n                \"Labels\": {\n                    SANDBOX_ID_LABEL: \"sandbox-1\",\n                    SANDBOX_OSSFS_MOUNTS_LABEL: f'[\"{mount_key}\"]',\n                }\n            },\n            \"State\": {\"Running\": True},\n        }\n\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = [mock_container]\n        mock_docker.from_env.return_value = mock_client\n        service = DockerSandboxService(config=_app_config())\n        service._ossfs_mount_ref_counts[mount_key] = 1\n\n        with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=True):\n            with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n                service.delete_sandbox(\"sandbox-1\")\n\n        assert mount_key not in service._ossfs_mount_ref_counts\n        assert mock_run.called\n\n    def test_release_ossfs_mount_untracked_key_does_not_unmount(self, mock_docker):\n        \"\"\"Untracked mount key must not trigger unmount command.\"\"\"\n        mount_key = \"/mnt/ossfs/bucket-test-3/task-001\"\n        mock_docker.from_env.return_value = MagicMock()\n        service = DockerSandboxService(config=_app_config())\n\n        with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=True):\n            with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                service._release_ossfs_mount(mount_key)\n\n        mock_run.assert_not_called()\n        assert mount_key not in service._ossfs_mount_ref_counts\n\n    def test_restore_existing_sandboxes_rebuilds_ossfs_refs(self, mock_docker):\n        \"\"\"Service startup rebuilds OSSFS mount refs from container labels.\"\"\"\n        mount_key = \"/mnt/ossfs/bucket-test-3/task-001\"\n        expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat()\n        container = MagicMock()\n        container.attrs = {\n            \"Config\": {\n                \"Labels\": {\n                    SANDBOX_ID_LABEL: \"sandbox-1\",\n                    SANDBOX_EXPIRES_AT_LABEL: expires_at,\n                    SANDBOX_OSSFS_MOUNTS_LABEL: f'[\"{mount_key}\"]',\n                }\n            },\n            \"State\": {\"Running\": True},\n        }\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = [container]\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        assert service._ossfs_mount_ref_counts[mount_key] == 1\n\n    def test_delete_one_sandbox_after_restart_keeps_shared_mount(self, mock_docker):\n        \"\"\"After restart, deleting one of two users must not unmount shared OSSFS mount.\"\"\"\n        mount_key = \"/mnt/ossfs/bucket-test-3/task-001\"\n        expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat()\n        container_a = MagicMock()\n        container_a.attrs = {\n            \"Config\": {\n                \"Labels\": {\n                    SANDBOX_ID_LABEL: \"sandbox-a\",\n                    SANDBOX_EXPIRES_AT_LABEL: expires_at,\n                    SANDBOX_OSSFS_MOUNTS_LABEL: f'[\"{mount_key}\"]',\n                }\n            },\n            \"State\": {\"Running\": True},\n        }\n        container_b = MagicMock()\n        container_b.attrs = {\n            \"Config\": {\n                \"Labels\": {\n                    SANDBOX_ID_LABEL: \"sandbox-b\",\n                    SANDBOX_EXPIRES_AT_LABEL: expires_at,\n                    SANDBOX_OSSFS_MOUNTS_LABEL: f'[\"{mount_key}\"]',\n                }\n            },\n            \"State\": {\"Running\": True},\n        }\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = [container_a, container_b]\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n        assert service._ossfs_mount_ref_counts[mount_key] == 2\n\n        with patch(\"src.services.ossfs_mixin.os.path.ismount\", return_value=True):\n            with patch(\"src.services.ossfs_mixin.subprocess.run\") as mock_run:\n                service.delete_sandbox(\"sandbox-a\")\n\n        assert service._ossfs_mount_ref_counts[mount_key] == 1\n        mock_run.assert_not_called()\n\n    def test_restore_manual_cleanup_sandbox_rebuilds_ossfs_refs(self, mock_docker):\n        \"\"\"Manual cleanup sandbox OSSFS refs should be restored on startup.\"\"\"\n        mount_key = \"/mnt/ossfs/bucket-manual/data\"\n        container = MagicMock()\n        container.attrs = {\n            \"Config\": {\n                \"Labels\": {\n                    SANDBOX_ID_LABEL: \"sandbox-manual\",\n                    SANDBOX_MANUAL_CLEANUP_LABEL: \"true\",\n                    SANDBOX_OSSFS_MOUNTS_LABEL: f'[\"{mount_key}\"]',\n                }\n            },\n            \"State\": {\"Running\": True},\n        }\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = [container]\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        assert service._ossfs_mount_ref_counts.get(mount_key) == 1\n\n    @pytest.mark.asyncio\n    async def test_pvc_volume_inspect_failure_returns_500(self, mock_docker):\n        \"\"\"Docker API failure during volume inspection should return 500.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.side_effect = DockerException(\"connection error\")\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"shared-data\",\n                    pvc=PVC(claim_name=\"my-volume\"),\n                    mount_path=\"/mnt/data\",\n                )\n            ],\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED\n\n    @pytest.mark.asyncio\n    async def test_pvc_volume_binds_passed_to_docker(self, mock_docker):\n        \"\"\"PVC volume binds should be passed to Docker host config as named volume refs.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.return_value = {\"Name\": \"my-shared-volume\"}\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"shared-data\",\n                    pvc=PVC(claim_name=\"my-shared-volume\"),\n                    mount_path=\"/mnt/data\",\n                    read_only=False,\n                )\n            ],\n        )\n\n        with (\n            patch.object(service, \"_ensure_image_available\"),\n            patch.object(service, \"_prepare_sandbox_runtime\"),\n        ):\n            response = await service.create_sandbox(request)\n\n        assert response.status.state == \"Running\"\n\n        # Verify named volume bind was passed to create_host_config\n        host_config_call = mock_client.api.create_host_config.call_args\n        assert \"binds\" in host_config_call.kwargs\n        binds = host_config_call.kwargs[\"binds\"]\n        assert len(binds) == 1\n        assert binds[0] == \"my-shared-volume:/mnt/data:rw\"\n\n    @pytest.mark.asyncio\n    async def test_pvc_volume_readonly_binds_passed_to_docker(self, mock_docker):\n        \"\"\"PVC volume with read-only should produce ':ro' bind string.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.return_value = {\"Name\": \"shared-models\"}\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"models\",\n                    pvc=PVC(claim_name=\"shared-models\"),\n                    mount_path=\"/mnt/models\",\n                    read_only=True,\n                )\n            ],\n        )\n\n        with (\n            patch.object(service, \"_ensure_image_available\"),\n            patch.object(service, \"_prepare_sandbox_runtime\"),\n        ):\n            await service.create_sandbox(request)\n\n        host_config_call = mock_client.api.create_host_config.call_args\n        binds = host_config_call.kwargs[\"binds\"]\n        assert binds[0] == \"shared-models:/mnt/models:ro\"\n\n    @pytest.mark.asyncio\n    async def test_pvc_subpath_non_local_driver_rejected(self, mock_docker):\n        \"\"\"PVC with subPath on a non-local driver should be rejected.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.return_value = {\n            \"Name\": \"cloud-vol\",\n            \"Driver\": \"nfs\",\n            \"Mountpoint\": \"\",\n        }\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"data\",\n                    pvc=PVC(claim_name=\"cloud-vol\"),\n                    mount_path=\"/mnt/data\",\n                    sub_path=\"subdir\",\n                )\n            ],\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER\n\n    @pytest.mark.asyncio\n    async def test_pvc_subpath_symlink_escape_rejected(self, mock_docker):\n        \"\"\"PVC with subPath that resolves outside mountpoint via symlink should be rejected.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.return_value = {\n            \"Name\": \"my-vol\",\n            \"Driver\": \"local\",\n            \"Mountpoint\": \"/var/lib/docker/volumes/my-vol/_data\",\n        }\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"data\",\n                    pvc=PVC(claim_name=\"my-vol\"),\n                    mount_path=\"/mnt/data\",\n                    sub_path=\"datasets\",\n                )\n            ],\n        )\n\n        # Simulate: realpath resolves a symlink that escapes the mountpoint.\n        # datasets -> / inside the volume, so realpath(…/_data/datasets) = /\n        with patch(\"src.services.docker.os.path.realpath\") as mock_realpath:\n            mock_realpath.side_effect = lambda p, **kwargs: (\"/\" if p.endswith(\"datasets\") else p)\n            with pytest.raises(HTTPException) as exc_info:\n                await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_SUB_PATH\n        assert \"symlink\" in exc_info.value.detail[\"message\"]\n\n    @pytest.mark.asyncio\n    async def test_pvc_subpath_binds_resolved_to_mountpoint(self, mock_docker):\n        \"\"\"PVC with subPath should resolve Mountpoint+subPath and pass as bind mount.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.inspect_volume.return_value = {\n            \"Name\": \"my-vol\",\n            \"Driver\": \"local\",\n            \"Mountpoint\": \"/var/lib/docker/volumes/my-vol/_data\",\n        }\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"train-data\",\n                    pvc=PVC(claim_name=\"my-vol\"),\n                    mount_path=\"/mnt/train\",\n                    read_only=True,\n                    sub_path=\"datasets/train\",\n                )\n            ],\n        )\n\n        with (\n            patch.object(service, \"_ensure_image_available\"),\n            patch.object(service, \"_prepare_sandbox_runtime\"),\n        ):\n            await service.create_sandbox(request)\n\n        host_config_call = mock_client.api.create_host_config.call_args\n        binds = host_config_call.kwargs[\"binds\"]\n        assert len(binds) == 1\n        assert binds[0] == \"/var/lib/docker/volumes/my-vol/_data/datasets/train:/mnt/train:ro\"\n\n    @pytest.mark.asyncio\n    async def test_host_path_not_found_rejected(self, mock_docker):\n        \"\"\"Host path create failure should return 500 with HOST_PATH_CREATE_FAILED.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"workdir\",\n                    host=Host(path=\"/nonexistent/path/that/does/not/exist\"),\n                    mount_path=\"/mnt/work\",\n                    read_only=False,\n                )\n            ],\n        )\n\n        with patch(\"src.services.docker.os.makedirs\", side_effect=PermissionError(\"denied\")):\n            with pytest.raises(HTTPException) as exc_info:\n                await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.HOST_PATH_CREATE_FAILED\n\n    @pytest.mark.asyncio\n    async def test_host_path_not_in_allowlist_rejected(self, mock_docker):\n        \"\"\"Host path not in allowlist should be rejected.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_docker.from_env.return_value = mock_client\n\n        cfg = _app_config()\n        cfg.storage = StorageConfig(allowed_host_paths=[\"/data/opensandbox\"])\n        service = DockerSandboxService(config=cfg)\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n            volumes=[\n                Volume(\n                    name=\"workdir\",\n                    host=Host(path=\"/etc/passwd\"),\n                    mount_path=\"/mnt/work\",\n                    read_only=False,\n                )\n            ],\n        )\n\n        with pytest.raises(HTTPException) as exc_info:\n            await service.create_sandbox(request)\n\n        assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED\n\n    @pytest.mark.asyncio\n    async def test_no_volumes_passes_validation(self, mock_docker):\n        \"\"\"Request without volumes should pass validation.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n        )\n\n        with (\n            patch.object(service, \"_ensure_image_available\"),\n            patch.object(service, \"_prepare_sandbox_runtime\"),\n        ):\n            response = await service.create_sandbox(request)\n\n        assert response.status.state == \"Running\"\n\n    @pytest.mark.asyncio\n    async def test_host_volume_binds_passed_to_docker(self, mock_docker):\n        \"\"\"Host volume binds should be passed to Docker host config.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        import tempfile\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            request = CreateSandboxRequest(\n                image=ImageSpec(uri=\"python:3.11\"),\n                timeout=120,\n                resourceLimits=ResourceLimits(root={}),\n                env={},\n                metadata={},\n                entrypoint=[\"python\"],\n                volumes=[\n                    Volume(\n                        name=\"workdir\",\n                        host=Host(path=tmpdir),\n                        mount_path=\"/mnt/work\",\n                        read_only=False,\n                    )\n                ],\n            )\n\n            with (\n                patch.object(service, \"_ensure_image_available\"),\n                patch.object(service, \"_prepare_sandbox_runtime\"),\n            ):\n                await service.create_sandbox(request)\n\n            # Verify binds were passed to create_host_config\n            host_config_call = mock_client.api.create_host_config.call_args\n            assert \"binds\" in host_config_call.kwargs\n            binds = host_config_call.kwargs[\"binds\"]\n            assert len(binds) == 1\n            assert binds[0] == f\"{tmpdir}:/mnt/work:rw\"\n\n    @pytest.mark.asyncio\n    async def test_host_volume_with_subpath_resolved_correctly(self, mock_docker):\n        \"\"\"Host volume subPath should be resolved and validated.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        import tempfile\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # Create the subPath directory\n            sub_dir = os.path.join(tmpdir, \"task-001\")\n            os.makedirs(sub_dir)\n\n            request = CreateSandboxRequest(\n                image=ImageSpec(uri=\"python:3.11\"),\n                timeout=120,\n                resourceLimits=ResourceLimits(root={}),\n                env={},\n                metadata={},\n                entrypoint=[\"python\"],\n                volumes=[\n                    Volume(\n                        name=\"workdir\",\n                        host=Host(path=tmpdir),\n                        mount_path=\"/mnt/work\",\n                        read_only=True,\n                        sub_path=\"task-001\",\n                    )\n                ],\n            )\n\n            with (\n                patch.object(service, \"_ensure_image_available\"),\n                patch.object(service, \"_prepare_sandbox_runtime\"),\n            ):\n                await service.create_sandbox(request)\n\n            host_config_call = mock_client.api.create_host_config.call_args\n            binds = host_config_call.kwargs[\"binds\"]\n            assert len(binds) == 1\n            assert binds[0] == f\"{sub_dir}:/mnt/work:ro\"\n\n    @pytest.mark.asyncio\n    async def test_host_subpath_auto_created(self, mock_docker):\n        \"\"\"Host volume with non-existent subPath should be auto-created.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        import tempfile\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            sub = \"auto-created-sub\"\n            request = CreateSandboxRequest(\n                image=ImageSpec(uri=\"python:3.11\"),\n                timeout=120,\n                resourceLimits=ResourceLimits(root={}),\n                env={},\n                metadata={},\n                entrypoint=[\"python\"],\n                volumes=[\n                    Volume(\n                        name=\"workdir\",\n                        host=Host(path=tmpdir),\n                        mount_path=\"/mnt/work\",\n                        read_only=False,\n                        sub_path=sub,\n                    )\n                ],\n            )\n\n            import os\n\n            resolved = os.path.join(tmpdir, sub)\n            assert not os.path.exists(resolved)\n\n            # create_sandbox will proceed past volume validation (subpath\n            # auto-created) but will fail later during container provisioning\n            # (mock doesn't cover the full flow).  We only care that the\n            # directory was created — NOT that it raised HOST_PATH_CREATE_FAILED.\n            try:\n                await service.create_sandbox(request)\n            except HTTPException as e:\n                # If it's our own create-failed error, the auto-create didn't\n                # work — let the test fail explicitly.\n                if e.detail.get(\"code\") == SandboxErrorCodes.HOST_PATH_CREATE_FAILED:\n                    raise\n            except Exception:\n                pass  # other provisioning errors are expected\n\n            assert os.path.isdir(resolved)\n\n    @pytest.mark.asyncio\n    async def test_empty_allowlist_permits_any_host_path(self, mock_docker):\n        \"\"\"Empty allowed_host_paths (default) should permit any valid host path.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        # Default config has storage.allowed_host_paths = []\n        cfg = _app_config()\n        assert cfg.storage.allowed_host_paths == []\n        service = DockerSandboxService(config=cfg)\n\n        import tempfile\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            request = CreateSandboxRequest(\n                image=ImageSpec(uri=\"python:3.11\"),\n                timeout=120,\n                resourceLimits=ResourceLimits(root={}),\n                env={},\n                metadata={},\n                entrypoint=[\"python\"],\n                volumes=[\n                    Volume(\n                        name=\"workdir\",\n                        host=Host(path=tmpdir),\n                        mount_path=\"/mnt/work\",\n                        read_only=False,\n                    )\n                ],\n            )\n\n            with (\n                patch.object(service, \"_ensure_image_available\"),\n                patch.object(service, \"_prepare_sandbox_runtime\"),\n            ):\n                response = await service.create_sandbox(request)\n\n            assert response.status.state == \"Running\"\n\n    @pytest.mark.asyncio\n    async def test_no_volumes_omits_binds_from_host_config(self, mock_docker):\n        \"\"\"When no volumes are specified, 'binds' should not appear in Docker host config.\"\"\"\n        mock_client = MagicMock()\n        mock_client.containers.list.return_value = []\n        mock_client.api.create_host_config.return_value = {}\n        mock_client.api.create_container.return_value = {\"Id\": \"cid\"}\n        mock_client.containers.get.return_value = MagicMock()\n        mock_docker.from_env.return_value = mock_client\n\n        service = DockerSandboxService(config=_app_config())\n\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=120,\n            resourceLimits=ResourceLimits(root={}),\n            env={},\n            metadata={},\n            entrypoint=[\"python\"],\n        )\n\n        with (\n            patch.object(service, \"_ensure_image_available\"),\n            patch.object(service, \"_prepare_sandbox_runtime\"),\n        ):\n            await service.create_sandbox(request)\n\n        host_config_call = mock_client.api.create_host_config.call_args\n        assert \"binds\" not in host_config_call.kwargs\n"
  },
  {
    "path": "server/tests/test_endpoint.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom src.services.helpers import normalize_external_endpoint_url\n\n\ndef test_normalize_external_endpoint_url_defaults_to_https() -> None:\n    assert (\n        normalize_external_endpoint_url(\"oss-cn-hangzhou.aliyuncs.com\")\n        == \"https://oss-cn-hangzhou.aliyuncs.com\"\n    )\n\n\ndef test_normalize_external_endpoint_url_keeps_existing_scheme() -> None:\n    assert (\n        normalize_external_endpoint_url(\"http://oss-cn-hangzhou.aliyuncs.com\")\n        == \"http://oss-cn-hangzhou.aliyuncs.com\"\n    )\n"
  },
  {
    "path": "server/tests/test_endpoint_auth.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER\nfrom src.services.endpoint_auth import (\n    build_egress_auth_headers,\n    generate_egress_token,\n    merge_endpoint_headers,\n)\n\n\ndef test_generate_egress_token_returns_random_urlsafe_strings() -> None:\n    first = generate_egress_token()\n    second = generate_egress_token()\n\n    assert first\n    assert second\n    assert first != second\n\n\ndef test_build_egress_auth_headers_uses_expected_header_name() -> None:\n    token = \"egress-token\"\n\n    assert build_egress_auth_headers(token) == {\n        OPEN_SANDBOX_EGRESS_AUTH_HEADER: token,\n    }\n\n\ndef test_merge_endpoint_headers_preserves_existing_headers() -> None:\n    existing = {\"OpenSandbox-Ingress-To\": \"sbx-1-18080\"}\n    extra = {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n\n    merged = merge_endpoint_headers(existing, extra)\n\n    assert merged == {\n        \"OpenSandbox-Ingress-To\": \"sbx-1-18080\",\n        OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\",\n    }\n    assert existing == {\"OpenSandbox-Ingress-To\": \"sbx-1-18080\"}\n\n\ndef test_merge_endpoint_headers_handles_missing_existing_headers() -> None:\n    merged = merge_endpoint_headers(None, {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"})\n\n    assert merged == {OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"}\n"
  },
  {
    "path": "server/tests/test_helpers.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom datetime import datetime, timezone\n\nfrom src.services.helpers import parse_timestamp\n\n\ndef test_parse_timestamp_truncates_nanoseconds():\n    ts = \"2025-12-10T05:29:56.359015208Z\"\n\n    result = parse_timestamp(ts)\n\n    assert result.tzinfo is not None\n    assert result.astimezone(timezone.utc) == result\n    assert result.year == 2025\n    assert result.month == 12\n    assert result.day == 10\n    assert result.microsecond == 359015\n\n\ndef test_parse_timestamp_parses_valid_rfc3339():\n    ts = \"2024-01-01T12:34:56.123456Z\"\n\n    result = parse_timestamp(ts)\n\n    assert result.tzinfo is not None\n    assert result == datetime(2024, 1, 1, 12, 34, 56, 123456, tzinfo=timezone.utc)\n\n\ndef test_parse_timestamp_invalid_falls_back_to_now():\n    before = datetime.now(timezone.utc)\n    result = parse_timestamp(\"not-a-time\")\n    after = datetime.now(timezone.utc)\n\n    assert result.tzinfo is not None\n    assert before <= result <= after\n"
  },
  {
    "path": "server/tests/test_ingress.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n# \n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n# \n#     http://www.apache.org/licenses/LICENSE-2.0\n# \n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\nfrom src.config import (\n    GatewayConfig,\n    GatewayRouteModeConfig,\n    IngressConfig,\n    INGRESS_MODE_DIRECT,\n    INGRESS_MODE_GATEWAY,\n)\nfrom src.services.constants import OPEN_SANDBOX_INGRESS_HEADER\nfrom src.services.helpers import format_ingress_endpoint\n\n\ndef test_format_ingress_endpoint_returns_none_when_not_gateway():\n    cfg = IngressConfig(mode=INGRESS_MODE_DIRECT)\n    assert format_ingress_endpoint(cfg, \"sid\", 8080) is None\n    assert format_ingress_endpoint(None, \"sid\", 8080) is None\n\n\ndef test_format_ingress_endpoint_wildcard():\n    cfg = IngressConfig(\n        mode=INGRESS_MODE_GATEWAY,\n        gateway=GatewayConfig(\n            address=\"*.example.com\",\n            route=GatewayRouteModeConfig(mode=\"wildcard\"),\n        ),\n    )\n    endpoint = format_ingress_endpoint(cfg, \"sid\", 8080)\n    assert endpoint is not None\n    assert endpoint.endpoint == \"sid-8080.example.com\"\n    assert endpoint.headers is None\n\n\ndef test_format_ingress_endpoint_uri():\n    cfg = IngressConfig(\n        mode=INGRESS_MODE_GATEWAY,\n        gateway=GatewayConfig(\n            address=\"gateway.example.com\",\n            route=GatewayRouteModeConfig(mode=\"uri\"),\n        ),\n    )\n    endpoint = format_ingress_endpoint(cfg, \"sid\", 9000)\n    assert endpoint is not None\n    assert endpoint.endpoint == \"gateway.example.com/sid/9000\"\n    assert endpoint.headers is None\n\n\ndef test_format_ingress_endpoint_header():\n    cfg = IngressConfig(\n        mode=INGRESS_MODE_GATEWAY,\n        gateway=GatewayConfig(\n            address=\"gateway.example.com\",\n            route=GatewayRouteModeConfig(mode=\"header\"),\n        ),\n    )\n    endpoint = format_ingress_endpoint(cfg, \"sid\", 8080)\n    assert endpoint is not None\n    assert endpoint.endpoint == \"gateway.example.com\"\n    assert endpoint.headers == {OPEN_SANDBOX_INGRESS_HEADER: \"sid-8080\"}"
  },
  {
    "path": "server/tests/test_routes.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"\nAPI route tests for OpenSandbox Lifecycle API.\n\nThis module contains test cases for all API endpoints.\nMost test bodies are placeholders that will be implemented as features mature.\n\"\"\"\n\nfrom datetime import datetime, timezone\n\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import ImageSpec, Sandbox, SandboxStatus\n\n\nclass TestHealthCheck:\n    \"\"\"Test cases for health check endpoint.\"\"\"\n\n    def test_health_check(self, client: TestClient):\n        \"\"\"\n        Test health check endpoint.\n        \"\"\"\n        response = client.get(\"/health\")\n        assert response.status_code == 200\n        assert response.json() == {\"status\": \"healthy\"}\n\n\nclass TestAuthentication:\n    \"\"\"Test cases for authentication middleware.\"\"\"\n\n    def test_missing_api_key(self, client: TestClient):\n        \"\"\"\n        Test request without API key returns 401.\n        \"\"\"\n        response = client.get(\"/sandboxes/123e4567-e89b-12d3-a456-426614174000\")\n        assert response.status_code == 401\n        assert \"MISSING_API_KEY\" in response.json()[\"code\"]\n\n    def test_missing_api_key_v1_prefix(self, client: TestClient):\n        \"\"\"\n        Test request without API key on versioned route returns 401.\n        \"\"\"\n        response = client.get(\"/v1/sandboxes/123e4567-e89b-12d3-a456-426614174000\")\n        assert response.status_code == 401\n        assert \"MISSING_API_KEY\" in response.json()[\"code\"]\n\n    def test_invalid_api_key(self, client: TestClient):\n        \"\"\"\n        Test request with invalid API key returns 401.\n        \"\"\"\n        _ = client.get(\n            \"/sandboxes/123e4567-e89b-12d3-a456-426614174000\",\n            headers={\"OPEN-SANDBOX-API-KEY\": \"invalid-key\"},\n        )\n        # Note: Current implementation accepts any non-empty key if no keys configured.\n        # This test will need to be updated when proper key validation is implemented.\n        pass\n\n\nclass TestCreateSandbox:\n    \"\"\"Test cases for sandbox creation endpoint.\"\"\"\n\n    def test_create_sandbox_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n        sample_sandbox_request: dict,\n    ):\n        \"\"\"\n        Test successful sandbox creation.\n        \"\"\"\n        pass\n\n    def test_create_sandbox_invalid_request(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test sandbox creation with invalid request.\n        \"\"\"\n        pass\n\n    def test_create_sandbox_unauthorized(\n        self,\n        client: TestClient,\n        sample_sandbox_request: dict,\n    ):\n        \"\"\"\n        Test sandbox creation without authentication.\n        \"\"\"\n        pass\n\n\nclass TestListSandboxes:\n    \"\"\"Test cases for sandbox listing endpoint.\"\"\"\n\n    def test_list_sandboxes_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful sandbox listing.\n        \"\"\"\n        _ = client.get(\"/sandboxes\", headers=auth_headers)\n        # Note: Actual response depends on mock service implementation,\n        # but here we just check if the endpoint is reachable via GET\n        # and doesn't 404. Since we haven't mocked the service response fully in this placeholder,\n        # we expect at least a valid status code flow (e.g. 200 if mocked properly, or 500 if mock fails).\n        # Assuming the service mock returns a valid list response:\n        # assert response.status_code == 200\n        pass\n\n    def test_list_sandboxes_with_filters(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test sandbox listing with filters.\n        \"\"\"\n        params = {\"state\": [\"Running\"], \"metadata\": \"project=test\"}\n        _ = client.get(\"/sandboxes\", headers=auth_headers, params=params)\n        pass\n\n    def test_list_sandboxes_with_pagination(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test sandbox listing with pagination.\n        \"\"\"\n        params = {\"page\": 2, \"pageSize\": 10}\n        _ = client.get(\"/sandboxes\", headers=auth_headers, params=params)\n        pass\n\n\nclass TestGetSandbox:\n    \"\"\"Test cases for get sandbox endpoint.\"\"\"\n\n    def test_get_sandbox_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful sandbox retrieval.\n        \"\"\"\n        pass\n\n    def test_get_sandbox_preserves_nullable_expires_at(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n        monkeypatch,\n    ):\n        \"\"\"\n        Ensure expiresAt is returned as null for manual-cleanup sandboxes.\n        \"\"\"\n        now = datetime.now(timezone.utc)\n        sandbox = Sandbox(\n            id=\"sandbox-123\",\n            image=ImageSpec(uri=\"python:3.11\"),\n            status=SandboxStatus(state=\"Running\"),\n            metadata=None,\n            entrypoint=[\"python\"],\n            expires_at=None,\n            created_at=now,\n        )\n\n        class StubService:\n            @staticmethod\n            def get_sandbox(sandbox_id: str) -> Sandbox:\n                return sandbox\n\n        monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n        response = client.get(\"/sandboxes/sandbox-123\", headers=auth_headers)\n        assert response.status_code == 200\n\n        payload = response.json()\n        assert payload[\"metadata\"] is None\n        assert payload[\"id\"] == \"sandbox-123\"\n        assert payload[\"entrypoint\"] == [\"python\"]\n        assert \"expiresAt\" in payload\n        assert payload[\"expiresAt\"] is None\n        assert \"createdAt\" in payload\n        assert payload[\"status\"][\"state\"] == \"Running\"\n        assert payload[\"status\"][\"reason\"] is None\n        assert payload[\"status\"][\"message\"] is None\n        assert payload[\"status\"][\"lastTransitionAt\"] is None\n\n    def test_get_sandbox_not_found(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test get sandbox with non-existent ID.\n        \"\"\"\n        pass\n\n\nclass TestDeleteSandbox:\n    \"\"\"Test cases for delete sandbox endpoint.\"\"\"\n\n    def test_delete_sandbox_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful sandbox deletion.\n        \"\"\"\n        pass\n\n    def test_delete_sandbox_not_found(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test delete sandbox with non-existent ID.\n        \"\"\"\n        pass\n\n\nclass TestPauseResumeSandbox:\n    \"\"\"Test cases for pause and resume endpoints.\"\"\"\n\n    def test_pause_sandbox_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful sandbox pause.\n        \"\"\"\n        pass\n\n    def test_resume_sandbox_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful sandbox resume.\n        \"\"\"\n        pass\n\n    def test_pause_sandbox_invalid_state(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test pause sandbox in invalid state.\n        \"\"\"\n        pass\n\n\nclass TestRenewExpiration:\n    \"\"\"Test cases for renew expiration endpoint.\"\"\"\n\n    def test_renew_expiration_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful expiration renewal.\n        \"\"\"\n        pass\n\n    def test_renew_expiration_invalid_time(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test renew expiration with invalid time.\n        \"\"\"\n        pass\n\n\nclass TestGetEndpoint:\n    \"\"\"Test cases for get endpoint endpoint.\"\"\"\n\n    def test_get_endpoint_success(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test successful endpoint retrieval.\n        \"\"\"\n        pass\n\n    def test_get_endpoint_invalid_port(\n        self,\n        client: TestClient,\n        auth_headers: dict,\n    ):\n        \"\"\"\n        Test get endpoint with invalid port.\n        \"\"\"\n        pass\n"
  },
  {
    "path": "server/tests/test_routes_create_delete.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import CreateSandboxResponse, SandboxStatus\n\n\ndef test_create_sandbox_returns_202_and_service_payload(\n    client: TestClient,\n    auth_headers: dict,\n    sample_sandbox_request: dict,\n    monkeypatch,\n) -> None:\n    now = datetime.now(timezone.utc)\n    calls: list[object] = []\n\n    class StubService:\n        @staticmethod\n        async def create_sandbox(request) -> CreateSandboxResponse:\n            calls.append(request)\n            return CreateSandboxResponse(\n                id=\"sbx-001\",\n                status=SandboxStatus(state=\"Pending\"),\n                metadata={\"project\": \"test-project\"},\n                expiresAt=now + timedelta(hours=1),\n                createdAt=now,\n                entrypoint=[\"python\", \"-c\", \"print('Hello from sandbox')\"],\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\n        \"/v1/sandboxes\",\n        headers=auth_headers,\n        json=sample_sandbox_request,\n    )\n\n    assert response.status_code == 202\n    payload = response.json()\n    assert payload[\"id\"] == \"sbx-001\"\n    assert payload[\"status\"][\"state\"] == \"Pending\"\n    assert payload[\"metadata\"][\"project\"] == \"test-project\"\n    assert payload[\"entrypoint\"] == [\"python\", \"-c\", \"print('Hello from sandbox')\"]\n    assert len(calls) == 1\n    assert calls[0].image.uri == \"python:3.11\"\n\n\ndef test_create_sandbox_manual_cleanup_returns_null_expiration(\n    client: TestClient,\n    auth_headers: dict,\n    sample_sandbox_request: dict,\n    monkeypatch,\n) -> None:\n    now = datetime.now(timezone.utc)\n\n    class StubService:\n        @staticmethod\n        async def create_sandbox(request) -> CreateSandboxResponse:\n            return CreateSandboxResponse(\n                id=\"sbx-manual\",\n                status=SandboxStatus(state=\"Pending\"),\n                metadata=None,\n                expiresAt=None,\n                createdAt=now,\n                entrypoint=[\"python\", \"-c\", \"print('Hello from sandbox')\"],\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    sample_sandbox_request.pop(\"timeout\", None)\n\n    response = client.post(\n        \"/v1/sandboxes\",\n        headers=auth_headers,\n        json=sample_sandbox_request,\n    )\n\n    assert response.status_code == 202\n    payload = response.json()\n    assert payload[\"expiresAt\"] is None\n    assert payload[\"metadata\"] is None\n    assert payload[\"status\"][\"reason\"] is None\n    assert payload[\"status\"][\"message\"] is None\n    assert payload[\"status\"][\"lastTransitionAt\"] is None\n\n\ndef test_create_sandbox_rejects_invalid_request(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.post(\n        \"/v1/sandboxes\",\n        headers=auth_headers,\n        json={\"timeout\": 10},\n    )\n\n    assert response.status_code == 422\n\n\ndef test_delete_sandbox_returns_204_and_calls_service(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    calls: list[str] = []\n\n    class StubService:\n        @staticmethod\n        def delete_sandbox(sandbox_id: str) -> None:\n            calls.append(sandbox_id)\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.delete(\"/v1/sandboxes/sbx-001\", headers=auth_headers)\n\n    assert response.status_code == 204\n    assert response.text == \"\"\n    assert calls == [\"sbx-001\"]\n\n\ndef test_delete_sandbox_requires_api_key(client: TestClient) -> None:\n    response = client.delete(\"/v1/sandboxes/sbx-001\")\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n"
  },
  {
    "path": "server/tests/test_routes_endpoint_behavior.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import Endpoint\n\n\ndef test_get_endpoint_returns_service_result(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    calls: list[tuple[str, int]] = []\n\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int) -> Endpoint:\n            calls.append((sandbox_id, port))\n            return Endpoint(endpoint=\"10.57.1.91:40109/proxy/44772\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-001/endpoints/44772\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    assert response.json()[\"endpoint\"] == \"10.57.1.91:40109/proxy/44772\"\n    assert calls == [(\"sbx-001\", 44772)]\n\n\ndef test_get_endpoint_use_server_proxy_rewrites_url(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109/proxy/44772\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-001/endpoints/44772\",\n        params={\"use_server_proxy\": \"true\"},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    assert response.json()[\"endpoint\"] == \"testserver/sandboxes/sbx-001/proxy/44772\"\n\n\ndef test_get_endpoint_rejects_non_numeric_port(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.get(\n        \"/v1/sandboxes/sbx-001/endpoints/not-a-port\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 422\n"
  },
  {
    "path": "server/tests/test_routes_get_sandbox.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import ImageSpec, Sandbox, SandboxStatus\n\n\ndef test_get_sandbox_returns_service_payload(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    now = datetime.now(timezone.utc)\n\n    class StubService:\n        @staticmethod\n        def get_sandbox(sandbox_id: str) -> Sandbox:\n            assert sandbox_id == \"sbx-001\"\n            return Sandbox(\n                id=sandbox_id,\n                image=ImageSpec(uri=\"python:3.11\"),\n                status=SandboxStatus(state=\"Running\"),\n                metadata={\"team\": \"infra\"},\n                entrypoint=[\"python\", \"-V\"],\n                expiresAt=now + timedelta(hours=1),\n                createdAt=now,\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\"/v1/sandboxes/sbx-001\", headers=auth_headers)\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"id\"] == \"sbx-001\"\n    assert payload[\"status\"][\"state\"] == \"Running\"\n    assert payload[\"image\"][\"uri\"] == \"python:3.11\"\n\n\ndef test_get_sandbox_propagates_not_found(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_sandbox(sandbox_id: str) -> Sandbox:\n            raise HTTPException(\n                status_code=404,\n                detail={\n                    \"code\": \"SANDBOX_NOT_FOUND\",\n                    \"message\": f\"Sandbox {sandbox_id} not found\",\n                },\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\"/v1/sandboxes/missing\", headers=auth_headers)\n\n    assert response.status_code == 404\n    assert response.json() == {\n        \"code\": \"SANDBOX_NOT_FOUND\",\n        \"message\": \"Sandbox missing not found\",\n    }\n\n\ndef test_get_sandbox_requires_api_key(client: TestClient) -> None:\n    response = client.get(\"/v1/sandboxes/sbx-001\")\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n"
  },
  {
    "path": "server/tests/test_routes_list_sandboxes.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import (\n    ImageSpec,\n    ListSandboxesResponse,\n    PaginationInfo,\n    Sandbox,\n    SandboxStatus,\n)\n\n\ndef test_list_sandboxes_parses_filters_and_pagination(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    now = datetime.now(timezone.utc)\n    captured_requests: list[object] = []\n\n    class StubService:\n        @staticmethod\n        def list_sandboxes(request) -> ListSandboxesResponse:\n            captured_requests.append(request)\n            return ListSandboxesResponse(\n                items=[\n                    Sandbox(\n                        id=\"sbx-001\",\n                        image=ImageSpec(uri=\"python:3.11\"),\n                        status=SandboxStatus(state=\"Running\"),\n                        metadata={\"team\": \"infra\", \"project\": \"alpha\"},\n                        entrypoint=[\"python\", \"-V\"],\n                        expiresAt=now + timedelta(hours=1),\n                        createdAt=now,\n                    )\n                ],\n                pagination=PaginationInfo(\n                    page=2,\n                    pageSize=5,\n                    totalItems=8,\n                    totalPages=2,\n                    hasNextPage=False,\n                ),\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\n        \"/v1/sandboxes\",\n        params={\n            \"state\": [\"Running\", \"Paused\"],\n            \"metadata\": \"team=infra&project=alpha\",\n            \"page\": 2,\n            \"pageSize\": 5,\n        },\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    payload = response.json()\n    assert payload[\"pagination\"][\"page\"] == 2\n    assert payload[\"pagination\"][\"pageSize\"] == 5\n    assert payload[\"items\"][0][\"status\"][\"state\"] == \"Running\"\n    assert captured_requests[0].filter.state == [\"Running\", \"Paused\"]\n    assert captured_requests[0].filter.metadata == {\"team\": \"infra\", \"project\": \"alpha\"}\n    assert captured_requests[0].pagination.page == 2\n    assert captured_requests[0].pagination.page_size == 5\n\n\ndef test_list_sandboxes_rejects_malformed_metadata_query(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.get(\n        \"/v1/sandboxes\",\n        params={\"metadata\": \"team=infra&broken\"},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 400\n    assert response.json()[\"code\"] == \"INVALID_METADATA_FORMAT\"\n    assert \"bad query field\" in response.json()[\"message\"]\n\n\ndef test_list_sandboxes_keeps_blank_metadata_values(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    captured_requests: list[object] = []\n\n    class StubService:\n        @staticmethod\n        def list_sandboxes(request) -> ListSandboxesResponse:\n            captured_requests.append(request)\n            return ListSandboxesResponse(\n                items=[],\n                pagination=PaginationInfo(\n                    page=1,\n                    pageSize=20,\n                    totalItems=0,\n                    totalPages=0,\n                    hasNextPage=False,\n                ),\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\n        \"/v1/sandboxes\",\n        params={\"metadata\": \"team=infra&note=\"},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    assert captured_requests[0].filter.metadata == {\"team\": \"infra\", \"note\": \"\"}\n\n\ndef test_list_sandboxes_preserves_only_nullable_expires_at(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    now = datetime.now(timezone.utc)\n\n    class StubService:\n        @staticmethod\n        def list_sandboxes(request) -> ListSandboxesResponse:\n            return ListSandboxesResponse(\n                items=[\n                    Sandbox(\n                        id=\"sbx-manual\",\n                        image=ImageSpec(uri=\"python:3.11\"),\n                        status=SandboxStatus(state=\"Running\"),\n                        metadata=None,\n                        entrypoint=[\"python\"],\n                        expiresAt=None,\n                        createdAt=now,\n                    )\n                ],\n                pagination=PaginationInfo(\n                    page=1,\n                    pageSize=20,\n                    totalItems=1,\n                    totalPages=1,\n                    hasNextPage=False,\n                ),\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.get(\"/v1/sandboxes\", headers=auth_headers)\n\n    assert response.status_code == 200\n    item = response.json()[\"items\"][0]\n    assert item[\"expiresAt\"] is None\n    assert item[\"metadata\"] is None\n    assert item[\"status\"][\"reason\"] is None\n    assert item[\"status\"][\"message\"] is None\n    assert item[\"status\"][\"lastTransitionAt\"] is None\n\n\ndef test_list_sandboxes_validates_page_bounds(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.get(\n        \"/v1/sandboxes\",\n        params={\"page\": 0},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 422\n\n\ndef test_list_sandboxes_validates_page_size_upper_bound(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.get(\n        \"/v1/sandboxes\",\n        params={\"pageSize\": 201},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 422\n\n\ndef test_list_sandboxes_requires_api_key(client: TestClient) -> None:\n    response = client.get(\"/v1/sandboxes\")\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n"
  },
  {
    "path": "server/tests/test_routes_pause_resume.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\n\n\ndef test_pause_route_calls_service_and_returns_202(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    calls: list[str] = []\n\n    class StubService:\n        @staticmethod\n        def pause_sandbox(sandbox_id: str) -> None:\n            calls.append(sandbox_id)\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\"/v1/sandboxes/sbx-001/pause\", headers=auth_headers)\n\n    assert response.status_code == 202\n    assert calls == [\"sbx-001\"]\n\n\ndef test_resume_route_calls_service_and_returns_202(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    calls: list[str] = []\n\n    class StubService:\n        @staticmethod\n        def resume_sandbox(sandbox_id: str) -> None:\n            calls.append(sandbox_id)\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\"/v1/sandboxes/sbx-001/resume\", headers=auth_headers)\n\n    assert response.status_code == 202\n    assert calls == [\"sbx-001\"]\n\n\ndef test_pause_route_propagates_service_http_error(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def pause_sandbox(sandbox_id: str) -> None:\n            raise HTTPException(\n                status_code=404,\n                detail={\"code\": \"SANDBOX_NOT_FOUND\", \"message\": f\"Sandbox {sandbox_id} not found\"},\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\"/v1/sandboxes/missing/pause\", headers=auth_headers)\n\n    assert response.status_code == 404\n    assert response.json() == {\n        \"code\": \"SANDBOX_NOT_FOUND\",\n        \"message\": \"Sandbox missing not found\",\n    }\n\n\ndef test_pause_route_requires_api_key(client: TestClient) -> None:\n    response = client.post(\"/v1/sandboxes/sbx-001/pause\")\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n"
  },
  {
    "path": "server/tests/test_routes_proxy.py",
    "content": "# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport httpx\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import Endpoint\nfrom src.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER\n\n\nclass _FakeStreamingResponse:\n    def __init__(\n        self, status_code: int = 200, headers: dict | None = None, chunks: list[bytes] | None = None\n    ):\n        self.status_code = status_code\n        self.headers = httpx.Headers(headers or {})\n        self._chunks = chunks or []\n\n    async def aiter_bytes(self):\n        for chunk in self._chunks:\n            yield chunk\n\n\nclass _FakeAsyncClient:\n    def __init__(self):\n        self.built = None\n        self.response = _FakeStreamingResponse()\n        self.raise_connect_error = False\n        self.raise_generic_error = False\n\n    def build_request(\n        self,\n        method: str,\n        url: str,\n        headers: dict,\n        content,\n        params: str | None = None,\n    ):\n        self.built = {\n            \"method\": method,\n            \"url\": url,\n            \"params\": params,\n            \"headers\": headers,\n            \"content\": content,\n        }\n        return self.built\n\n    async def send(self, req, stream: bool = True):\n        if self.raise_connect_error:\n            raise httpx.ConnectError(\"connection refused\")\n        if self.raise_generic_error:\n            raise RuntimeError(\"unexpected proxy error\")\n        return self.response\n\n\ndef test_proxy_forwards_filtered_headers_and_query(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            assert sandbox_id == \"sbx-123\"\n            assert port == 44772\n            assert resolve_internal is True\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=201,\n        headers={\"x-backend\": \"yes\"},\n        chunks=[b\"proxy-ok\"],\n    )\n    client.app.state.http_client = fake_client\n\n    headers = {\n        **auth_headers,\n        \"Authorization\": \"Bearer top-secret\",\n        \"Cookie\": \"sid=secret\",\n        \"Connection\": \"keep-alive, X-Hop-Temp\",\n        \"Upgrade\": \"h2c\",\n        \"Trailer\": \"X-Checksum\",\n        \"X-Hop-Temp\": \"drop-me\",\n        \"X-Trace\": \"trace-1\",\n    }\n\n    response = client.post(\n        \"/v1/sandboxes/sbx-123/proxy/44772/api/run\",\n        params={\"q\": \"search\"},\n        headers=headers,\n        content=b'{\"hello\":\"world\"}',\n    )\n\n    assert response.status_code == 201\n    assert response.content == b\"proxy-ok\"\n    assert response.headers.get(\"x-backend\") == \"yes\"\n\n    assert fake_client.built is not None\n    assert fake_client.built[\"method\"] == \"POST\"\n    assert fake_client.built[\"url\"] == \"http://10.57.1.91:40109/api/run\"\n    assert fake_client.built[\"params\"] == \"q=search\"\n    forwarded_headers = fake_client.built[\"headers\"]\n    lowered_headers = {k.lower(): v for k, v in forwarded_headers.items()}\n    assert \"host\" not in lowered_headers\n    assert \"connection\" not in lowered_headers\n    assert \"upgrade\" not in lowered_headers\n    assert \"trailer\" not in lowered_headers\n    assert \"authorization\" not in lowered_headers\n    assert \"cookie\" not in lowered_headers\n    assert \"x-hop-temp\" not in lowered_headers\n    assert lowered_headers.get(\"x-trace\") == \"trace-1\"\n\n\ndef test_proxy_forwards_get_request_with_query_params(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    \"\"\"Test that GET requests with query parameters are forwarded correctly.\n\n    This test verifies the fix for issue #484 where GET requests with query\n    parameters were failing with 400 MISSING_QUERY when using use_server_proxy.\n    The query string should be passed via httpx params, not embedded in URL.\n    \"\"\"\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            assert sandbox_id == \"sbx-123\"\n            assert port == 44772\n            assert resolve_internal is True\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=200,\n        headers={\"content-type\": \"application/json\"},\n        chunks=[b'[{\"name\":\"file.txt\",\"size\":100}]'],\n    )\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/44772/files/search\",\n        params={\"path\": \"/workspace\"},\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    assert fake_client.built is not None\n    assert fake_client.built[\"method\"] == \"GET\"\n    assert fake_client.built[\"url\"] == \"http://10.57.1.91:40109/files/search\"\n    assert fake_client.built[\"params\"] == \"path=%2Fworkspace\"\n    assert fake_client.built[\"content\"] is None\n\n\ndef test_proxy_forwards_delete_request_with_body(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    \"\"\"Test that DELETE requests with body payload are forwarded correctly.\n\n    This verifies that DELETE requests with JSON/body payload are not\n    incorrectly stripped when proxying.\n    \"\"\"\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=200,\n        headers={\"content-type\": \"application/json\"},\n        chunks=[b'{\"deleted\":true}'],\n    )\n    client.app.state.http_client = fake_client\n\n    response = client.request(\n        \"DELETE\",\n        \"/v1/sandboxes/sbx-123/proxy/44772/resources\",\n        headers=auth_headers,\n        content=b'{\"id\": \"resource-123\"}',\n    )\n\n    assert response.status_code == 200\n    assert fake_client.built is not None\n    assert fake_client.built[\"method\"] == \"DELETE\"\n    assert fake_client.built[\"content\"] is not None\n\n\ndef test_proxy_filters_response_hop_by_hop_headers(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            assert resolve_internal is True\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=200,\n        headers={\n            \"x-backend\": \"yes\",\n            \"Connection\": \"keep-alive, X-Hop-Temp\",\n            \"Keep-Alive\": \"timeout=5\",\n            \"Trailer\": \"X-Checksum\",\n            \"X-Hop-Temp\": \"drop-me\",\n        },\n        chunks=[b\"proxy-ok\"],\n    )\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/44772/healthz\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 200\n    assert response.content == b\"proxy-ok\"\n    assert response.headers.get(\"x-backend\") == \"yes\"\n    assert response.headers.get(\"connection\") is None\n    assert response.headers.get(\"keep-alive\") is None\n    assert response.headers.get(\"trailer\") is None\n    assert response.headers.get(\"x-hop-temp\") is None\n\n\ndef test_proxy_rejects_websocket_upgrade(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    client.app.state.http_client = _FakeAsyncClient()\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/44772/ws\",\n        headers={**auth_headers, \"Upgrade\": \"websocket\"},\n    )\n\n    assert response.status_code == 400\n    assert response.json()[\"message\"] == \"Websocket upgrade is not supported yet\"\n\n\ndef test_proxy_rejects_websocket_upgrade_for_post_and_mixed_case_header(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    client.app.state.http_client = _FakeAsyncClient()\n\n    response = client.post(\n        \"/v1/sandboxes/sbx-123/proxy/44772/ws\",\n        headers={**auth_headers, \"Upgrade\": \"WebSocket\"},\n        content=b\"{}\",\n    )\n\n    assert response.status_code == 400\n    assert response.json()[\"message\"] == \"Websocket upgrade is not supported yet\"\n\n\ndef test_proxy_maps_connect_error_to_502(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    fake_client = _FakeAsyncClient()\n    fake_client.raise_connect_error = True\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/44772/healthz\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 502\n    assert \"Could not connect to the backend sandbox\" in response.json()[\"message\"]\n\n\ndef test_proxy_maps_unexpected_error_to_500(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            return Endpoint(endpoint=\"10.57.1.91:40109\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    fake_client = _FakeAsyncClient()\n    fake_client.raise_generic_error = True\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/44772/healthz\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 500\n    assert \"An internal error occurred in the proxy\" in response.json()[\"message\"]\n\n\ndef test_proxy_forwards_18080_without_server_side_egress_auth_check(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            assert port == 18080\n            assert resolve_internal is True\n            return Endpoint(endpoint=\"10.57.1.91:18080\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=401,\n        headers={\"content-type\": \"application/json\"},\n        chunks=[b'{\"code\":\"UNAUTHORIZED\"}'],\n    )\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/18080/policy\",\n        headers=auth_headers,\n    )\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"UNAUTHORIZED\"\n    assert fake_client.built is not None\n    assert fake_client.built[\"url\"] == \"http://10.57.1.91:18080/policy\"\n\n\ndef test_proxy_forwards_egress_auth_header_for_18080(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:\n            assert port == 18080\n            assert resolve_internal is True\n            return Endpoint(endpoint=\"10.57.1.91:18080\")\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    fake_client = _FakeAsyncClient()\n    fake_client.response = _FakeStreamingResponse(\n        status_code=200,\n        headers={\"content-type\": \"application/json\"},\n        chunks=[b'{\"status\":\"ok\"}'],\n    )\n    client.app.state.http_client = fake_client\n\n    response = client.get(\n        \"/v1/sandboxes/sbx-123/proxy/18080/policy\",\n        headers={**auth_headers, OPEN_SANDBOX_EGRESS_AUTH_HEADER: \"egress-token\"},\n    )\n\n    assert response.status_code == 200\n    assert fake_client.built is not None\n    lowered_headers = {k.lower(): v for k, v in fake_client.built[\"headers\"].items()}\n    assert lowered_headers[OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower()] == \"egress-token\"\n"
  },
  {
    "path": "server/tests/test_routes_renew_expiration.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi.exceptions import HTTPException\nfrom fastapi.testclient import TestClient\n\nfrom src.api import lifecycle\nfrom src.api.schema import RenewSandboxExpirationResponse\n\n\ndef test_renew_expiration_returns_updated_timestamp(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    target = datetime.now(timezone.utc) + timedelta(hours=2)\n    calls: list[tuple[str, datetime]] = []\n\n    class StubService:\n        @staticmethod\n        def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse:\n            calls.append((sandbox_id, request.expires_at))\n            return RenewSandboxExpirationResponse(expiresAt=target)\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\n        \"/v1/sandboxes/sbx-001/renew-expiration\",\n        headers=auth_headers,\n        json={\"expiresAt\": target.isoformat()},\n    )\n\n    assert response.status_code == 200\n    expires_at = datetime.fromisoformat(response.json()[\"expiresAt\"].replace(\"Z\", \"+00:00\"))\n    assert expires_at == target\n    assert calls == [(\"sbx-001\", target)]\n\n\ndef test_renew_expiration_rejects_invalid_payload(\n    client: TestClient,\n    auth_headers: dict,\n) -> None:\n    response = client.post(\n        \"/v1/sandboxes/sbx-001/renew-expiration\",\n        headers=auth_headers,\n        json={\"expiresAt\": \"not-a-datetime\"},\n    )\n\n    assert response.status_code == 422\n\n\ndef test_renew_expiration_propagates_service_http_error(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse:\n            raise HTTPException(\n                status_code=409,\n                detail={\n                    \"code\": \"INVALID_EXPIRES_AT\",\n                    \"message\": f\"Requested expiresAt is not valid for sandbox {sandbox_id}\",\n                },\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\n        \"/v1/sandboxes/sbx-001/renew-expiration\",\n        headers=auth_headers,\n        json={\"expiresAt\": \"2030-01-01T00:00:00Z\"},\n    )\n\n    assert response.status_code == 409\n    assert response.json() == {\n        \"code\": \"INVALID_EXPIRES_AT\",\n        \"message\": \"Requested expiresAt is not valid for sandbox sbx-001\",\n    }\n\n\ndef test_renew_expiration_returns_409_for_manual_cleanup_sandbox(\n    client: TestClient,\n    auth_headers: dict,\n    monkeypatch,\n) -> None:\n    class StubService:\n        @staticmethod\n        def renew_expiration(sandbox_id: str, request) -> RenewSandboxExpirationResponse:\n            raise HTTPException(\n                status_code=409,\n                detail={\n                    \"code\": \"DOCKER::INVALID_EXPIRATION\",\n                    \"message\": f\"Sandbox {sandbox_id} does not have automatic expiration enabled.\",\n                },\n            )\n\n    monkeypatch.setattr(lifecycle, \"sandbox_service\", StubService())\n\n    response = client.post(\n        \"/v1/sandboxes/sbx-manual/renew-expiration\",\n        headers=auth_headers,\n        json={\"expiresAt\": \"2030-01-01T00:00:00Z\"},\n    )\n\n    assert response.status_code == 409\n    assert response.json() == {\n        \"code\": \"DOCKER::INVALID_EXPIRATION\",\n        \"message\": \"Sandbox sbx-manual does not have automatic expiration enabled.\",\n    }\n\n\ndef test_renew_expiration_requires_api_key(client: TestClient) -> None:\n    response = client.post(\n        \"/v1/sandboxes/sbx-001/renew-expiration\",\n        json={\"expiresAt\": \"2030-01-01T00:00:00Z\"},\n    )\n\n    assert response.status_code == 401\n    assert response.json()[\"code\"] == \"MISSING_API_KEY\"\n"
  },
  {
    "path": "server/tests/test_schema.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\"\"\"Tests for Pydantic schema models.\"\"\"\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom src.api.schema import (\n    CreateSandboxRequest,\n    Host,\n    ImageSpec,\n    OSSFS,\n    PVC,\n    ResourceLimits,\n    Volume,\n)\n\n\n# ============================================================================\n# Host Tests\n# ============================================================================\n\n\nclass TestHost:\n    \"\"\"Tests for Host model.\"\"\"\n\n    def test_valid_path(self):\n        \"\"\"Valid absolute path should be accepted.\"\"\"\n        backend = Host(path=\"/data/opensandbox\")\n        assert backend.path == \"/data/opensandbox\"\n\n    def test_path_required(self):\n        \"\"\"Path field should be required.\"\"\"\n        with pytest.raises(ValidationError) as exc_info:\n            Host()  # type: ignore\n        errors = exc_info.value.errors()\n        assert any(e[\"loc\"] == (\"path\",) for e in errors)\n\n    def test_serialization(self):\n        \"\"\"Model should serialize correctly.\"\"\"\n        backend = Host(path=\"/data/opensandbox\")\n        data = backend.model_dump()\n        assert data == {\"path\": \"/data/opensandbox\"}\n\n    def test_deserialization(self):\n        \"\"\"Model should deserialize correctly.\"\"\"\n        data = {\"path\": \"/data/opensandbox\"}\n        backend = Host.model_validate(data)\n        assert backend.path == \"/data/opensandbox\"\n\n\n# ============================================================================\n# PVC Tests\n# ============================================================================\n\n\nclass TestPVC:\n    \"\"\"Tests for PVC model.\"\"\"\n\n    def test_valid_claim_name(self):\n        \"\"\"Valid claim name should be accepted.\"\"\"\n        backend = PVC(claim_name=\"my-pvc\")\n        assert backend.claim_name == \"my-pvc\"\n\n    def test_claim_name_alias(self):\n        \"\"\"claimName alias should work.\"\"\"\n        data = {\"claimName\": \"my-pvc\"}\n        backend = PVC.model_validate(data)\n        assert backend.claim_name == \"my-pvc\"\n\n    def test_serialization_uses_alias(self):\n        \"\"\"Serialization should use camelCase alias.\"\"\"\n        backend = PVC(claim_name=\"my-pvc\")\n        data = backend.model_dump(by_alias=True)\n        assert data == {\"claimName\": \"my-pvc\"}\n\n    def test_claim_name_required(self):\n        \"\"\"claim_name field should be required.\"\"\"\n        with pytest.raises(ValidationError) as exc_info:\n            PVC()  # type: ignore\n        errors = exc_info.value.errors()\n        assert any(\"claim_name\" in str(e[\"loc\"]) or \"claimName\" in str(e[\"loc\"]) for e in errors)\n\n\n# ============================================================================\n# OSSFS Tests\n# ============================================================================\n\n\nclass TestOSSFS:\n    \"\"\"Tests for OSSFS model.\"\"\"\n\n    def test_valid_ossfs(self):\n        backend = OSSFS(\n            bucket=\"bucket-test-3\",\n            endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n            version=\"2.0\",\n            options=[\"allow_other\"],\n            access_key_id=\"AKIDEXAMPLE\",\n            access_key_secret=\"SECRETEXAMPLE\",\n        )\n        assert backend.bucket == \"bucket-test-3\"\n        assert backend.version == \"2.0\"\n        assert backend.access_key_id == \"AKIDEXAMPLE\"\n\n    def test_default_ossfs_version_is_2_0(self):\n        backend = OSSFS(\n            bucket=\"bucket-test-3\",\n            endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n            access_key_id=\"AKIDEXAMPLE\",\n            access_key_secret=\"SECRETEXAMPLE\",\n        )\n        assert backend.version == \"2.0\"\n\n    def test_inline_credentials_required(self):\n        with pytest.raises(ValidationError):\n            OSSFS(  # type: ignore\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n            )\n\n\n# ============================================================================\n# Volume Tests\n# ============================================================================\n\n\nclass TestVolume:\n    \"\"\"Tests for Volume model.\"\"\"\n\n    def test_valid_host_volume(self):\n        \"\"\"Valid host volume should be accepted.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n        )\n        assert volume.name == \"workdir\"\n        assert volume.host is not None\n        assert volume.host.path == \"/data/opensandbox\"\n        assert volume.mount_path == \"/mnt/work\"\n        assert volume.read_only is False\n        assert volume.pvc is None\n        assert volume.sub_path is None\n\n    def test_valid_pvc_volume(self):\n        \"\"\"Valid PVC volume should be accepted.\"\"\"\n        volume = Volume(\n            name=\"models\",\n            pvc=PVC(claim_name=\"shared-models-pvc\"),\n            mount_path=\"/mnt/models\",\n            read_only=True,\n        )\n        assert volume.name == \"models\"\n        assert volume.pvc is not None\n        assert volume.pvc.claim_name == \"shared-models-pvc\"\n        assert volume.mount_path == \"/mnt/models\"\n        assert volume.read_only is True\n        assert volume.host is None\n\n    def test_valid_volume_with_subpath(self):\n        \"\"\"Volume with subPath should be accepted.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        assert volume.sub_path == \"task-001\"\n\n    def test_valid_ossfs_volume(self):\n        \"\"\"Valid OSSFS volume should be accepted.\"\"\"\n        volume = Volume(\n            name=\"data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            sub_path=\"task-001\",\n        )\n        assert volume.ossfs is not None\n        assert volume.ossfs.access_key_id == \"AKIDEXAMPLE\"\n        assert volume.sub_path == \"task-001\"\n\n    def test_no_backend_raises(self):\n        \"\"\"Volume without any backend should raise ValidationError.\"\"\"\n        with pytest.raises(ValidationError) as exc_info:\n            Volume(\n                name=\"workdir\",\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            )\n        # Check that validation error mentions backend\n        error_message = str(exc_info.value)\n        assert \"backend\" in error_message.lower()\n\n    def test_multiple_backends_raises(self):\n        \"\"\"Volume with multiple backends should raise ValidationError.\"\"\"\n        with pytest.raises(ValidationError) as exc_info:\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/opensandbox\"),\n                pvc=PVC(claim_name=\"my-pvc\"),\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            )\n        # Check that validation error mentions backend\n        error_message = str(exc_info.value)\n        assert \"backend\" in error_message.lower()\n\n    def test_serialization_host_volume(self):\n        \"\"\"Host volume should serialize correctly with camelCase aliases.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        data = volume.model_dump(by_alias=True, exclude_none=True)\n        assert data == {\n            \"name\": \"workdir\",\n            \"host\": {\"path\": \"/data/opensandbox\"},\n            \"mountPath\": \"/mnt/work\",\n            \"readOnly\": False,\n            \"subPath\": \"task-001\",\n        }\n\n    def test_serialization_pvc_volume(self):\n        \"\"\"PVC volume should serialize correctly with camelCase aliases.\"\"\"\n        volume = Volume(\n            name=\"models\",\n            pvc=PVC(claim_name=\"shared-models-pvc\"),\n            mount_path=\"/mnt/models\",\n            read_only=True,\n        )\n        data = volume.model_dump(by_alias=True, exclude_none=True)\n        assert data == {\n            \"name\": \"models\",\n            \"pvc\": {\"claimName\": \"shared-models-pvc\"},\n            \"mountPath\": \"/mnt/models\",\n            \"readOnly\": True,\n        }\n\n    def test_deserialization_host_volume(self):\n        \"\"\"Host volume should deserialize correctly from camelCase.\"\"\"\n        data = {\n            \"name\": \"workdir\",\n            \"host\": {\"path\": \"/data/opensandbox\"},\n            \"mountPath\": \"/mnt/work\",\n            \"readOnly\": False,\n            \"subPath\": \"task-001\",\n        }\n        volume = Volume.model_validate(data)\n        assert volume.name == \"workdir\"\n        assert volume.host is not None\n        assert volume.host.path == \"/data/opensandbox\"\n        assert volume.mount_path == \"/mnt/work\"\n        assert volume.read_only is False\n        assert volume.sub_path == \"task-001\"\n\n    def test_deserialization_pvc_volume(self):\n        \"\"\"PVC volume should deserialize correctly from camelCase.\"\"\"\n        data = {\n            \"name\": \"models\",\n            \"pvc\": {\"claimName\": \"shared-models-pvc\"},\n            \"mountPath\": \"/mnt/models\",\n            \"readOnly\": True,\n        }\n        volume = Volume.model_validate(data)\n        assert volume.name == \"models\"\n        assert volume.pvc is not None\n        assert volume.pvc.claim_name == \"shared-models-pvc\"\n        assert volume.mount_path == \"/mnt/models\"\n        assert volume.read_only is True\n\n    def test_serialization_ossfs_volume(self):\n        volume = Volume(\n            name=\"data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        data = volume.model_dump(by_alias=True, exclude_none=True)\n        assert data[\"ossfs\"][\"bucket\"] == \"bucket-test-3\"\n        assert data[\"ossfs\"][\"accessKeyId\"] == \"AKIDEXAMPLE\"\n        assert data[\"subPath\"] == \"task-001\"\n\n\n# ============================================================================\n# CreateSandboxRequest with Volumes Tests\n# ============================================================================\n\n\nclass TestCreateSandboxRequestWithVolumes:\n    \"\"\"Tests for CreateSandboxRequest with volumes field.\"\"\"\n\n    def test_request_without_timeout_uses_manual_cleanup(self):\n        \"\"\"Request without timeout should be valid and represent manual cleanup mode.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n        )\n        assert request.timeout is None\n\n    def test_request_without_volumes(self):\n        \"\"\"Request without volumes should be valid.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n        )\n        assert request.volumes is None\n\n    def test_request_with_empty_volumes(self):\n        \"\"\"Request with empty volumes list should be valid.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            volumes=[],\n        )\n        assert request.volumes == []\n\n    def test_request_with_host_volume(self):\n        \"\"\"Request with host volume should be valid.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            volumes=[\n                Volume(\n                    name=\"workdir\",\n                    host=Host(path=\"/data/opensandbox\"),\n                    mount_path=\"/mnt/work\",\n                    read_only=False,\n                )\n            ],\n        )\n        assert request.volumes is not None\n        assert len(request.volumes) == 1\n        assert request.volumes[0].name == \"workdir\"\n\n    def test_request_with_pvc_volume(self):\n        \"\"\"Request with PVC volume should be valid.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            volumes=[\n                Volume(\n                    name=\"models\",\n                    pvc=PVC(claim_name=\"shared-models-pvc\"),\n                    mount_path=\"/mnt/models\",\n                    read_only=True,\n                )\n            ],\n        )\n        assert request.volumes is not None\n        assert len(request.volumes) == 1\n        assert request.volumes[0].pvc is not None\n        assert request.volumes[0].pvc.claim_name == \"shared-models-pvc\"\n\n    def test_request_with_multiple_volumes(self):\n        \"\"\"Request with multiple volumes should be valid.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            volumes=[\n                Volume(\n                    name=\"workdir\",\n                    host=Host(path=\"/data/opensandbox\"),\n                    mount_path=\"/mnt/work\",\n                    read_only=False,\n                ),\n                Volume(\n                    name=\"models\",\n                    pvc=PVC(claim_name=\"shared-models-pvc\"),\n                    mount_path=\"/mnt/models\",\n                    read_only=True,\n                ),\n            ],\n        )\n        assert request.volumes is not None\n        assert len(request.volumes) == 2\n\n    def test_serialization_with_volumes(self):\n        \"\"\"Request with volumes should serialize correctly.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=3600,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            volumes=[\n                Volume(\n                    name=\"workdir\",\n                    host=Host(path=\"/data/opensandbox\"),\n                    mount_path=\"/mnt/work\",\n                    read_only=False,\n                    sub_path=\"task-001\",\n                )\n            ],\n        )\n        data = request.model_dump(by_alias=True, exclude_none=True)\n        assert \"volumes\" in data\n        assert len(data[\"volumes\"]) == 1\n        assert data[\"volumes\"][0][\"name\"] == \"workdir\"\n        assert data[\"volumes\"][0][\"mountPath\"] == \"/mnt/work\"\n        assert data[\"volumes\"][0][\"readOnly\"] is False\n        assert data[\"volumes\"][0][\"subPath\"] == \"task-001\"\n\n    def test_deserialization_with_volumes(self):\n        \"\"\"Request with volumes should deserialize correctly.\"\"\"\n        data = {\n            \"image\": {\"uri\": \"python:3.11\"},\n            \"timeout\": 3600,\n            \"resourceLimits\": {\"cpu\": \"500m\", \"memory\": \"512Mi\"},\n            \"entrypoint\": [\"python\", \"-c\", \"print('hello')\"],\n            \"volumes\": [\n                {\n                    \"name\": \"workdir\",\n                    \"host\": {\"path\": \"/data/opensandbox\"},\n                    \"mountPath\": \"/mnt/work\",\n                    \"readOnly\": False,\n                    \"subPath\": \"task-001\",\n                },\n                {\n                    \"name\": \"models\",\n                    \"pvc\": {\"claimName\": \"shared-models-pvc\"},\n                    \"mountPath\": \"/mnt/models\",\n                    \"readOnly\": True,\n                },\n            ],\n        }\n        request = CreateSandboxRequest.model_validate(data)\n        assert request.volumes is not None\n        assert len(request.volumes) == 2\n\n        # Check host volume\n        assert request.volumes[0].name == \"workdir\"\n        assert request.volumes[0].host is not None\n        assert request.volumes[0].host.path == \"/data/opensandbox\"\n        assert request.volumes[0].mount_path == \"/mnt/work\"\n        assert request.volumes[0].read_only is False\n        assert request.volumes[0].sub_path == \"task-001\"\n\n        # Check PVC volume\n        assert request.volumes[1].name == \"models\"\n        assert request.volumes[1].pvc is not None\n        assert request.volumes[1].pvc.claim_name == \"shared-models-pvc\"\n        assert request.volumes[1].mount_path == \"/mnt/models\"\n        assert request.volumes[1].read_only is True\n\n    def test_request_rejects_zero_timeout(self):\n        \"\"\"Zero timeout should still be rejected.\"\"\"\n        with pytest.raises(ValidationError):\n            CreateSandboxRequest(\n                image=ImageSpec(uri=\"python:3.11\"),\n                timeout=0,\n                resource_limits=ResourceLimits({\"cpu\": \"500m\"}),\n                entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n            )\n\n    def test_request_allows_timeout_above_previous_hardcoded_limit(self):\n        \"\"\"Schema should not hardcode the server-side maximum timeout.\"\"\"\n        request = CreateSandboxRequest(\n            image=ImageSpec(uri=\"python:3.11\"),\n            timeout=172800,\n            resource_limits=ResourceLimits({\"cpu\": \"500m\", \"memory\": \"512Mi\"}),\n            entrypoint=[\"python\", \"-c\", \"print('hello')\"],\n        )\n\n        assert request.timeout == 172800\n"
  },
  {
    "path": "server/tests/test_validators.py",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nimport pytest\nfrom fastapi import HTTPException\n\nfrom src.api.schema import Host, OSSFS, PVC, Volume\nfrom src.services.constants import SandboxErrorCodes\nfrom src.services.validators import (\n    ensure_metadata_labels,\n    ensure_timeout_within_limit,\n    ensure_valid_host_path,\n    ensure_valid_mount_path,\n    ensure_valid_pvc_name,\n    ensure_valid_sub_path,\n    ensure_valid_volume_name,\n    ensure_volumes_valid,\n)\n\n\ndef test_ensure_metadata_labels_accepts_common_k8s_forms():\n    # Various valid label shapes: with/without prefix, mixed chars, empty value allowed.\n    valid_metadata = {\n        \"app\": \"web\",\n        \"k8s.io/name\": \"app-1\",\n        \"example.com/label\": \"a.b_c-1\",\n        \"team\": \"A1_b-2.c\",\n        \"empty\": \"\",\n    }\n\n    # Should not raise\n    ensure_metadata_labels(valid_metadata)\n\n\ndef test_ensure_metadata_labels_allows_none_or_empty():\n    ensure_metadata_labels(None)\n    ensure_metadata_labels({})\n\n\ndef test_ensure_metadata_labels_rejects_name_too_long():\n    \"\"\"Label name part exceeding 63 characters should be rejected.\"\"\"\n    long_name = \"a\" * 64\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({long_name: \"value\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_rejects_prefix_too_long():\n    \"\"\"Label prefix (DNS subdomain) exceeding 253 characters should be rejected.\"\"\"\n    # Build a prefix that is longer than 253 chars: 5 labels of 62 chars = 314 chars\n    label_part = \"a\" * 62\n    long_prefix = \".\".join([label_part] * 5)  # 62*5 + 4 = 314 chars\n    key = f\"{long_prefix}/name\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({key: \"value\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_accepts_key_with_max_length_prefix_and_name():\n    \"\"\"Valid key where prefix <= 253 chars and name <= 63 chars but total > 253 should be accepted.\"\"\"\n    # prefix = 4 labels of 62 chars = 62*4 + 3 = 251 chars (valid DNS subdomain)\n    label_part = \"a\" * 62\n    prefix = \".\".join([label_part] * 4)  # 251 chars\n    assert len(prefix) == 251\n    key = f\"{prefix}/valid-name\"  # total = 251 + 1 + 10 = 262 chars, but prefix <= 253 ✓\n    # This was previously rejected due to the incorrect total-length check.\n    ensure_metadata_labels({key: \"value\"})  # Should NOT raise\n\n\ndef test_ensure_metadata_labels_rejects_invalid_prefix_format():\n    \"\"\"Label prefix with invalid DNS subdomain characters should be rejected.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"INVALID_PREFIX.io/name\": \"value\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_rejects_value_too_long():\n    \"\"\"Label value exceeding 63 characters should be rejected.\"\"\"\n    long_value = \"a\" * 64\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"app\": long_value})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_rejects_non_string_key():\n    \"\"\"Non-string keys in metadata should be rejected.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({1: \"value\"})  # type: ignore[dict-item]\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_rejects_key_with_empty_prefix():\n    \"\"\"Key with an empty prefix (starts with '/') should be rejected.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"/name\": \"value\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_metadata_labels_rejects_reserved_prefix():\n    \"\"\"User metadata must not use the opensandbox.io/ reserved prefix.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"opensandbox.io/expires-at\": \"2030-01-01T00:00:00Z\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n    assert \"reserved prefix\" in exc_info.value.detail[\"message\"]\n\n\ndef test_ensure_metadata_labels_rejects_manual_cleanup_key():\n    \"\"\"User must not inject the manual-cleanup lifecycle label.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"opensandbox.io/manual-cleanup\": \"true\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n    assert \"reserved prefix\" in exc_info.value.detail[\"message\"]\n\n\ndef test_ensure_metadata_labels_rejects_arbitrary_reserved_key():\n    \"\"\"Any key under opensandbox.io/ should be rejected, not just known labels.\"\"\"\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_metadata_labels({\"opensandbox.io/custom\": \"value\"})\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_METADATA_LABEL\n\n\ndef test_ensure_timeout_within_limit_allows_equal_boundary():\n    ensure_timeout_within_limit(3600, 3600)\n\n\ndef test_ensure_timeout_within_limit_allows_disabled_upper_bound():\n    ensure_timeout_within_limit(7200, None)\n\n\ndef test_ensure_timeout_within_limit_rejects_timeout_above_limit():\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_timeout_within_limit(3601, 3600)\n\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n\n\ndef test_ensure_timeout_within_limit_rejects_unrepresentable_timeout():\n    with pytest.raises(HTTPException) as exc_info:\n        ensure_timeout_within_limit(10**20, None)\n\n    assert exc_info.value.status_code == 400\n    assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PARAMETER\n    assert \"too large\" in exc_info.value.detail[\"message\"]\n\n\n# ============================================================================\n# Volume Name Validation Tests\n# ============================================================================\n\n\nclass TestEnsureValidVolumeName:\n    \"\"\"Tests for ensure_valid_volume_name function.\"\"\"\n\n    def test_valid_simple_name(self):\n        \"\"\"Simple lowercase names should be valid.\"\"\"\n        ensure_valid_volume_name(\"workdir\")\n        ensure_valid_volume_name(\"data\")\n        ensure_valid_volume_name(\"models\")\n\n    def test_valid_name_with_numbers(self):\n        \"\"\"Names with numbers should be valid.\"\"\"\n        ensure_valid_volume_name(\"data1\")\n        ensure_valid_volume_name(\"vol2\")\n        ensure_valid_volume_name(\"123\")\n\n    def test_valid_name_with_hyphens(self):\n        \"\"\"Names with hyphens should be valid.\"\"\"\n        ensure_valid_volume_name(\"my-volume\")\n        ensure_valid_volume_name(\"data-cache-1\")\n        ensure_valid_volume_name(\"a-b-c\")\n\n    def test_empty_name_raises(self):\n        \"\"\"Empty name should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(\"\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n    def test_name_too_long_raises(self):\n        \"\"\"Name exceeding 63 characters should raise HTTPException.\"\"\"\n        long_name = \"a\" * 64\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(long_name)\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n    def test_uppercase_name_raises(self):\n        \"\"\"Uppercase letters should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(\"MyVolume\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n    def test_underscore_name_raises(self):\n        \"\"\"Underscores should raise HTTPException (not valid DNS label).\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(\"my_volume\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n    def test_name_starting_with_hyphen_raises(self):\n        \"\"\"Names starting with hyphen should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(\"-volume\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n    def test_name_ending_with_hyphen_raises(self):\n        \"\"\"Names ending with hyphen should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_volume_name(\"volume-\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_VOLUME_NAME\n\n\n# ============================================================================\n# Mount Path Validation Tests\n# ============================================================================\n\n\nclass TestEnsureValidMountPath:\n    \"\"\"Tests for ensure_valid_mount_path function.\"\"\"\n\n    def test_valid_absolute_path(self):\n        \"\"\"Absolute paths should be valid.\"\"\"\n        ensure_valid_mount_path(\"/mnt/data\")\n        ensure_valid_mount_path(\"/\")\n        ensure_valid_mount_path(\"/home/user/work\")\n\n    def test_empty_path_raises(self):\n        \"\"\"Empty path should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_mount_path(\"\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_MOUNT_PATH\n\n    def test_relative_path_raises(self):\n        \"\"\"Relative paths should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_mount_path(\"data/files\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_MOUNT_PATH\n\n    def test_path_not_starting_with_slash_raises(self):\n        \"\"\"Paths not starting with '/' should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_mount_path(\"mnt/data\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_MOUNT_PATH\n\n\n# ============================================================================\n# SubPath Validation Tests\n# ============================================================================\n\n\nclass TestEnsureValidSubPath:\n    \"\"\"Tests for ensure_valid_sub_path function.\"\"\"\n\n    def test_none_subpath_valid(self):\n        \"\"\"None subpath should be valid.\"\"\"\n        ensure_valid_sub_path(None)\n\n    def test_empty_subpath_valid(self):\n        \"\"\"Empty string subpath should be valid.\"\"\"\n        ensure_valid_sub_path(\"\")\n\n    def test_relative_subpath_valid(self):\n        \"\"\"Relative paths should be valid.\"\"\"\n        ensure_valid_sub_path(\"task-001\")\n        ensure_valid_sub_path(\"user/data\")\n        ensure_valid_sub_path(\"a/b/c\")\n\n    def test_absolute_subpath_raises(self):\n        \"\"\"Absolute paths should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_sub_path(\"/absolute/path\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_SUB_PATH\n\n    def test_path_traversal_raises(self):\n        \"\"\"Path traversal (..) should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_sub_path(\"../parent\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_SUB_PATH\n\n    def test_embedded_path_traversal_raises(self):\n        \"\"\"Embedded path traversal should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_sub_path(\"a/../b\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_SUB_PATH\n\n\n# ============================================================================\n# Host Path Validation Tests\n# ============================================================================\n\n\nclass TestEnsureValidHostPath:\n    \"\"\"Tests for ensure_valid_host_path function.\"\"\"\n\n    def test_valid_absolute_path(self):\n        \"\"\"Absolute paths should be valid.\"\"\"\n        ensure_valid_host_path(\"/data/opensandbox\")\n        ensure_valid_host_path(\"/tmp\")\n\n    def test_empty_path_raises(self):\n        \"\"\"Empty path should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_HOST_PATH\n\n    def test_relative_path_raises(self):\n        \"\"\"Relative paths should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"data/files\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_HOST_PATH\n\n    def test_path_with_traversal_raises(self):\n        \"\"\"Paths with traversal should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"/data/../etc/passwd\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_HOST_PATH\n\n    def test_path_with_double_slash_raises(self):\n        \"\"\"Paths with double slashes should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"/data//files\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_HOST_PATH\n\n    def test_allowed_prefix_match(self):\n        \"\"\"Paths under allowed prefixes should be valid.\"\"\"\n        allowed = [\"/data/opensandbox\", \"/tmp/sandbox\"]\n        ensure_valid_host_path(\"/data/opensandbox/user-a\", allowed)\n        ensure_valid_host_path(\"/tmp/sandbox/task-1\", allowed)\n\n    def test_allowed_prefix_exact_match(self):\n        \"\"\"Exact prefix match should be valid.\"\"\"\n        allowed = [\"/data/opensandbox\"]\n        ensure_valid_host_path(\"/data/opensandbox\", allowed)\n\n    def test_path_not_in_allowed_prefix_raises(self):\n        \"\"\"Paths not under allowed prefixes should raise HTTPException.\"\"\"\n        allowed = [\"/data/opensandbox\"]\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"/etc/passwd\", allowed)\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED\n\n    def test_partial_prefix_match_raises(self):\n        \"\"\"Partial prefix matches should not be allowed.\"\"\"\n        allowed = [\"/data/opensandbox\"]\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_host_path(\"/data/opensandbox-evil\", allowed)\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED\n\n\n# ============================================================================\n# PVC Name Validation Tests\n# ============================================================================\n\n\nclass TestEnsureValidPvcName:\n    \"\"\"Tests for ensure_valid_pvc_name function.\"\"\"\n\n    def test_valid_simple_name(self):\n        \"\"\"Simple lowercase names should be valid.\"\"\"\n        ensure_valid_pvc_name(\"my-pvc\")\n        ensure_valid_pvc_name(\"data-volume\")\n        ensure_valid_pvc_name(\"pvc1\")\n\n    def test_empty_name_raises(self):\n        \"\"\"Empty name should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_pvc_name(\"\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PVC_NAME\n\n    def test_name_too_long_raises(self):\n        \"\"\"Name exceeding 253 characters should raise HTTPException.\"\"\"\n        long_name = \"a\" * 254\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_pvc_name(long_name)\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PVC_NAME\n\n    def test_uppercase_name_raises(self):\n        \"\"\"Uppercase letters should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_pvc_name(\"MyPVC\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PVC_NAME\n\n    def test_underscore_name_raises(self):\n        \"\"\"Underscores should raise HTTPException.\"\"\"\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_valid_pvc_name(\"my_pvc\")\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_PVC_NAME\n\n\n# ============================================================================\n# Volumes List Validation Tests\n# ============================================================================\n\n\nclass TestEnsureVolumesValid:\n    \"\"\"Tests for ensure_volumes_valid function.\"\"\"\n\n    def test_none_volumes_valid(self):\n        \"\"\"None volumes should be valid.\"\"\"\n        ensure_volumes_valid(None)\n\n    def test_empty_volumes_valid(self):\n        \"\"\"Empty volumes list should be valid.\"\"\"\n        ensure_volumes_valid([])\n\n    def test_valid_host_volume(self):\n        \"\"\"Valid host volume should pass validation.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n        )\n        ensure_volumes_valid([volume])\n\n    def test_valid_pvc_volume(self):\n        \"\"\"Valid PVC volume should pass validation.\"\"\"\n        volume = Volume(\n            name=\"models\",\n            pvc=PVC(claim_name=\"shared-models-pvc\"),\n            mount_path=\"/mnt/models\",\n            read_only=True,\n        )\n        ensure_volumes_valid([volume])\n\n    def test_valid_ossfs_volume(self):\n        \"\"\"Valid OSSFS volume should pass validation.\"\"\"\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                    access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        ensure_volumes_valid([volume])\n\n    def test_valid_volume_with_subpath(self):\n        \"\"\"Valid volume with subPath should pass validation.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n            sub_path=\"task-001\",\n        )\n        ensure_volumes_valid([volume])\n\n    def test_multiple_valid_volumes(self):\n        \"\"\"Multiple valid volumes should pass validation.\"\"\"\n        volumes = [\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/opensandbox\"),\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            ),\n            Volume(\n                name=\"models\",\n                pvc=PVC(claim_name=\"shared-models-pvc\"),\n                mount_path=\"/mnt/models\",\n                read_only=True,\n            ),\n        ]\n        ensure_volumes_valid(volumes)\n\n    def test_duplicate_volume_name_raises(self):\n        \"\"\"Duplicate volume names should raise HTTPException.\"\"\"\n        volumes = [\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/a\"),\n                mount_path=\"/mnt/a\",\n                read_only=False,\n            ),\n            Volume(\n                name=\"workdir\",  # Duplicate name\n                host=Host(path=\"/data/b\"),\n                mount_path=\"/mnt/b\",\n                read_only=False,\n            ),\n        ]\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid(volumes)\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.DUPLICATE_VOLUME_NAME\n\n    def test_invalid_volume_name_rejected_by_pydantic(self):\n        \"\"\"Invalid volume name should be rejected by Pydantic pattern validation.\"\"\"\n        from pydantic import ValidationError\n\n        # Pydantic validates the pattern before our validators run\n        with pytest.raises(ValidationError) as exc_info:\n            Volume(\n                name=\"Invalid_Name\",  # Invalid: uppercase and underscore\n                host=Host(path=\"/data/opensandbox\"),\n                mount_path=\"/mnt/work\",\n                read_only=False,\n            )\n        assert \"name\" in str(exc_info.value)\n\n    def test_invalid_mount_path_rejected_by_pydantic(self):\n        \"\"\"Invalid mount path should be rejected by Pydantic pattern validation.\"\"\"\n        from pydantic import ValidationError\n\n        # Pydantic validates the pattern before our validators run\n        with pytest.raises(ValidationError) as exc_info:\n            Volume(\n                name=\"workdir\",\n                host=Host(path=\"/data/opensandbox\"),\n                mount_path=\"relative/path\",  # Invalid: not absolute\n                read_only=False,\n            )\n        assert \"mount_path\" in str(exc_info.value)\n\n    def test_invalid_subpath_raises(self):\n        \"\"\"Invalid subPath should raise HTTPException.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/data/opensandbox\"),\n            mount_path=\"/mnt/work\",\n            read_only=False,\n            sub_path=\"../escape\",  # Invalid: path traversal\n        )\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid([volume])\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_SUB_PATH\n\n    def test_host_path_allowlist_enforced(self):\n        \"\"\"Host path allowlist should be enforced.\"\"\"\n        volume = Volume(\n            name=\"workdir\",\n            host=Host(path=\"/etc/passwd\"),  # Not in allowed list\n            mount_path=\"/mnt/work\",\n            read_only=False,\n        )\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid([volume], allowed_host_prefixes=[\"/data/opensandbox\"])\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED\n\n    def test_ossfs_invalid_version_rejected_by_schema(self):\n        \"\"\"Unsupported OSSFS version should be rejected by schema validation.\"\"\"\n        from pydantic import ValidationError\n\n        with pytest.raises(ValidationError):\n            OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"3.0\",  # type: ignore[arg-type]\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            )\n\n    def test_ossfs_missing_inline_credentials_raises(self):\n        \"\"\"Missing inline credentials should raise HTTPException.\"\"\"\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n        )\n        volume.ossfs.access_key_id = None\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid([volume])\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS\n\n    def test_ossfs_v1_options_reject_prefixed_entries(self):\n        \"\"\"OSSFS options should reject prefixed entries for 1.0.\"\"\"\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"1.0\",\n                options=[\"--allow_other\"],\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n        )\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid([volume])\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_OSSFS_OPTION\n\n    def test_ossfs_v2_options_reject_prefixed_entries(self):\n        \"\"\"OSSFS options should reject prefixed entries for 2.0.\"\"\"\n        volume = Volume(\n            name=\"oss-data\",\n            ossfs=OSSFS(\n                bucket=\"bucket-test-3\",\n                endpoint=\"oss-cn-hangzhou.aliyuncs.com\",\n                version=\"2.0\",\n                options=[\"-o allow_other\"],\n                access_key_id=\"AKIDEXAMPLE\",\n                access_key_secret=\"SECRETEXAMPLE\",\n            ),\n            mount_path=\"/mnt/data\",\n        )\n        with pytest.raises(HTTPException) as exc_info:\n            ensure_volumes_valid([volume])\n        assert exc_info.value.status_code == 400\n        assert exc_info.value.detail[\"code\"] == SandboxErrorCodes.INVALID_OSSFS_OPTION\n\n    def test_invalid_pvc_name_rejected_by_pydantic(self):\n        \"\"\"Invalid PVC name should be rejected by Pydantic pattern validation.\"\"\"\n        from pydantic import ValidationError\n\n        # Pydantic validates the pattern before our validators run\n        with pytest.raises(ValidationError) as exc_info:\n            PVC(claim_name=\"Invalid_PVC\")  # Invalid: uppercase and underscore\n        assert \"claim_name\" in str(exc_info.value)\n"
  },
  {
    "path": "server/tests/testdata/config.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[server]\nhost = \"127.0.0.1\"\nport = 9000\nlog_level = \"DEBUG\"\napi_key = \"test-api-key-12345\"\n\n[runtime]\ntype = \"docker\"\nexecd_image = \"ghcr.io/opensandbox/platform:latest\"\n\n[ingress]\nmode = \"direct\"\n"
  },
  {
    "path": "server/tests/testdata/k8s_config.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Test configuration for Kubernetes runtime tests\n\n[server]\nhost = \"0.0.0.0\"\nport = 8080\nlog_level = \"DEBUG\"\napi_key = \"test-k8s-api-key\"\n\n[runtime]\ntype = \"kubernetes\"\nexecd_image = \"ghcr.io/opensandbox/execd:test\"\n\n[kubernetes]\nkubeconfig_path = \"/tmp/test-kubeconfig\"\nnamespace = \"test-namespace\"\nservice_account = \"test-sa\"\nworkload_provider = \"batchsandbox\"\n"
  },
  {
    "path": "specs/README.md",
    "content": "# OpenSandbox API Specifications\n\nEnglish | [中文](README_zh.md)\n\nThis directory contains OpenAPI specification documents for the OpenSandbox project, defining the complete API interfaces and data models. Use the server base URLs defined in each spec (for example, `http://localhost:8080/v1` for the lifecycle API, `http://localhost:44772` for execd, and `http://localhost:18080` for egress) when constructing requests.\n\n## Specification Files\n\n### 1. sandbox-lifecycle.yml\n\n**Sandbox Lifecycle Management API**\n\nDefines the complete lifecycle interfaces for creating, managing, and destroying sandbox environments directly from container images.\n\n**Core Features:**\n- **Sandbox Management**: Create, list, query, and delete sandbox instances with metadata filters and pagination\n- **State Control**: Pause and resume sandbox execution\n- **Lifecycle States**: Supports transitions across Pending → Running → Pausing → Paused → Stopping → Terminated, and error handling with `Failed`\n- **Resource & Runtime Configuration**: Specify CPU/memory/GPU resource limits, required `entrypoint`, environment variables, and opaque `extensions`\n- **Image Support**: Create sandboxes from public or private registries, including registry auth\n- **Timeout Management**: Mandatory `timeout` on creation with explicit renewal via API\n- **Endpoint Access**: Retrieve public access endpoints for services running inside sandboxes\n\n**Main Endpoints (base path `/v1`):**\n- `POST /sandboxes` - Create a sandbox from an image with timeout and resource limits\n- `GET /sandboxes` - List sandboxes with state/metadata filters and pagination\n- `GET /sandboxes/{sandboxId}` - Get full sandbox details (including image and entrypoint)\n- `DELETE /sandboxes/{sandboxId}` - Delete a sandbox\n- `POST /sandboxes/{sandboxId}/pause` - Pause a sandbox (asynchronous)\n- `POST /sandboxes/{sandboxId}/resume` - Resume a paused sandbox\n- `POST /sandboxes/{sandboxId}/renew-expiration` - Renew sandbox expiration (TTL)\n- `GET /sandboxes/{sandboxId}/endpoints/{port}` - Get an access endpoint for a service port\n\n**Authentication:**\n- HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key`\n- Environment Variable: `OPEN_SANDBOX_API_KEY` (for SDK clients)\n\n### 2. execd-api.yaml\n\n**Code Execution API Inside Sandbox**\n\nDefines interfaces for executing code, commands, and file operations within sandbox environments, providing complete code interpreter and filesystem management capabilities. All endpoints require the `X-EXECD-ACCESS-TOKEN` header.\n\n**Core Features:**\n- **Code Execution**: Stateful code execution supporting Python, JavaScript, and other languages with context lifecycle management\n- **Command Execution**: Shell command execution with foreground/background modes and polling endpoints for status/output\n- **File Operations**: Complete CRUD operations for files and directories\n- **Real-time Streaming**: Real-time output streaming via SSE (Server-Sent Events)\n- **System Monitoring**: Real-time monitoring of CPU and memory metrics\n- **Access Control**: Token-based API authentication via `X-EXECD-ACCESS-TOKEN`\n\n**Main Endpoint Categories:**\n\n**Health Check:**\n- `GET /ping` - Service health check\n\n**Code Interpreter:**\n- `GET /code/contexts` - List active code execution contexts (filterable by language)\n- `DELETE /code/contexts` - Delete all contexts for a language\n- `DELETE /code/contexts/{context_id}` - Delete a specific context\n- `POST /code/context` - Create a code execution context\n- `POST /code` - Execute code in a context (streaming output)\n- `DELETE /code` - Interrupt code execution\n\n**Command Execution:**\n- `POST /command` - Execute shell command (streaming output)\n- `DELETE /command` - Interrupt command execution\n- `GET /command/status/{session}` - Get foreground/background command status\n- `GET /command/output/{session}` - Fetch accumulated stdout/stderr for a command\n\n**Filesystem:**\n- `GET /files/info` - Get metadata for files\n- `DELETE /files` - Delete files (not directories)\n- `POST /files/permissions` - Change file permissions\n- `POST /files/mv` - Move/rename files\n- `GET /files/search` - Search files (supports glob patterns)\n- `POST /files/replace` - Batch replace file content\n- `POST /files/upload` - Upload files (multipart)\n- `GET /files/download` - Download files (supports range requests)\n\n**Directory Operations:**\n- `POST /directories` - Create directories with permissions (mkdir -p semantics)\n- `DELETE /directories` - Recursively delete directories\n\n**System Metrics:**\n- `GET /metrics` - Get system resource metrics\n- `GET /metrics/watch` - Watch system metrics in real-time (SSE stream)\n\n### 3. egress-api.yaml\n\n**Sandbox Egress Runtime API**\n\nDefines the runtime egress policy interface exposed directly by the egress sidecar\ninside a sandbox. Unlike lifecycle operations, this API is reached by first resolving\nthe sandbox endpoint for the egress port and then calling the sidecar endpoint directly.\n\n**Core Features:**\n- **Policy Inspection**: Retrieve the currently enforced egress policy and derived runtime mode\n- **Policy Mutation**: Patch egress rules at runtime using sidecar merge semantics\n- **Direct Sidecar Access**: Access via sandbox endpoint resolution instead of server-side lifecycle forwarding\n- **Optional Sidecar Auth**: Supports endpoint-specific headers when the egress sidecar requires auth\n\n**Main Endpoints:**\n- `GET /policy` - Get the current egress policy\n- `PATCH /policy` - Merge new egress rules into the current policy\n\n## Technical Features\n\n### Streaming Output (Server-Sent Events)\n\nCode execution and command execution interfaces use SSE for real-time streaming output, supporting the following event types:\n- `init` - Initialization event\n- `status` - Status update\n- `stdout` / `stderr` - Standard output/error streams\n- `result` - Execution result\n- `execution_complete` - Execution completed\n- `execution_count` - Execution count\n- `error` - Error information\n\n### Resource Limits\n\nSupports flexible resource configuration (similar to Kubernetes):\n```json\n{\n  \"cpu\": \"500m\",\n  \"memory\": \"512Mi\",\n  \"gpu\": \"1\"\n}\n```\n\n### File Permissions\n\nSupports Unix-style file permission management:\n- Owner\n- Group\n- Permission mode (octal format, e.g., 755)\n"
  },
  {
    "path": "specs/README_zh.md",
    "content": "# OpenSandbox API 规范文档\n\n中文 | [English](README.md)\n\n本目录包含 OpenSandbox 项目的 OpenAPI 规范文档，定义了完整的 API 接口和数据模型。发起请求时请使用各规范中定义的服务器地址（例如生命周期 API 的 `http://localhost:8080/v1`，execd 的 `http://localhost:44772`，egress 的 `http://localhost:18080`）。\n\n## 规范文件\n\n### 1. sandbox-lifecycle.yml\n\n**沙箱生命周期管理 API**\n\n定义了沙箱环境的创建、管理和销毁的完整生命周期接口，并可直接从容器镜像启动。\n\n**核心功能：**\n- **沙箱管理**：创建、列表、查询、删除沙箱实例，支持元数据过滤与分页\n- **状态控制**：暂停 (Pause)、恢复 (Resume) 沙箱执行\n- **生命周期**：支持 Pending → Running → Pausing → Paused → Stopping → Terminated，并包含错误态 `Failed`\n- **资源与运行时配置**：指定 CPU/内存/GPU 资源限制、必填 `entrypoint`、环境变量，以及自定义 `extensions`\n- **镜像支持**：从公共或私有镜像仓库创建沙箱，支持私有仓库认证\n- **超时管理**：创建时必填 `timeout`，并可通过 API 续期\n- **端点访问**：获取沙箱内服务的公共访问端点\n\n**主要端点（基础路径 `/v1`）：**\n- `POST /sandboxes` - 从镜像创建沙箱，设置超时与资源限制\n- `GET /sandboxes` - 列出沙箱，支持状态/元数据过滤与分页\n- `GET /sandboxes/{sandboxId}` - 获取完整沙箱详情（包含镜像与 entrypoint）\n- `DELETE /sandboxes/{sandboxId}` - 删除沙箱\n- `POST /sandboxes/{sandboxId}/pause` - 异步暂停沙箱\n- `POST /sandboxes/{sandboxId}/resume` - 恢复已暂停的沙箱\n- `POST /sandboxes/{sandboxId}/renew-expiration` - 续期沙箱 TTL\n- `GET /sandboxes/{sandboxId}/endpoints/{port}` - 获取指定端口的访问端点\n\n**认证方式：**\n- HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key`\n- 环境变量: `OPEN_SANDBOX_API_KEY`（SDK 客户端）\n\n### 2. execd-api.yaml\n\n**沙箱内代码执行 API**\n\n定义了在沙箱环境内执行代码、命令和文件操作的接口，提供完整的代码解释器和文件系统管理能力。所有端点需要 `X-EXECD-ACCESS-TOKEN` 认证头。\n\n**核心功能：**\n- **代码执行**：支持 Python、JavaScript 等多语言的有状态代码执行，并提供上下文生命周期管理\n- **命令执行**：Shell 命令执行，支持前台/后台模式，并可通过轮询端点查看状态和输出\n- **文件操作**：完整的文件和目录 CRUD 操作（创建、读取、更新、删除）\n- **实时流式输出**：基于 SSE (Server-Sent Events) 的实时输出流\n- **系统监控**：CPU 和内存指标的实时监控\n- **访问控制**：通过 `X-EXECD-ACCESS-TOKEN` 进行 Token 认证\n\n**主要端点分类：**\n\n**健康检查：**\n- `GET /ping` - 服务健康检查\n\n**代码解释器：**\n- `GET /code/contexts` - 列出活跃的代码执行上下文（可按语言过滤）\n- `DELETE /code/contexts` - 按语言批量删除上下文\n- `DELETE /code/contexts/{context_id}` - 删除指定上下文\n- `POST /code/context` - 创建代码执行上下文\n- `POST /code` - 在上下文中执行代码（流式输出）\n- `DELETE /code` - 中断代码执行\n\n**命令执行：**\n- `POST /command` - 执行 Shell 命令（流式输出）\n- `DELETE /command` - 中断命令执行\n- `GET /command/status/{session}` - 查询前台/后台命令状态\n- `GET /command/output/{session}` - 获取命令的累积 stdout/stderr\n\n**文件系统：**\n- `GET /files/info` - 获取文件元数据\n- `DELETE /files` - 删除文件（不包含目录）\n- `POST /files/permissions` - 修改文件权限\n- `POST /files/mv` - 移动/重命名文件\n- `GET /files/search` - 搜索文件（支持 glob 模式）\n- `POST /files/replace` - 批量替换文件内容\n- `POST /files/upload` - 上传文件（multipart）\n- `GET /files/download` - 下载文件（支持断点续传）\n\n**目录操作：**\n- `POST /directories` - 按权限配置创建目录（mkdir -p 语义）\n- `DELETE /directories` - 递归删除目录\n\n**系统指标：**\n- `GET /metrics` - 获取系统资源指标\n- `GET /metrics/watch` - 实时监控系统指标（SSE 流）\n\n### 3. egress-api.yaml\n\n**沙箱 Egress 运行时 API**\n\n定义了由沙箱内 egress sidecar 直接暴露的运行时策略接口。与生命周期 API 不同，\n该 API 需要先解析沙箱 egress 端口对应的 endpoint，再直接访问 sidecar。\n\n**核心功能：**\n- **策略查询**：获取当前生效的 egress 策略及其运行时模式\n- **策略变更**：使用 sidecar 的 merge 语义在运行时 patch egress 规则\n- **直连 Sidecar**：不再通过生命周期 API 做服务端转发\n- **可选鉴权**：当 egress sidecar 需要鉴权时，支持携带 endpoint 返回的请求头\n\n**主要端点：**\n- `GET /policy` - 获取当前 egress 策略\n- `PATCH /policy` - 将新的 egress 规则合并到当前策略\n\n## 技术特性\n\n### 流式输出 (Server-Sent Events)\n\n代码执行和命令执行接口使用 SSE 提供实时流式输出，支持以下事件类型：\n- `init` - 初始化事件\n- `status` - 状态更新\n- `stdout` / `stderr` - 标准输出/错误流\n- `result` - 执行结果\n- `execution_complete` - 执行完成\n- `execution_count` - 执行计数\n- `error` - 错误信息\n\n### 资源限制\n\n支持灵活的资源配置（类似 Kubernetes）：\n```json\n{\n  \"cpu\": \"500m\",\n  \"memory\": \"512Mi\",\n  \"gpu\": \"1\"\n}\n```\n\n### 文件权限\n\n支持 Unix 风格的文件权限管理：\n- 所有者 (owner)\n- 用户组 (group)\n- 权限模式 (mode) - 八进制格式，如 755\n"
  },
  {
    "path": "specs/egress-api.yaml",
    "content": "openapi: 3.1.0\ninfo:\n  title: OpenSandbox Egress API\n  version: 0.1.0\n  description: |\n    The OpenSandbox Egress API exposes the runtime policy interface served by the\n    egress sidecar inside a sandbox. Unlike the lifecycle API, these operations are\n    performed by connecting to a sandbox endpoint for the egress port and calling\n    the sidecar directly.\n\n    This API is intended for runtime inspection and mutation of outbound network\n    policy after sandbox creation. Initial egress policy configuration during sandbox\n    provisioning remains part of the Sandbox Lifecycle API create request.\n\n    ## Access Model\n\n    Clients typically access this API in two steps:\n\n    1. Use the Sandbox Lifecycle API to resolve the sandbox endpoint for the egress\n       service port.\n    2. Send requests directly to that endpoint's `/policy` route.\n\n    ## Authentication\n\n    The sidecar may optionally require the `OPENSANDBOX-EGRESS-AUTH` header. When\n    the sandbox endpoint resolver returns required headers, clients must forward\n    them on every egress API request.\nservers:\n  - url: http://localhost:18080\n    description: Local egress sidecar\ntags:\n  - name: Policy\n    description: Inspect and mutate sandbox egress policy at runtime\npaths:\n  /policy:\n    get:\n      tags: [Policy]\n      summary: Get current egress policy\n      description: |\n        Returns the currently enforced egress policy and the sidecar's derived\n        runtime mode metadata.\n      responses:\n        '200':\n          description: Current policy returned successfully.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/PolicyStatusResponse'\n              examples:\n                deny-with-allowlist:\n                  summary: Current deny-by-default policy\n                  value:\n                    status: ok\n                    mode: deny_all\n                    enforcementMode: dns\n                    policy:\n                      defaultAction: deny\n                      egress:\n                        - action: allow\n                          target: pypi.org\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n    patch:\n      tags: [Policy]\n      summary: Patch egress rules\n      description: |\n        Merge incoming egress rules with the currently enforced policy.\n\n        This endpoint uses merge semantics:\n        - Existing rules remain unless overridden by incoming rules.\n        - Incoming rules are applied with higher priority than existing rules.\n        - If multiple incoming rules refer to the same `target`, the first one wins.\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: array\n              minItems: 1\n              items:\n                $ref: '#/components/schemas/NetworkRule'\n            examples:\n              duplicate-target-first-wins:\n                summary: First rule wins for duplicate target within the same patch payload\n                value:\n                  - action: allow\n                    target: example.com\n                  - action: deny\n                    target: example.com\n      responses:\n        '200':\n          description: Patch applied successfully.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/PolicyStatusResponse'\n              examples:\n                patched:\n                  summary: Patch applied\n                  value:\n                    status: ok\n                    mode: deny_all\n                    enforcementMode: dns\n        '400':\n          $ref: '#/components/responses/BadRequest'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\ncomponents:\n  responses:\n    BadRequest:\n      description: The request was invalid or malformed.\n      content:\n        text/plain:\n          schema:\n            type: string\n    Unauthorized:\n      description: Authentication failed for the egress sidecar.\n      content:\n        text/plain:\n          schema:\n            type: string\n    InternalServerError:\n      description: The sidecar failed to apply or fetch policy state.\n      content:\n        text/plain:\n          schema:\n            type: string\n  schemas:\n    PolicyStatusResponse:\n      type: object\n      properties:\n        status:\n          type: string\n          description: Operation status reported by the sidecar.\n          example: ok\n        mode:\n          type: string\n          description: Derived runtime mode for the current policy.\n          example: deny_all\n        enforcementMode:\n          type: string\n          description: Egress sidecar enforcement backend mode.\n          example: dns\n        reason:\n          type: string\n          description: Optional human-readable reason when the sidecar returns extra context.\n        policy:\n          $ref: '#/components/schemas/NetworkPolicy'\n      additionalProperties: false\n    NetworkPolicy:\n      type: object\n      description: |\n        Egress network policy matching the sidecar `/policy` request body.\n        If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n        object or null results in allow-all behavior at startup.\n      properties:\n        defaultAction:\n          type: string\n          enum: [allow, deny]\n          description: Default action when no egress rule matches. Defaults to \"deny\".\n        egress:\n          type: array\n          description: List of egress rules evaluated in order.\n          items:\n            $ref: '#/components/schemas/NetworkRule'\n      additionalProperties: false\n    NetworkRule:\n      type: object\n      properties:\n        action:\n          type: string\n          enum: [allow, deny]\n          description: Whether to allow or deny matching targets.\n        target:\n          type: string\n          description: |\n            FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n            IP/CIDR not yet supported in the egress MVP.\n      required: [action, target]\n      additionalProperties: false\n"
  },
  {
    "path": "specs/execd-api.yaml",
    "content": "openapi: 3.1.0\ninfo:\n  title: OpenSandbox Execd API\n  version: 1.0.0\n  description: |\n    OpenSandbox Execd provides a comprehensive API for managing code execution, file operations,\n    and system monitoring within a sandboxed environment. The API supports multiple programming\n    languages, real-time streaming output via Server-Sent Events (SSE), and complete file system\n    management capabilities.\n\n    ## Key Features\n    - **Code Execution**: Execute code in Python, JavaScript, and other languages with stateful contexts\n    - **Command Execution**: Run shell commands with foreground/background modes\n    - **File Operations**: Complete CRUD operations for files and directories\n    - **Real-time Streaming**: SSE-based output streaming for code and command execution\n    - **System Monitoring**: CPU and memory metrics with real-time watching\n    - **Access Control**: Token-based authentication for all API endpoints\n\n  contact:\n    name: OpenSandbox Team\n    url: https://github.com/alibaba/OpenSandbox\n  license:\n    name: Apache 2.0\n    url: https://www.apache.org/licenses/LICENSE-2.0.html\n\nservers:\n  - url: http://localhost:44772\n    description: Local development server\n  - url: https://api.opensandbox.example.com\n    description: Production server\n\nsecurity:\n  - AccessToken: []\n\ntags:\n  - name: Health\n    description: Server health check and status monitoring\n  - name: CodeInterpreting\n    description: Code execution and context management\n  - name: Command\n    description: Shell command execution and interruption\n  - name: Filesystem\n    description: File and directory operations\n  - name: Metric\n    description: System resource monitoring and metrics\n\npaths:\n  /ping:\n    get:\n      summary: Health check endpoint\n      description: |\n        Performs a simple health check to verify that the server is running and responsive.\n        Returns HTTP 200 OK status if the server is healthy. This endpoint is typically used\n        by load balancers, monitoring systems, and orchestration platforms (like Kubernetes)\n        to check service availability.\n      operationId: ping\n      tags:\n        - Health\n      responses:\n        \"200\":\n          description: Server is alive and healthy\n\n  /code/contexts:\n    get:\n      summary: List active code execution contexts\n      description: |\n        Lists all active/available code execution contexts.\n        If `language` is provided, only contexts under that language/runtime are returned.\n      operationId: listContexts\n      tags:\n        - CodeInterpreting\n      parameters:\n        - name: language\n          in: query\n          required: true\n          description: Filter contexts by execution runtime (python, bash, java, etc.)\n          schema:\n            type: string\n          example: python\n      responses:\n        \"200\":\n          description: Array of active contexts\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/CodeContext\"\n              examples:\n                python_only:\n                  summary: Context list filtered by language\n                  value:\n                    - id: session-abc123\n                      language: python\n                    - id: session-def456\n                      language: python\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n    delete:\n      summary: Delete all contexts under a language\n      description: |\n        Deletes all existing code execution contexts under the specified `language`/runtime.\n        This is a bulk operation intended for code-interpreter context cleanup.\n      operationId: deleteContextsByLanguage\n      tags:\n        - CodeInterpreting\n      parameters:\n        - name: language\n          in: query\n          required: true\n          description: Target execution runtime whose contexts should be deleted\n          schema:\n            type: string\n          example: python\n      responses:\n        \"200\":\n          description: Contexts deleted successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /code/contexts/{context_id}:\n    get:\n      summary: Get a code execution context by id\n      description: |\n        Retrieves the details of an existing code execution context (session) by id.\n        Returns the context ID, language, and any associated metadata.\n      operationId: getContext\n      tags:\n        - CodeInterpreting\n      parameters:\n        - name: context_id\n          in: path\n          required: true\n          description: Session/context id to get\n          schema:\n            type: string\n          example: session-abc123\n      responses:\n        \"200\":\n          description: Context details retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CodeContext\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n    delete:\n      summary: Delete a code execution context by id\n      description: |\n        Deletes an existing code execution context (session) by id.\n        This should terminate the underlying context thread/process and release resources.\n      operationId: deleteContext\n      tags:\n        - CodeInterpreting\n      parameters:\n        - name: context_id\n          in: path\n          required: true\n          description: Session/context id to delete\n          schema:\n            type: string\n          example: session-abc123\n      responses:\n        \"200\":\n          description: Context deleted successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /code/context:\n    post:\n      summary: Create code execution context\n      description: |\n        Creates a new code execution environment and returns a session ID that can be used\n        for subsequent code execution requests. The context maintains state across multiple\n        code executions within the same session.\n      operationId: createCodeContext\n      tags:\n        - CodeInterpreting\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/CodeContextRequest\"\n            examples:\n              python:\n                summary: Create Python context\n                value:\n                  language: python\n              bash:\n                summary: Create Bash context\n                value:\n                  language: bash\n      responses:\n        \"200\":\n          description: Successfully created context with session ID\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CodeContext\"\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /code:\n    post:\n      summary: Execute code in context\n      description: |\n        Executes code using Jupyter kernel in a specified execution context and streams\n        the output in real-time using SSE (Server-Sent Events). Supports multiple programming\n        languages (Python, JavaScript, etc.) and maintains execution state within the session.\n        Returns execution results, output streams, execution count, and any errors.\n      operationId: runCode\n      tags:\n        - CodeInterpreting\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/RunCodeRequest\"\n            examples:\n              python:\n                summary: Execute Python code\n                value:\n                  context:\n                    id: session-123\n                    language: python\n                  code: |\n                    print(\"Hello, World!\")\n                    result = 2 + 2\n                    result\n              stateless:\n                summary: Stateless execution\n                value:\n                  code: echo \"Hello from shell\"\n      responses:\n        \"200\":\n          description: Stream of code execution events\n          content:\n            text/event-stream:\n              schema:\n                $ref: \"#/components/schemas/ServerStreamEvent\"\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n    delete:\n      summary: Interrupt code execution\n      description: |\n        Interrupts the currently running code execution in the specified context.\n        This sends a signal to terminate the execution process and releases associated resources.\n      operationId: interruptCode\n      tags:\n        - CodeInterpreting\n      parameters:\n        - name: id\n          in: query\n          required: true\n          description: Session ID of the execution context to interrupt\n          schema:\n            type: string\n          example: session-123\n      responses:\n        \"200\":\n          description: Code execution successfully interrupted\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /command:\n    post:\n      summary: Execute shell command\n      description: |\n        Executes a shell command and streams the output in real-time using SSE (Server-Sent Events).\n        The command can run in foreground or background mode. The response includes stdout, stderr,\n        execution status, and completion events.\n        Optionally specify `timeout` (milliseconds) to enforce a maximum runtime; the server will\n        terminate the process when the timeout is reached. You can also pass `uid`/`gid` to run\n        with specific user/group IDs, and `envs` to inject environment variables.\n      operationId: runCommand\n      tags:\n        - Command\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/RunCommandRequest\"\n            examples:\n              foreground:\n                summary: Foreground command\n                value:\n                  command: ls -la /workspace\n                  cwd: /workspace\n                  background: false\n                  timeout: 30000\n                  uid: 1000\n                  gid: 1000\n                  envs:\n                    PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n                    PYTHONUNBUFFERED: \"1\"\n              background:\n                summary: Background command\n                value:\n                  command: python server.py\n                  cwd: /app\n                  background: true\n                  timeout: 120000\n                  uid: 1000\n                  envs:\n                    APP_ENV: production\n                    LOG_LEVEL: info\n      responses:\n        \"200\":\n          description: Stream of command execution events\n          content:\n            text/event-stream:\n              schema:\n                $ref: \"#/components/schemas/ServerStreamEvent\"\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n    delete:\n      summary: Interrupt command execution\n      description: |\n        Interrupts the currently running command execution in the specified context.\n        This sends a signal to terminate the execution process and releases associated resources.\n      operationId: interruptCommand\n      tags:\n        - Command\n      parameters:\n        - name: id\n          in: query\n          required: true\n          description: Session ID of the execution context to interrupt\n          schema:\n            type: string\n          example: session-456\n      responses:\n        \"200\":\n          description: Command execution successfully interrupted\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /command/status/{id}:\n    get:\n      summary: Get command running status\n      description: |\n        Returns the current status of a command (foreground or background) by command ID.\n        Includes running flag, exit code, error (if any), and start/finish timestamps.\n      operationId: getCommandStatus\n      tags:\n        - Command\n      parameters:\n        - name: id\n          in: path\n          required: true\n          description: Command ID returned by RunCommand\n          schema:\n            type: string\n          example: cmd-abc123\n      responses:\n        \"200\":\n          description: Command status\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CommandStatusResponse\"\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /command/{id}/logs:\n    get:\n      summary: Get background command stdout/stderr (non-streamed)\n      description: |\n        Returns stdout and stderr for a background (detached) command by command ID.\n        Foreground commands should be consumed via SSE; this endpoint is intended for\n        polling logs of background commands. Supports incremental reads similar to a file seek:\n        pass a starting line via query to fetch output after that line and receive the latest\n        tail cursor for the next poll. When no starting line is provided, the full logs are returned.\n        Response body is plain text so it can be rendered directly in browsers; the latest line index\n        is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests.\n      operationId: getBackgroundCommandLogs\n      tags:\n        - Command\n      parameters:\n        - name: id\n          in: path\n          required: true\n          description: Command ID returned by RunCommand\n          schema:\n            type: string\n          example: cmd-abc123\n        - name: cursor\n          in: query\n          required: false\n          description: |\n            Optional 0-based line cursor (behaves like a file seek). When provided, only\n            stdout/stderr lines after this line are returned. The response includes the\n            latest line index (`cursor`) so the client can request incremental output\n            on subsequent calls. If omitted, the full log is returned.\n          schema:\n            type: integer\n            format: int64\n            minimum: 0\n          example: 120\n      responses:\n        \"200\":\n          description: Command output (plain text) and status metadata via headers\n          content:\n            text/plain:\n              schema:\n                type: string\n              example: |\n                line1\n                line2\n                warn: something on stderr\n          headers:\n            EXECD-COMMANDS-TAIL-CURSOR:\n              description: Highest available 0-based line index after applying the request cursor (use as the next cursor for incremental reads)\n              schema:\n                type: integer\n                format: int64\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/info:\n    get:\n      summary: Get file metadata\n      description: |\n        Retrieves detailed metadata for one or multiple files including permissions, owner,\n        group, size, and modification time. Returns a map of file paths to their corresponding\n        FileInfo objects.\n      operationId: getFilesInfo\n      tags:\n        - Filesystem\n      parameters:\n        - name: path\n          in: query\n          required: true\n          description: File path(s) to get info for (can be specified multiple times)\n          schema:\n            type: array\n            items:\n              type: string\n          style: form\n          explode: true\n          examples:\n            single:\n              summary: Single file\n              value: [\"/workspace/file.txt\"]\n            multiple:\n              summary: Multiple files\n              value: [\"/workspace/file1.txt\", \"/workspace/file2.py\"]\n      responses:\n        \"200\":\n          description: Map of file paths to FileInfo objects\n          content:\n            application/json:\n              schema:\n                type: object\n                additionalProperties:\n                  $ref: \"#/components/schemas/FileInfo\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files:\n    delete:\n      summary: Delete files\n      description: |\n        Deletes one or multiple files from the sandbox. Only removes files, not directories.\n        Use RemoveDirs for directory removal.\n      operationId: removeFiles\n      tags:\n        - Filesystem\n      parameters:\n        - name: path\n          in: query\n          required: true\n          description: File path(s) to delete (can be specified multiple times)\n          schema:\n            type: array\n            items:\n              type: string\n          style: form\n          explode: true\n          example: [\"/workspace/temp.txt\"]\n      responses:\n        \"200\":\n          description: Files deleted successfully\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/permissions:\n    post:\n      summary: Change file permissions\n      description: |\n        Changes permissions (mode), owner, and group for one or multiple files.\n        Accepts a map of file paths to permission settings including octal mode,\n        owner username, and group name.\n      operationId: chmodFiles\n      tags:\n        - Filesystem\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              additionalProperties:\n                $ref: \"#/components/schemas/Permission\"\n            example:\n              \"/workspace/script.sh\":\n                owner: admin\n                group: admin\n                mode: 755\n              \"/workspace/config.json\":\n                owner: admin\n                group: admin\n                mode: 755\n      responses:\n        \"200\":\n          description: Permissions changed successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/mv:\n    post:\n      summary: Rename or move files\n      description: |\n        Renames or moves one or multiple files to new paths. Can be used for both\n        renaming within the same directory and moving to different directories.\n        Target directory must exist.\n      operationId: renameFiles\n      tags:\n        - Filesystem\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: array\n              items:\n                $ref: \"#/components/schemas/RenameFileItem\"\n            example:\n              - src: /workspace/old_name.txt\n                dest: /workspace/new_name.txt\n              - src: /workspace/file.py\n                dest: /archive/file.py\n      responses:\n        \"200\":\n          description: Files renamed/moved successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/search:\n    get:\n      summary: Search for files\n      description: |\n        Searches for files matching a glob pattern within a specified directory and\n        its subdirectories. Returns file metadata including path, permissions, owner,\n        and group. Supports glob patterns like **, *.txt, etc. Default pattern is ** (all files).\n      operationId: searchFiles\n      tags:\n        - Filesystem\n      parameters:\n        - name: path\n          in: query\n          required: true\n          description: Root directory path to search in\n          schema:\n            type: string\n        - name: pattern\n          in: query\n          required: false\n          description: Glob pattern to match files (default is **)\n          schema:\n            type: string\n            default: \"**\"\n      responses:\n        \"200\":\n          description: Array of matching files with metadata\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/FileInfo\"\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/replace:\n    post:\n      summary: Replace file content\n      description: |\n        Performs text replacement in one or multiple files. Replaces all occurrences\n        of the old string with the new string (similar to strings.ReplaceAll).\n        Preserves file permissions. Useful for batch text substitution across files.\n      operationId: replaceContent\n      tags:\n        - Filesystem\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              additionalProperties:\n                $ref: \"#/components/schemas/ReplaceFileContentItem\"\n            example:\n              \"/workspace/config.yaml\":\n                old: \"localhost:8080\"\n                new: \"0.0.0.0:9090\"\n              \"/workspace/app.py\":\n                old: \"DEBUG = True\"\n                new: \"DEBUG = False\"\n      responses:\n        \"200\":\n          description: Content replaced successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/upload:\n    post:\n      summary: Upload files to sandbox\n      description: |\n        Uploads one or multiple files to specified paths within the sandbox.\n        Reads metadata and file content from multipart form parts in sequence.\n        Each file upload consists of two parts: a metadata part (JSON) followed\n        by the actual file part.\n      operationId: uploadFile\n      tags:\n        - Filesystem\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                metadata:\n                  type: string\n                  description: JSON-encoded file metadata (FileMetadata object)\n                  example: '{\"path\":\"/workspace/file.txt\",\"owner\":\"admin\",\"group\":\"admin\",\"mode\":755}'\n                file:\n                  type: string\n                  format: binary\n                  description: File to upload\n            encoding:\n              metadata:\n                contentType: application/json\n              file:\n                contentType: application/octet-stream\n      responses:\n        \"200\":\n          description: Files uploaded successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /files/download:\n    get:\n      summary: Download file from sandbox\n      description: |\n        Downloads a file from the specified path within the sandbox. Supports HTTP\n        range requests for resumable downloads and partial content retrieval.\n        Returns file as octet-stream with appropriate headers.\n      operationId: downloadFile\n      tags:\n        - Filesystem\n      parameters:\n        - name: path\n          in: query\n          required: true\n          description: Absolute or relative path of the file to download\n          schema:\n            type: string\n          example: /workspace/data.csv\n        - name: Range\n          in: header\n          required: false\n          description: HTTP Range header for partial content requests\n          schema:\n            type: string\n          example: \"bytes=0-1023\"\n      responses:\n        \"200\":\n          description: File content\n          content:\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n          headers:\n            Content-Disposition:\n              schema:\n                type: string\n              description: Attachment header with filename\n            Content-Length:\n              schema:\n                type: integer\n              description: File size in bytes\n        \"206\":\n          description: Partial file content (when Range header is provided)\n          content:\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n          headers:\n            Content-Range:\n              schema:\n                type: string\n              description: Range of bytes being returned\n            Content-Length:\n              schema:\n                type: integer\n              description: Length of the returned range\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"404\":\n          $ref: \"#/components/responses/NotFound\"\n        \"416\":\n          description: Requested range not satisfiable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /directories:\n    post:\n      summary: Create directories\n      description: |\n        Creates one or multiple directories with specified permissions. Creates parent\n        directories as needed (similar to mkdir -p). Accepts a map of directory paths\n        to permission objects.\n      operationId: makeDirs\n      tags:\n        - Filesystem\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              additionalProperties:\n                $ref: \"#/components/schemas/Permission\"\n            example:\n              \"/workspace/project\":\n                owner: admin\n                group: admin\n                mode: 755\n              \"/workspace/logs\":\n                owner: admin\n                group: admin\n                mode: 755\n      responses:\n        \"200\":\n          description: Directories created successfully\n        \"400\":\n          $ref: \"#/components/responses/BadRequest\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n    delete:\n      summary: Delete directories\n      description: |\n        Recursively deletes one or multiple directories and all their contents.\n        Similar to rm -rf. Use with caution as this operation cannot be undone.\n      operationId: removeDirs\n      tags:\n        - Filesystem\n      parameters:\n        - name: path\n          in: query\n          required: true\n          description: Directory path(s) to delete (can be specified multiple times)\n          schema:\n            type: array\n            items:\n              type: string\n          style: form\n          explode: true\n          example: [\"/workspace/temp\"]\n      responses:\n        \"200\":\n          description: Directories deleted successfully\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /metrics:\n    get:\n      summary: Get system metrics\n      description: |\n        Retrieves current system resource metrics including CPU usage percentage,\n        CPU core count, total memory, used memory, and timestamp. Provides a snapshot\n        of system resource utilization at the time of request.\n      operationId: getMetrics\n      tags:\n        - Metric\n      responses:\n        \"200\":\n          description: Current system metrics including CPU and memory usage\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Metrics\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\n  /metrics/watch:\n    get:\n      summary: Watch system metrics in real-time\n      description: |\n        Streams system resource metrics in real-time using Server-Sent Events (SSE).\n        Updates are sent every second, providing continuous monitoring of CPU usage,\n        memory usage, and other system metrics. The connection remains open until\n        the client disconnects.\n      operationId: watchMetrics\n      tags:\n        - Metric\n      responses:\n        \"200\":\n          description: Stream of system metrics updated every second\n          content:\n            text/event-stream:\n              schema:\n                $ref: \"#/components/schemas/Metrics\"\n        \"500\":\n          $ref: \"#/components/responses/InternalServerError\"\n\ncomponents:\n  securitySchemes:\n    AccessToken:\n      type: apiKey\n      in: header\n      name: X-EXECD-ACCESS-TOKEN\n      description: |\n        Access token for API authentication. All requests must include this header\n        with a valid token. The token is configured during server initialization.\n\n  schemas:\n    CodeContextRequest:\n      type: object\n      description: Request to create a code execution context\n      properties:\n        language:\n          type: string\n          description: Execution runtime (python, bash, java, etc.)\n          example: python\n\n    CodeContext:\n      type: object\n      description: Code execution context with session identifier\n      properties:\n        id:\n          type: string\n          description: Unique session identifier returned by CreateContext\n          example: session-abc123\n        language:\n          type: string\n          description: Execution runtime\n          example: python\n      required:\n        - language\n\n    RunCodeRequest:\n      type: object\n      required:\n        - code\n      description: Request to execute code in a context\n      properties:\n        context:\n          $ref: \"#/components/schemas/CodeContext\"\n        code:\n          type: string\n          description: Source code to execute\n          example: |\n            import numpy as np\n            result = np.array([1, 2, 3])\n            print(result)\n\n    RunCommandRequest:\n      type: object\n      required:\n        - command\n      description: Request to execute a shell command\n      properties:\n        command:\n          type: string\n          description: Shell command to execute\n          example: ls -la /workspace\n        cwd:\n          type: string\n          description: Working directory for command execution\n          example: /workspace\n        background:\n          type: boolean\n          description: Whether to run command in detached mode\n          default: false\n          example: false\n        timeout:\n          type: integer\n          format: int64\n          description: Maximum allowed execution time in milliseconds before the command is forcefully terminated by the server. If omitted, the server will not enforce any timeout.\n          example: 60000\n        uid:\n          type: integer\n          format: int32\n          minimum: 0\n          description: |\n            Unix user ID used to run the command. If `gid` is provided, `uid` is required.\n          example: 1000\n        gid:\n          type: integer\n          format: int32\n          minimum: 0\n          description: |\n            Unix group ID used to run the command. Requires `uid` to be provided.\n          example: 1000\n        envs:\n          type: object\n          description: Environment variables injected into the command process.\n          additionalProperties:\n            type: string\n          example:\n            PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n            PYTHONUNBUFFERED: \"1\"\n\n    CommandStatusResponse:\n      type: object\n      description: Command execution status (foreground or background)\n      properties:\n        id:\n          type: string\n          description: Command ID returned by RunCommand\n          example: cmd-abc123\n        content:\n          type: string\n          description: Original command content\n          example: ls -la\n        running:\n          type: boolean\n          description: Whether the command is still running\n          example: false\n        exit_code:\n          type: integer\n          format: int32\n          nullable: true\n          description: Exit code if the command has finished\n          example: 0\n        error:\n          type: string\n          description: Error message if the command failed\n          example: permission denied\n        started_at:\n          type: string\n          format: date-time\n          description: Start time in RFC3339 format\n          example: \"2025-12-22T09:08:05Z\"\n        finished_at:\n          type: string\n          format: date-time\n          nullable: true\n          description: Finish time in RFC3339 format (null if still running)\n          example: \"2025-12-22T09:08:09Z\"\n\n    ServerStreamEvent:\n      type: object\n      description: Server-sent event for streaming execution output\n      properties:\n        type:\n          type: string\n          enum:\n            - init\n            - status\n            - error\n            - stdout\n            - stderr\n            - result\n            - execution_complete\n            - execution_count\n            - ping\n          description: Event type for client-side handling\n          example: stdout\n        text:\n          type: string\n          description: Textual data for status, init, and stream events\n          example: \"Hello, World!\\n\"\n        execution_count:\n          type: integer\n          description: Cell execution number in the session\n          example: 1\n        execution_time:\n          type: integer\n          format: int64\n          description: Execution duration in milliseconds\n          example: 150\n        timestamp:\n          type: integer\n          format: int64\n          description: When the event was generated (Unix milliseconds)\n          example: 1700000000000\n        results:\n          type: object\n          additionalProperties: true\n          description: Execution output in various MIME types (e.g., \"text/plain\", \"text/html\")\n          example:\n            text/plain: \"4\"\n        error:\n          type: object\n          description: Execution error details if an error occurred\n          properties:\n            ename:\n              type: string\n              description: Error name/type\n              example: \"NameError\"\n            evalue:\n              type: string\n              description: Error value/message\n              example: \"name 'undefined_var' is not defined\"\n            traceback:\n              type: array\n              items:\n                type: string\n              description: Stack trace lines\n              example:\n                - \"Traceback (most recent call last):\"\n                - '  File \"<stdin>\", line 1, in <module>'\n                - \"NameError: name 'undefined_var' is not defined\"\n\n    FileInfo:\n      type: object\n      description: File metadata including path and permissions\n      properties:\n        path:\n          type: string\n          description: Absolute file path\n          example: /workspace/file.txt\n        size:\n          type: integer\n          format: int64\n          description: File size in bytes\n          example: 2048\n        modified_at:\n          type: string\n          format: date-time\n          description: Last modification time\n          example: 2025-11-16T14:30:45Z\n        created_at:\n          type: string\n          format: date-time\n          description: File creation time\n          example: 2025-11-16T14:30:45Z\n        owner:\n          type: string\n          description: File owner username\n          example: admin\n        group:\n          type: string\n          description: File group name\n          example: admin\n        mode:\n          type: integer\n          description: File permissions in octal format\n          example: 755\n      required: [path, size, modified_at, created_at, owner, group, mode]\n\n    Permission:\n      type: object\n      description: File ownership and mode settings\n      properties:\n        owner:\n          type: string\n          description: Owner username\n          example: root\n        group:\n          type: string\n          description: Group name\n          example: root\n        mode:\n          type: integer\n          description: Permission mode in octal format (e.g., 644, 755)\n          default: 755\n          example: 755\n      required: [mode]\n\n    FileMetadata:\n      type: object\n      description: File metadata for upload operations\n      properties:\n        path:\n          type: string\n          description: Target file path\n          example: /workspace/upload.txt\n        owner:\n          type: string\n          description: File owner\n          example: admin\n        group:\n          type: string\n          description: File group\n          example: admin\n        mode:\n          type: integer\n          description: File permissions in octal\n          example: 755\n\n    RenameFileItem:\n      type: object\n      description: File rename/move operation\n      properties:\n        src:\n          type: string\n          description: Source file path\n          example: /workspace/old.txt\n        dest:\n          type: string\n          description: Destination file path\n          example: /workspace/new.txt\n      required: [src, dest]\n\n    ReplaceFileContentItem:\n      type: object\n      description: Content replacement operation\n      properties:\n        old:\n          type: string\n          description: String to be replaced\n          example: \"localhost\"\n        new:\n          type: string\n          description: Replacement string\n          example: \"0.0.0.0\"\n      required: [old, new]\n\n    Metrics:\n      type: object\n      description: System resource usage metrics\n      properties:\n        cpu_count:\n          type: number\n          format: float\n          description: Number of CPU cores\n          example: 4.0\n        cpu_used_pct:\n          type: number\n          format: float\n          description: CPU usage percentage\n          example: 45.5\n        mem_total_mib:\n          type: number\n          format: float\n          description: Total memory in MiB\n          example: 8192.0\n        mem_used_mib:\n          type: number\n          format: float\n          description: Used memory in MiB\n          example: 4096.0\n        timestamp:\n          type: integer\n          format: int64\n          description: Timestamp when metrics were collected (Unix milliseconds)\n          example: 1700000000000\n      required:\n        [cpu_count, cpu_used_pct, mem_total_mib, mem_used_mib, timestamp]\n\n    ErrorResponse:\n      type: object\n      description: Standard error response format\n      properties:\n        code:\n          type: string\n          description: Error code for programmatic handling\n          example: INVALID_REQUEST_BODY\n        message:\n          type: string\n          description: Human-readable error message\n          example: \"error parsing request, MAYBE invalid body format\"\n      required: [code, message]\n\n  responses:\n    BadRequest:\n      description: Invalid request body format or missing required fields\n      content:\n        application/json:\n          schema:\n            $ref: \"#/components/schemas/ErrorResponse\"\n          example:\n            code: INVALID_REQUEST_BODY\n            message: \"error parsing request, MAYBE invalid body format\"\n\n    NotFound:\n      description: File or resource not found\n      content:\n        application/json:\n          schema:\n            $ref: \"#/components/schemas/ErrorResponse\"\n          example:\n            code: FILE_NOT_FOUND\n            message: \"file not found\"\n\n    InternalServerError:\n      description: Runtime server error during operation\n      content:\n        application/json:\n          schema:\n            $ref: \"#/components/schemas/ErrorResponse\"\n          example:\n            code: RUNTIME_ERROR\n            message: \"error running code execution\"\n"
  },
  {
    "path": "specs/sandbox-lifecycle.yml",
    "content": "openapi: 3.1.0\ninfo:\n  title: OpenSandbox Lifecycle API\n  version: 0.1.0\n  description: |\n    The Sandbox Lifecycle API coordinates how untrusted workloads are created,\n    executed, paused, resumed, and finally disposed. This specification focuses on\n    the primary lifecycle flows for the `sandbox` domain concept. Sandboxes are\n    provisioned directly from container images without requiring pre-created templates.\n\n    ## Sandbox Lifecycle\n\n    A sandbox follows this lifecycle:\n\n    1. **Creation** → Sandbox enters `Pending` state (auto-starts)\n    2. **Execution** → Transitions to `Running` state\n    3. **Pause** (optional) → `Pausing` → `Paused` (asynchronous process)\n    4. **Resume** (optional) → Returns to `Running` from `Paused`\n    5. **Termination** → `Stopping` → `Terminated` (can be triggered by kill action, TTL expiry, or error)\n    6. **Error** → Any state can transition to `Failed` on critical errors\n\n    The `status` field provides fine-grained details through `state`, `reason`, and `message`.\n\n    ## Authentication\n\n    API Key authentication is required for all operations:\n\n    1. **HTTP Header**\n       ```\n       OPEN-SANDBOX-API-KEY: your-api-key\n       ```\n\n    2. **Environment Variable** (for SDK clients)\n       ```\n       OPEN_SANDBOX_API_KEY=your-api-key\n       ```\n\n       SDK clients will automatically pick up this environment variable.\nservers:\n  - url: http://localhost:8080/v1\n    description: Local development\nsecurity:\n  - apiKeyAuth: []\ntags:\n  - name: Sandboxes\n    description: Provision and transition sandboxes through their lifecycle\npaths:\n  /sandboxes:\n    get:\n      tags: [ Sandboxes ]\n      summary: List sandboxes\n      description: |\n        List all sandboxes with optional filtering and pagination using query parameters.\n        All filter conditions use AND logic. Multiple `state` parameters use OR logic within states.\n      parameters:\n        - name: state\n          in: query\n          description: |\n            Filter by lifecycle state. Pass multiple times for OR logic.\n            Example: `?state=Running&state=Paused`\n          schema:\n            type: array\n            items:\n              type: string\n          style: form\n          explode: true\n        - name: metadata\n          in: query\n          description: |\n            Arbitrary metadata key-value pairs for filtering，keys and values must be url encoded\n            Example: To filter by `project=Apollo` and `note=Demo Test`: `?metadata=project%3DApollo%26note%3DDemo%252520Test`\n          schema:\n            type: string\n          style: form\n        - name: page\n          in: query\n          description: Page number for pagination\n          schema:\n            type: integer\n            minimum: 1\n            default: 1\n        - name: pageSize\n          in: query\n          description: Number of items per page\n          schema:\n            type: integer\n            minimum: 1\n            default: 20\n      responses:\n        '200':\n          description: Paginated collection of sandboxes\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ListSandboxesResponse'\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '400':\n          $ref: '#/components/responses/BadRequest'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n    post:\n      tags: [Sandboxes]\n      summary: Create a sandbox from a container image\n      description: |\n        Creates a new sandbox from a container image with optional resource limits,\n        environment variables, and metadata. Sandboxes are provisioned directly from\n        the specified image without requiring a pre-created template.\n\n        ## Authentication\n\n        API Key authentication is required via:\n        - `OPEN-SANDBOX-API-KEY: <api-key>` header\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/CreateSandboxRequest'\n            examples:\n              deny-with-allowlist:\n                summary: Deny by default with allowed domains\n                value:\n                  image:\n                    uri: python:3.11\n                  timeout: 3600\n                  resourceLimits:\n                    cpu: \"500m\"\n                    memory: \"512Mi\"\n                  entrypoint: [\"python\", \"/app/main.py\"]\n                  networkPolicy:\n                    defaultAction: deny\n                    egress:\n                      - action: allow\n                        target: \"pypi.org\"\n              allow-with-denylist:\n                summary: Allow by default with a deny rule\n                value:\n                  image:\n                    uri: python:3.11\n                  timeout: 3600\n                  resourceLimits:\n                    cpu: \"500m\"\n                    memory: \"512Mi\"\n                  entrypoint: [\"python\", \"/app/main.py\"]\n                  networkPolicy:\n                    defaultAction: allow\n                    egress:\n                      - action: deny\n                        target: \"bad.example.com\"\n              manual-cleanup:\n                summary: Manual cleanup without automatic expiration\n                value:\n                  image:\n                    uri: python:3.11\n                  resourceLimits:\n                    cpu: \"500m\"\n                    memory: \"512Mi\"\n                  entrypoint: [\"python\", \"/app/main.py\"]\n      responses:\n        '202':\n          description: |\n            Sandbox created and accepted for provisioning.\n\n            The returned sandbox includes:\n            - `id`: Unique sandbox identifier\n            - `status.state: \"Pending\"` (auto-starting provisioning)\n            - `status.reason` and `status.message` indicating initialization stage\n            - `metadata`, `expiresAt`, `createdAt`: Core sandbox information\n\n            Note: `image` and `updatedAt` are not included in the create response.\n            Use GET /sandboxes/{sandboxId} to retrieve the complete sandbox information including image spec.\n\n            To track provisioning progress, poll GET /sandboxes/{sandboxId}.\n            The sandbox will automatically transition to `Running` state once provisioning completes.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/CreateSandboxResponse'\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n            Location:\n              $ref: '#/components/headers/Location'\n        '400':\n          $ref: '#/components/responses/BadRequest'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '409':\n          $ref: '#/components/responses/Conflict'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n  /sandboxes/{sandboxId}:\n    parameters:\n      - $ref: '#/components/parameters/SandboxId'\n    get:\n      tags: [Sandboxes]\n      summary: Fetch a sandbox by id\n      description: |\n        Returns the complete sandbox information including:\n        - `id`, `status`, `metadata`, `expiresAt`, `createdAt`: Core information\n        - `image`: Container image specification (not included in create response)\n        - `entrypoint`: Entry process specification\n\n        This is the complete representation of the sandbox resource.\n      responses:\n        '200':\n          description: Sandbox current state and metadata\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Sandbox'\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n    delete:\n      tags: [Sandboxes]\n      summary: Delete a sandbox\n      description: Delete a sandbox, terminating its execution. The sandbox will transition through Stopping state to Terminated.\n      responses:\n        '204':\n          description: |\n            Sandbox successfully deleted.\n\n            Sandbox has been scheduled for termination and will transition to Stopping state, then Terminated.\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '409':\n          $ref: '#/components/responses/Conflict'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n  /sandboxes/{sandboxId}/pause:\n    post:\n      tags: [Sandboxes]\n      summary: Pause execution while retaining state\n      description: Pause a running sandbox while preserving its state. Poll GET /sandboxes/{sandboxId} to track state transition to Paused.\n      parameters:\n        - $ref: '#/components/parameters/SandboxId'\n      responses:\n        '202':\n          description: |\n            Pause operation accepted.\n\n            Sandbox will transition to Pausing state.\n            Poll GET /sandboxes/{sandboxId} to track progress.\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '409':\n          $ref: '#/components/responses/Conflict'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n  /sandboxes/{sandboxId}/resume:\n    post:\n      tags: [Sandboxes]\n      summary: Resume a paused sandbox\n      description: Resume execution of a paused sandbox. Poll GET /sandboxes/{sandboxId} to track state transition to Running.\n      parameters:\n        - $ref: '#/components/parameters/SandboxId'\n      responses:\n        '202':\n          description: |\n            Resume operation accepted.\n\n            Sandbox will transition from Paused → Running.\n            Poll GET /sandboxes/{sandboxId} to track progress.\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '409':\n          $ref: '#/components/responses/Conflict'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n  /sandboxes/{sandboxId}/renew-expiration:\n    post:\n      tags: [Sandboxes]\n      summary: Renew sandbox expiration\n      description: Renew the absolute expiration time of a sandbox.\n      parameters:\n        - $ref: '#/components/parameters/SandboxId'\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/RenewSandboxExpirationRequest'\n      responses:\n        '200':\n          description: |\n            Sandbox expiration updated successfully.\n\n            Returns only the updated expiresAt field.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/RenewSandboxExpirationResponse'\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '400':\n          $ref: '#/components/responses/BadRequest'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '409':\n          $ref: '#/components/responses/Conflict'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\n  /sandboxes/{sandboxId}/endpoints/{port}:\n    get:\n      tags: [Sandboxes]\n      summary: Get sandbox access endpoint\n      description: |\n        Get the public access endpoint URL for accessing a service running on a specific port\n        within the sandbox. The service must be listening on the specified port inside\n        the sandbox for the endpoint to be available.\n      parameters:\n        - $ref: '#/components/parameters/SandboxId'\n        - name: port\n          in: path\n          required: true\n          description: Port number where the service is listening inside the sandbox\n          schema:\n            type: integer\n            minimum: 1\n            maximum: 65535\n        - name: use_server_proxy\n          in: query\n          description: Whether to return a server-proxied URL\n          schema:\n            type: boolean\n            default: false\n      responses:\n        '200':\n          description: |\n            Endpoint retrieved successfully.\n\n            Returns the public URL for accessing the service on the specified port.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/Endpoint'\n          headers:\n            X-Request-ID:\n              $ref: '#/components/headers/XRequestId'\n        '401':\n          $ref: '#/components/responses/Unauthorized'\n        '403':\n          $ref: '#/components/responses/Forbidden'\n        '404':\n          $ref: '#/components/responses/NotFound'\n        '500':\n          $ref: '#/components/responses/InternalServerError'\ncomponents:\n  securitySchemes:\n    apiKeyAuth:\n      type: apiKey\n      in: header\n      name: OPEN-SANDBOX-API-KEY\n      description: |\n        API Key for authentication. Can be provided via:\n        1. HTTP Header: OPEN-SANDBOX-API-KEY: your-api-key\n        2. Environment variable: OPEN_SANDBOX_API_KEY (for SDK clients)\n  parameters:\n    SandboxId:\n      name: sandboxId\n      in: path\n      required: true\n      description: Unique sandbox identifier\n      schema:\n        type: string\n  headers:\n    XRequestId:\n      description: Unique request identifier for tracing\n      schema:\n        type: string\n        format: uuid\n    Location:\n      description: URI of the newly created or related resource\n      schema:\n        type: string\n        format: uri\n    RetryAfter:\n      description: Suggested delay in seconds before retrying\n      schema:\n        type: integer\n        minimum: 1\n  responses:\n    Error:\n      description: Error response envelope\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n    BadRequest:\n      description: The request was invalid or malformed\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n    Unauthorized:\n      description: Authentication credentials are missing or invalid\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n    Forbidden:\n      description: The authenticated user lacks permission for this operation\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n    NotFound:\n      description: The requested resource does not exist\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n    Conflict:\n      description: The operation conflicts with the current state\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n    InternalServerError:\n      description: An unexpected server error occurred\n      content:\n        application/json:\n          schema:\n            $ref: '#/components/schemas/ErrorResponse'\n      headers:\n        X-Request-ID:\n          $ref: '#/components/headers/XRequestId'\n  schemas:\n    ListSandboxesResponse:\n      type: object\n      properties:\n        items:\n          type: array\n          items:\n            $ref: '#/components/schemas/Sandbox'\n        pagination:\n          $ref: '#/components/schemas/PaginationInfo'\n      required: [items, pagination]\n    PaginationInfo:\n      type: object\n      description: Pagination metadata for list responses\n      properties:\n        page:\n          type: integer\n          minimum: 1\n          description: Current page number\n        pageSize:\n          type: integer\n          minimum: 1\n          description: Number of items per page\n        totalItems:\n          type: integer\n          minimum: 0\n          description: Total number of items matching the filter\n        totalPages:\n          type: integer\n          minimum: 0\n          description: Total number of pages\n        hasNextPage:\n          type: boolean\n          description: Whether there are more pages after the current one\n      required: [page, pageSize, totalItems, totalPages, hasNextPage]\n    CreateSandboxResponse:\n      type: object\n      description: Response from creating a new sandbox. Contains essential information without image and updatedAt.\n      properties:\n        id:\n          type: string\n          description: Unique sandbox identifier\n\n        status:\n          $ref: '#/components/schemas/SandboxStatus'\n          description: Current lifecycle status and detailed state information\n\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Custom metadata from creation request\n\n        expiresAt:\n          oneOf:\n            - type: string\n              format: date-time\n            - type: 'null'\n          description: Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.\n\n        createdAt:\n          type: string\n          format: date-time\n          description: Sandbox creation timestamp\n\n        entrypoint:\n          type: array\n          items:\n            type: string\n          description: Entry process specification from creation request\n\n      required:\n        - id\n        - status\n        - createdAt\n        - entrypoint\n\n    Sandbox:\n      type: object\n      description: Runtime execution environment provisioned from a container image\n      properties:\n        id:\n          type: string\n          description: Unique sandbox identifier\n\n        image:\n          $ref: '#/components/schemas/ImageSpec'\n          description: |\n            Container image specification used to provision this sandbox.\n            Only present in responses for GET/LIST operations. Not returned in createSandbox response.\n\n        status:\n          $ref: '#/components/schemas/SandboxStatus'\n          description: Current lifecycle status and detailed state information\n\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Custom metadata from creation request\n\n        entrypoint:\n          type: array\n          items:\n            type: string\n          description: |\n            The command to execute as the sandbox's entry process.\n            Always present in responses since entrypoint is required in creation requests.\n\n        expiresAt:\n          oneOf:\n            - type: string\n              format: date-time\n            - type: 'null'\n          description: Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled.\n\n        createdAt:\n          type: string\n          format: date-time\n          description: Sandbox creation timestamp\n\n      required:\n        - id\n        - status\n        - createdAt\n        - entrypoint\n        - image\n    SandboxState:\n      type: string\n      description: |\n        High-level lifecycle state of the sandbox.\n\n        Common state values:\n        - Pending: Sandbox is being provisioned\n        - Running: Sandbox is running and ready to accept requests\n        - Pausing: Sandbox is in the process of pausing\n        - Paused: Sandbox has been paused while retaining its state\n        - Stopping: Sandbox is being terminated\n        - Terminated: Sandbox has been successfully terminated\n        - Failed: Sandbox encountered a critical error\n\n        State transitions:\n        - Pending → Running (after creation completes)\n        - Running → Pausing (when pause is requested)\n        - Pausing → Paused (pause operation completes)\n        - Paused → Running (when resume is requested)\n        - Running/Paused → Stopping (when kill is requested or TTL expires)\n        - Stopping → Terminated (kill/timeout operation completes)\n        - Pending/Running/Paused → Failed (on error)\n\n        Note: New state values may be added in future versions.\n        Clients should handle unknown state values gracefully.\n    SandboxStatus:\n      type: object\n      description: Detailed status information with lifecycle state and transition details\n      properties:\n        state:\n          $ref: '#/components/schemas/SandboxState'\n          description: Current lifecycle state of the sandbox\n\n        reason:\n          type: string\n          description: |\n            Short machine-readable reason code for the current state.\n            Examples: \"user_delete\", \"ttl_expiry\", \"provision_timeout\", \"runtime_error\"\n\n        message:\n          type: string\n          description: Human-readable message describing the current state or reason for state transition\n\n        lastTransitionAt:\n          type: string\n          format: date-time\n          description: Timestamp of the last state transition\n\n      required: [state]\n    ImageSpec:\n      type: object\n      required: [uri]\n      description: |\n        Container image specification for sandbox provisioning.\n\n        Supports public registry images and private registry images with authentication.\n      properties:\n        uri:\n          type: string\n          description: |\n            Container image URI in standard format.\n\n            Examples:\n              - \"python:3.11\" (Docker Hub)\n              - \"ubuntu:22.04\"\n              - \"gcr.io/my-project/model-server:v1.0\"\n              - \"private-registry.company.com:5000/app:latest\"\n\n        auth:\n          type: object\n          description: Registry authentication credentials (required for private registries)\n          properties:\n            username:\n              type: string\n              description: Registry username or service account\n            password:\n              type: string\n              description: Registry password or authentication token\n          additionalProperties: false\n\n      additionalProperties: false\n    CreateSandboxRequest:\n      type: object\n      required: [image, resourceLimits, entrypoint]\n      description: |\n        Request to create a new sandbox from a container image.\n\n        **Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.\n      properties:\n        image:\n          $ref: '#/components/schemas/ImageSpec'\n          description: Container image specification for the sandbox\n\n        timeout:\n          oneOf:\n            - type: integer\n              minimum: 60\n            - type: 'null'\n          description: |\n            Sandbox timeout in seconds. The sandbox will automatically terminate after this duration.\n            The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`).\n            Omit or set null to disable automatic expiration and require explicit cleanup.\n            Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject\n            null timeout when the underlying workload provider does not support non-expiring sandboxes.\n\n        resourceLimits:\n          $ref: '#/components/schemas/ResourceLimits'\n          description: |\n            Runtime resource constraints for the sandbox instance.\n            SDK clients should provide sensible defaults (e.g., cpu: \"500m\", memory: \"512Mi\").\n\n        env:\n          type: object\n          additionalProperties:\n            type: string\n          description: Environment variables to inject into the sandbox runtime.\n          example:\n            API_KEY: \"secret-key\"\n            DEBUG: \"true\"\n            LOG_LEVEL: \"info\"\n\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: |\n            Custom key-value metadata for management, filtering, and tagging.\n            Use \"name\" key for a human-readable identifier.\n          example:\n            name: \"Data Processing Sandbox\"\n            project: \"data-processing\"\n            team: \"ml\"\n            environment: \"staging\"\n\n        entrypoint:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: |\n            The command to execute as the sandbox's entry process (required).\n\n            Explicitly specifies the user's expected main process, allowing the sandbox management\n            service to reliably inject control processes before executing this command.\n\n            Format: [executable, arg1, arg2, ...]\n\n            Examples:\n            - [\"python\", \"/app/main.py\"]\n            - [\"/bin/bash\"]\n            - [\"java\", \"-jar\", \"/app/app.jar\"]\n            - [\"node\", \"server.js\"]\n          example:\n            - \"python\"\n            - \"/app/main.py\"\n\n        networkPolicy:\n          $ref: '#/components/schemas/NetworkPolicy'\n          description: |\n            Optional outbound network policy for the sandbox.\n            Shape matches the sidecar `/policy` endpoint. If omitted or empty,\n            the sidecar starts in allow-all mode until updated.\n\n        volumes:\n          type: array\n          description: |\n            Storage mounts for the sandbox. Each volume entry specifies a named backend-specific\n            storage source and common mount settings. Exactly one backend type must be specified\n            per volume entry.\n          items:\n            $ref: '#/components/schemas/Volume'\n\n        extensions:\n          type: object\n          additionalProperties:\n            type: string\n          description: |\n            Opaque container for provider-specific or transient parameters not supported by the core API.\n\n            **Note**: This field is reserved for internal features, experimental flags, or temporary behaviors. Standard parameters should be proposed as core API fields.\n\n            **Best Practices**:\n            - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.\n            - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.\n    ResourceLimits:\n      type: object\n      description: |\n        Runtime resource constraints as key-value pairs. Similar to Kubernetes resource specifications,\n        allows flexible definition of resource limits. Common resource types include:\n        - `cpu`: CPU allocation in millicores (e.g., \"250m\" for 0.25 CPU cores)\n        - `memory`: Memory allocation in bytes or human-readable format (e.g., \"512Mi\", \"1Gi\")\n        - `gpu`: Number of GPU devices (e.g., \"1\")\n\n        New resource types can be added without API changes.\n      additionalProperties:\n        type: string\n      example:\n        cpu: \"500m\"\n        memory: \"512Mi\"\n        gpu: \"1\"\n    RenewSandboxExpirationRequest:\n      type: object\n      required: [expiresAt]\n      properties:\n        expiresAt:\n          type: string\n          format: date-time\n          description: |\n            New absolute expiration time in UTC (RFC 3339 format).\n            Must be in the future and after the current expiresAt time.\n\n            Example: \"2025-11-16T14:30:45Z\"\n      additionalProperties: false\n    RenewSandboxExpirationResponse:\n      type: object\n      required: [expiresAt]\n      properties:\n        expiresAt:\n          type: string\n          format: date-time\n          description: |\n            The new absolute expiration time in UTC (RFC 3339 format).\n\n            Example: \"2025-11-16T14:30:45Z\"\n      additionalProperties: false\n    ErrorResponse:\n      type: object\n      description: |\n        Standard error response for all non-2xx HTTP responses.\n        HTTP status code indicates the error category; code and message provide details.\n      properties:\n        code:\n          type: string\n          description: |\n            Machine-readable error code (e.g., INVALID_REQUEST, NOT_FOUND, INTERNAL_ERROR).\n            Use this for programmatic error handling.\n        message:\n          type: string\n          description: Human-readable error message describing what went wrong and how to fix it.\n      required: [code, message]\n      additionalProperties: false\n    Endpoint:\n      type: object\n      description: |\n        Endpoint for accessing a service running in the sandbox.\n        The service must be listening on the specified port inside the sandbox for the endpoint to be available.\n      properties:\n        endpoint:\n          type: string\n          description: |\n            Public URL to access the service from outside the sandbox.\n            Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port}\n            Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080\n        headers:\n          type: object\n          additionalProperties:\n            type: string\n          description: |\n            Requests targeting the sandbox must include the corresponding header(s).\n      required:\n        - endpoint\n      additionalProperties: false\n\n    NetworkPolicy:\n      type: object\n      description: |\n        Egress network policy matching the sidecar `/policy` request body.\n        If `defaultAction` is omitted, the sidecar defaults to \"deny\"; passing an empty\n        object or null results in allow-all behavior at startup.\n      properties:\n        defaultAction:\n          type: string\n          enum: [allow, deny]\n          description: Default action when no egress rule matches. Defaults to \"deny\".\n        egress:\n          type: array\n          description: List of egress rules evaluated in order.\n          items:\n            $ref: '#/components/schemas/NetworkRule'\n      additionalProperties: false\n\n    NetworkRule:\n      type: object\n      properties:\n        action:\n          type: string\n          enum: [allow, deny]\n          description: Whether to allow or deny matching targets.\n        target:\n          type: string\n          description: |\n            FQDN or wildcard domain (e.g., \"example.com\", \"*.example.com\").\n            IP/CIDR not yet supported in the egress MVP.\n      required: [action, target]\n      additionalProperties: false\n\n    Volume:\n      type: object\n      description: |\n        Storage mount definition for a sandbox. Each volume entry contains:\n        - A unique name identifier\n        - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields\n        - Common mount settings (mountPath, readOnly, subPath)\n      required: [name, mountPath]\n      properties:\n        name:\n          type: string\n          description: |\n            Unique identifier for the volume within the sandbox.\n            Must be a valid DNS label (lowercase alphanumeric, hyphens allowed, max 63 chars).\n          pattern: \"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\"\n          maxLength: 63\n        host:\n          $ref: '#/components/schemas/Host'\n        pvc:\n          $ref: '#/components/schemas/PVC'\n        ossfs:\n          $ref: '#/components/schemas/OSSFS'\n        mountPath:\n          type: string\n          description: |\n            Absolute path inside the container where the volume is mounted.\n            Must start with '/'.\n          pattern: \"^/.*\"\n        readOnly:\n          type: boolean\n          description: |\n            If true, the volume is mounted as read-only. Defaults to false (read-write).\n          default: false\n        subPath:\n          type: string\n          description: |\n            Optional subdirectory under the backend path to mount.\n            For `ossfs` backend, this field is used as the bucket prefix.\n            Must be a relative path without '..' components.\n      additionalProperties: false\n\n    Host:\n      type: object\n      description: |\n        Host path bind mount backend. Maps a directory on the host filesystem\n        into the container. Only available when the runtime supports host mounts.\n\n        Security note: Host paths are restricted by server-side allowlist.\n        Users must specify paths under permitted prefixes.\n      required: [path]\n      properties:\n        path:\n          type: string\n          description: |\n            Absolute path on the host filesystem to mount.\n            Must start with '/' and be under an allowed prefix.\n          pattern: \"^/.*\"\n      additionalProperties: false\n\n    PVC:\n      type: object\n      description: |\n        Platform-managed named volume backend. A runtime-neutral abstraction\n        for referencing a pre-existing, platform-managed named volume.\n\n        - Kubernetes: maps to a PersistentVolumeClaim in the same namespace.\n        - Docker: maps to a Docker named volume (created via `docker volume create`).\n\n        The volume must already exist on the target platform before sandbox\n        creation.\n      required: [claimName]\n      properties:\n        claimName:\n          type: string\n          description: |\n            Name of the volume on the target platform.\n            In Kubernetes this is the PVC name; in Docker this is the named\n            volume name. Must be a valid DNS label.\n          pattern: \"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$\"\n          maxLength: 253\n      additionalProperties: false\n\n    OSSFS:\n      type: object\n      description: |\n        Alibaba Cloud OSS mount backend via ossfs.\n\n        The runtime mounts a host-side OSS path under `storage.ossfs_mount_root`\n        and bind-mounts the resolved path into the sandbox container.\n        Prefix selection is expressed via `Volume.subPath`.\n        In Docker runtime, OSSFS backend requires OpenSandbox Server to run on a Linux host with FUSE support.\n      required: [bucket, endpoint, accessKeyId, accessKeySecret]\n      properties:\n        bucket:\n          type: string\n          description: OSS bucket name.\n          minLength: 3\n          maxLength: 63\n        endpoint:\n          type: string\n          description: OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`).\n          minLength: 1\n        version:\n          type: string\n          description: ossfs major version used by runtime mount integration.\n          enum: [\"1.0\", \"2.0\"]\n          default: \"2.0\"\n        options:\n          type: array\n          description: |\n            Additional ossfs mount options.\n            Runtime encodes options by `version`:\n            - `1.0`: mounts with `ossfs ... -o <option>`\n            - `2.0`: mounts with `ossfs2 mount ... -c <config-file>` and encodes options as `--<option>` lines in the config file\n            Option values must be provided as raw payloads without leading `-`.\n          items:\n            type: string\n        accessKeyId:\n          type: string\n          description: OSS access key ID for inline credentials mode.\n          minLength: 1\n        accessKeySecret:\n          type: string\n          description: OSS access key secret for inline credentials mode.\n          minLength: 1\n      additionalProperties: false\n"
  },
  {
    "path": "tests/csharp/OpenSandbox.E2ETests/CodeInterpreterE2ETests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.CodeInterpreter;\nusing OpenSandbox.CodeInterpreter.Models;\nusing OpenSandbox.Models;\nusing Xunit;\nusing CodeInterpreterClient = OpenSandbox.CodeInterpreter.CodeInterpreter;\n\nnamespace OpenSandbox.E2ETests;\n\n[Collection(\"CSharp E2E Tests\")]\npublic class CodeInterpreterE2ETests : IClassFixture<CodeInterpreterE2ETestFixture>\n{\n    private readonly CodeInterpreterE2ETestFixture _fixture;\n\n    public CodeInterpreterE2ETests(CodeInterpreterE2ETestFixture fixture)\n    {\n        _fixture = fixture;\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task CreateInterpreter_ExposesSandboxServices()\n    {\n        var sandbox = _fixture.Sandbox;\n        var interpreter = _fixture.Interpreter;\n\n        Assert.Equal(sandbox.Id, interpreter.Id);\n        Assert.NotNull(interpreter.Codes);\n        Assert.NotNull(interpreter.Files);\n        Assert.NotNull(interpreter.Commands);\n        Assert.NotNull(interpreter.Metrics);\n\n        var metrics = await interpreter.Metrics.GetMetricsAsync();\n        Assert.True(metrics.CpuCount > 0);\n\n        var cmd = await RunCommandWithRetryAsync(interpreter, \"echo code-interpreter-ready\");\n        Assert.Null(cmd.Error);\n        Assert.Contains(cmd.Logs.Stdout, m => m.Text.Contains(\"code-interpreter-ready\", StringComparison.Ordinal));\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task ContextManagement_CreateGetListDelete()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var ctx = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n        Assert.NotNull(ctx.Id);\n        Assert.Equal(SupportedLanguage.Python, ctx.Language);\n\n        var fetched = await interpreter.Codes.GetContextAsync(ctx.Id!);\n        Assert.Equal(ctx.Id, fetched.Id);\n        Assert.Equal(SupportedLanguage.Python, fetched.Language);\n\n        var listed = await interpreter.Codes.ListContextsAsync(SupportedLanguage.Python);\n        Assert.Contains(listed, c => c.Id == ctx.Id);\n\n        await interpreter.Codes.DeleteContextAsync(ctx.Id!);\n\n        var listedAfterDelete = await interpreter.Codes.ListContextsAsync(SupportedLanguage.Python);\n        Assert.DoesNotContain(listedAfterDelete, c => c.Id == ctx.Id);\n    }\n\n    [Fact(Timeout = 4 * 60 * 1000)]\n    public async Task RunAsync_ContextPersistence_AndIsolation()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var ctx1 = await CreateContextWithRetryAsync(interpreter, SupportedLanguage.Python);\n        var ctx2 = await CreateContextWithRetryAsync(interpreter, SupportedLanguage.Python);\n\n        await RunWithRetryAsync(interpreter, \"x = 42\", new RunCodeOptions { Context = ctx1 });\n        var persisted = await RunWithRetryAsync(interpreter, \"print(x)\", new RunCodeOptions { Context = ctx1 });\n        Assert.Contains(persisted.Logs.Stdout, s => s.Text.Contains(\"42\", StringComparison.Ordinal));\n\n        var isolated = await RunWithRetryAsync(interpreter, \"print('x' in globals())\", new RunCodeOptions { Context = ctx2 });\n        Assert.Contains(isolated.Logs.Stdout, s => s.Text.Contains(\"False\", StringComparison.OrdinalIgnoreCase));\n\n        await interpreter.Codes.DeleteContextAsync(ctx1.Id!);\n        await interpreter.Codes.DeleteContextAsync(ctx2.Id!);\n    }\n\n    [Fact(Timeout = 3 * 60 * 1000)]\n    public async Task RunAsync_MultiLanguage_BasicExecution()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var py = await interpreter.Codes.RunAsync(\"print(1+2)\", new RunCodeOptions { Language = SupportedLanguage.Python });\n        Assert.Contains(py.Logs.Stdout, s => s.Text.Contains(\"3\", StringComparison.Ordinal));\n\n        var js = await interpreter.Codes.RunAsync(\"console.log(3+4)\", new RunCodeOptions { Language = SupportedLanguage.JavaScript });\n        Assert.Contains(js.Logs.Stdout, s => s.Text.Contains(\"7\", StringComparison.Ordinal));\n\n        var bash = await interpreter.Codes.RunAsync(\"echo $((8+9))\", new RunCodeOptions { Language = SupportedLanguage.Bash });\n        Assert.Contains(bash.Logs.Stdout, s => s.Text.Contains(\"17\", StringComparison.Ordinal));\n    }\n\n    [Fact(Timeout = 6 * 60 * 1000)]\n    public async Task RunAsync_MultiLanguage_Java_Go_TypeScript()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var javaCtx = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Java);\n        var goCtx = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Go);\n        var tsCtx = await interpreter.Codes.CreateContextAsync(SupportedLanguage.TypeScript);\n\n        try\n        {\n            var javaResult = await interpreter.Codes.RunAsync(\n                \"System.out.println(\\\"java-ok\\\");\\nint v = 2 + 3;\\nSystem.out.println(v);\\n\",\n                new RunCodeOptions { Context = javaCtx });\n            Assert.Null(javaResult.Error);\n            Assert.True(HasText(javaResult, \"java-ok\") || HasText(javaResult, \"5\"));\n\n            var goResult = await interpreter.Codes.RunAsync(\n                \"package main\\nimport \\\"fmt\\\"\\nfunc main(){ fmt.Print(\\\"go-ok\\\") }\",\n                new RunCodeOptions { Context = goCtx });\n            Assert.Null(goResult.Error);\n            Assert.True(HasText(goResult, \"go-ok\"));\n\n            var tsResult = await interpreter.Codes.RunAsync(\n                \"console.log('ts-ok'); const n: number = 3 + 4; console.log(n);\",\n                new RunCodeOptions { Context = tsCtx });\n            Assert.Null(tsResult.Error);\n            Assert.True(HasText(tsResult, \"ts-ok\") || HasText(tsResult, \"7\"));\n        }\n        finally\n        {\n            await interpreter.Codes.DeleteContextAsync(javaCtx.Id!);\n            await interpreter.Codes.DeleteContextAsync(goCtx.Id!);\n            await interpreter.Codes.DeleteContextAsync(tsCtx.Id!);\n        }\n    }\n\n    [Fact(Timeout = 3 * 60 * 1000)]\n    public async Task ContextManagement_DeleteContexts_ByLanguage()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        await interpreter.Codes.DeleteContextsAsync(SupportedLanguage.Bash);\n\n        var ctx1 = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Bash);\n        var ctx2 = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Bash);\n\n        var listed = await interpreter.Codes.ListContextsAsync(SupportedLanguage.Bash);\n        Assert.Contains(listed, c => c.Id == ctx1.Id);\n        Assert.Contains(listed, c => c.Id == ctx2.Id);\n\n        await interpreter.Codes.DeleteContextsAsync(SupportedLanguage.Bash);\n\n        var afterDelete = await interpreter.Codes.ListContextsAsync(SupportedLanguage.Bash);\n        Assert.DoesNotContain(afterDelete, c => c.Id == ctx1.Id);\n        Assert.DoesNotContain(afterDelete, c => c.Id == ctx2.Id);\n    }\n\n    [Fact(Timeout = 3 * 60 * 1000)]\n    public async Task RunStreamAsync_ReturnsRealtimeEvents()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var request = new RunCodeRequest\n        {\n            Code = \"for i in range(3): print(i)\",\n            Context = new CodeContext { Language = SupportedLanguage.Python }\n        };\n\n        var events = await RunStreamCollectWithRetryAsync(interpreter, request);\n\n        Assert.True(events.Count > 0);\n        Assert.Contains(\n            events,\n            ev => ev.Type == ServerStreamEventTypes.Stdout ||\n                  ev.Type == ServerStreamEventTypes.Result ||\n                  ev.Type == ServerStreamEventTypes.Error ||\n                  ev.Type == ServerStreamEventTypes.ExecutionComplete);\n    }\n\n    [Fact(Timeout = 3 * 60 * 1000)]\n    public async Task InterruptAsync_StopsLongRunningExecution()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var ctx = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n        var runTask = interpreter.Codes.RunAsync(\n            \"import time\\nwhile True: time.sleep(1)\",\n            new RunCodeOptions { Context = ctx });\n\n        await Task.Delay(2000);\n        await interpreter.Codes.InterruptAsync(ctx.Id!);\n\n        var execution = await runTask.WaitAsync(TimeSpan.FromSeconds(30));\n        Assert.True(execution.Error != null || execution.Logs.Stderr.Count > 0 || execution.Complete != null);\n\n        await interpreter.Codes.DeleteContextAsync(ctx.Id!);\n    }\n\n    [Fact(Timeout = 6 * 60 * 1000)]\n    public async Task RunAsync_ConcurrentExecution_MultipleContexts()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var py = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n        var java = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Java);\n        var go = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Go);\n\n        try\n        {\n            var tasks = new[]\n            {\n                interpreter.Codes.RunAsync(\n                    \"import time\\nfor i in range(3):\\n print(f'py-{i}')\\n time.sleep(0.1)\\nprint('py-done')\",\n                    new RunCodeOptions { Context = py }),\n                interpreter.Codes.RunAsync(\n                    \"for (int i=0;i<3;i++){System.out.println(\\\"java-\\\" + i);} System.out.println(\\\"java-done\\\");\",\n                    new RunCodeOptions { Context = java }),\n                interpreter.Codes.RunAsync(\n                    \"package main\\nimport \\\"fmt\\\"\\nfunc main(){for i:=0;i<3;i++{fmt.Println(i)}; fmt.Print(\\\"go-done\\\")}\",\n                    new RunCodeOptions { Context = go })\n            };\n\n            var results = await Task.WhenAll(tasks);\n            var succeeded = results.Count(r => r != null && r.Error == null && !string.IsNullOrWhiteSpace(r.Id));\n            Assert.True(succeeded >= 2, $\"expected at least 2 successful concurrent runs, actual={succeeded}\");\n        }\n        finally\n        {\n            await interpreter.Codes.DeleteContextAsync(py.Id!);\n            await interpreter.Codes.DeleteContextAsync(java.Id!);\n            await interpreter.Codes.DeleteContextAsync(go.Id!);\n        }\n    }\n\n    [Fact(Timeout = 8 * 60 * 1000)]\n    public async Task RunAsync_MultiLanguage_ErrorHandling_WithEventContract()\n    {\n        var interpreter = _fixture.Interpreter;\n\n        var py = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Python);\n        var java = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Java);\n        var go = await interpreter.Codes.CreateContextAsync(SupportedLanguage.Go);\n        var ts = await interpreter.Codes.CreateContextAsync(SupportedLanguage.TypeScript);\n\n        try\n        {\n            var pyExecution = await RunWithTrackedEventsAsync(\n                interpreter,\n                \"print(undefined_variable)\",\n                py);\n            Assert.True(pyExecution.Execution.Error != null || pyExecution.Execution.Logs.Stderr.Count > 0);\n            if (pyExecution.Execution.Error != null)\n            {\n                Assert.Contains(\"NameError\", pyExecution.Execution.Error.Name, StringComparison.OrdinalIgnoreCase);\n            }\n            AssertTerminalEventContract(pyExecution.InitEvents, pyExecution.CompleteEvents, pyExecution.ErrorEvents, pyExecution.Execution.Id);\n\n            var javaExecution = await RunWithTrackedEventsAsync(\n                interpreter,\n                \"int x = 10 / 0;\",\n                java);\n            Assert.True(javaExecution.Execution.Error != null || javaExecution.Execution.Logs.Stderr.Count > 0);\n            AssertTerminalEventContract(javaExecution.InitEvents, javaExecution.CompleteEvents, javaExecution.ErrorEvents, javaExecution.Execution.Id);\n\n            var goExecution = await RunWithTrackedEventsAsync(\n                interpreter,\n                \"package main\\nfunc main(){ undeclaredVariable++ }\",\n                go);\n            Assert.True(goExecution.Execution.Error != null || goExecution.Execution.Logs.Stderr.Count > 0);\n            AssertTerminalEventContract(goExecution.InitEvents, goExecution.CompleteEvents, goExecution.ErrorEvents, goExecution.Execution.Id);\n\n            var tsExecution = await RunWithTrackedEventsAsync(\n                interpreter,\n                \"throw new Error('ts-runtime-error');\",\n                ts);\n            Assert.True(tsExecution.Execution.Error != null || tsExecution.Execution.Logs.Stderr.Count > 0);\n            AssertTerminalEventContract(tsExecution.InitEvents, tsExecution.CompleteEvents, tsExecution.ErrorEvents, tsExecution.Execution.Id);\n        }\n        finally\n        {\n            await interpreter.Codes.DeleteContextAsync(py.Id!);\n            await interpreter.Codes.DeleteContextAsync(java.Id!);\n            await interpreter.Codes.DeleteContextAsync(go.Id!);\n            await interpreter.Codes.DeleteContextAsync(ts.Id!);\n        }\n    }\n\n    private static async Task<TrackedExecution> RunWithTrackedEventsAsync(\n        CodeInterpreterClient interpreter,\n        string code,\n        CodeContext context)\n    {\n        var initEvents = new List<ExecutionInit>();\n        var completeEvents = new List<ExecutionComplete>();\n        var errorEvents = new List<ExecutionError>();\n        var handlers = new ExecutionHandlers\n        {\n            OnInit = ev =>\n            {\n                initEvents.Add(ev);\n                return Task.CompletedTask;\n            },\n            OnExecutionComplete = ev =>\n            {\n                completeEvents.Add(ev);\n                return Task.CompletedTask;\n            },\n            OnError = ev =>\n            {\n                errorEvents.Add(ev);\n                return Task.CompletedTask;\n            }\n        };\n\n        var execution = await interpreter.Codes.RunAsync(\n            code,\n            new RunCodeOptions\n            {\n                Context = context,\n                Handlers = handlers\n            });\n\n        return new TrackedExecution(execution, initEvents, completeEvents, errorEvents);\n    }\n\n    private static void AssertTerminalEventContract(\n        IReadOnlyList<ExecutionInit> initEvents,\n        IReadOnlyList<ExecutionComplete> completeEvents,\n        IReadOnlyList<ExecutionError> errorEvents,\n        string? executionId)\n    {\n        Assert.Single(initEvents);\n        Assert.False(string.IsNullOrWhiteSpace(initEvents[0].Id));\n        if (!string.IsNullOrWhiteSpace(executionId))\n        {\n            Assert.Equal(executionId, initEvents[0].Id);\n        }\n        AssertRecentTimestampMs(initEvents[0].Timestamp, 180_000);\n\n        var hasComplete = completeEvents.Count > 0;\n        var hasError = errorEvents.Count > 0;\n        Assert.True(hasComplete || hasError);\n\n        if (hasComplete)\n        {\n            Assert.Single(completeEvents);\n            Assert.True(completeEvents[0].ExecutionTimeMs >= 0);\n            AssertRecentTimestampMs(completeEvents[0].Timestamp, 180_000);\n        }\n\n        if (hasError)\n        {\n            Assert.False(string.IsNullOrWhiteSpace(errorEvents[0].Name));\n            Assert.False(string.IsNullOrWhiteSpace(errorEvents[0].Value));\n            AssertRecentTimestampMs(errorEvents[0].Timestamp, 180_000);\n        }\n    }\n\n    private static void AssertRecentTimestampMs(long ts, long toleranceMs)\n    {\n        Assert.True(ts > 0);\n        var delta = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts);\n        Assert.True(delta <= toleranceMs, $\"timestamp too far from now: delta={delta}ms (ts={ts})\");\n    }\n\n    private static bool HasText(Execution execution, string expected)\n    {\n        return execution.Logs.Stdout.Any(x => x.Text.Contains(expected, StringComparison.Ordinal)) ||\n               execution.Logs.Stderr.Any(x => x.Text.Contains(expected, StringComparison.Ordinal)) ||\n               execution.Results.Any(x => (x.Text ?? string.Empty).Contains(expected, StringComparison.Ordinal));\n    }\n\n    private static async Task<CodeContext> CreateContextWithRetryAsync(\n        CodeInterpreterClient interpreter,\n        string language,\n        int maxRetries = 3)\n    {\n        Exception? lastError = null;\n        var delayMs = 1000;\n        for (var attempt = 1; attempt <= maxRetries; attempt++)\n        {\n            try\n            {\n                var ctx = await interpreter.Codes.CreateContextAsync(language).WaitAsync(TimeSpan.FromSeconds(60));\n                await Task.Delay(500);\n                return ctx;\n            }\n            catch (Exception ex) when (IsRetryable(ex) && attempt < maxRetries)\n            {\n                lastError = ex;\n                await Task.Delay(delayMs);\n                delayMs = (int)(delayMs * 1.5);\n            }\n            catch (Exception ex)\n            {\n                lastError = ex;\n                break;\n            }\n        }\n\n        throw lastError ?? new TimeoutException(\"CreateContextWithRetryAsync failed unexpectedly.\");\n    }\n\n    private static async Task<Execution> RunWithRetryAsync(\n        CodeInterpreterClient interpreter,\n        string code,\n        RunCodeOptions? options = null,\n        int maxRetries = 3,\n        int perCallTimeoutSeconds = 120)\n    {\n        Exception? lastError = null;\n        var delayMs = 1000;\n        for (var attempt = 1; attempt <= maxRetries; attempt++)\n        {\n            try\n            {\n                var result = await interpreter.Codes\n                    .RunAsync(code, options)\n                    .WaitAsync(TimeSpan.FromSeconds(perCallTimeoutSeconds));\n\n                if (!string.IsNullOrWhiteSpace(result.Id))\n                {\n                    return result;\n                }\n\n                if (attempt < maxRetries)\n                {\n                    await Task.Delay(delayMs);\n                    delayMs = (int)(delayMs * 1.5);\n                    continue;\n                }\n\n                return result;\n            }\n            catch (Exception ex) when (IsRetryable(ex) && attempt < maxRetries)\n            {\n                lastError = ex;\n                await Task.Delay(delayMs);\n                delayMs = (int)(delayMs * 1.5);\n            }\n            catch (Exception ex)\n            {\n                lastError = ex;\n                break;\n            }\n        }\n\n        throw lastError ?? new TimeoutException(\"RunWithRetryAsync failed unexpectedly.\");\n    }\n\n    private static async Task<List<ServerStreamEvent>> RunStreamCollectWithRetryAsync(\n        CodeInterpreterClient interpreter,\n        RunCodeRequest request,\n        int maxRetries = 3,\n        int perCallTimeoutSeconds = 120)\n    {\n        Exception? lastError = null;\n        var delayMs = 1000;\n        for (var attempt = 1; attempt <= maxRetries; attempt++)\n        {\n            try\n            {\n                var events = new List<ServerStreamEvent>();\n                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(perCallTimeoutSeconds));\n                await foreach (var ev in interpreter.Codes.RunStreamAsync(request, cts.Token))\n                {\n                    events.Add(ev);\n                }\n\n                var hasBusinessEvent = events.Any(ev =>\n                    ev.Type == ServerStreamEventTypes.Stdout ||\n                    ev.Type == ServerStreamEventTypes.Result ||\n                    ev.Type == ServerStreamEventTypes.Error ||\n                    ev.Type == ServerStreamEventTypes.ExecutionComplete);\n\n                if (hasBusinessEvent || attempt == maxRetries)\n                {\n                    return events;\n                }\n\n                await Task.Delay(delayMs);\n                delayMs = (int)(delayMs * 1.5);\n            }\n            catch (Exception ex) when (IsRetryable(ex) && attempt < maxRetries)\n            {\n                lastError = ex;\n                await Task.Delay(delayMs);\n                delayMs = (int)(delayMs * 1.5);\n            }\n            catch (Exception ex)\n            {\n                lastError = ex;\n                break;\n            }\n        }\n\n        throw lastError ?? new TimeoutException(\"RunStreamCollectWithRetryAsync failed unexpectedly.\");\n    }\n\n    private static async Task<Execution> RunCommandWithRetryAsync(\n        CodeInterpreterClient interpreter,\n        string command,\n        int maxRetries = 3,\n        int perCallTimeoutSeconds = 30)\n    {\n        Exception? lastError = null;\n        Execution? lastResult = null;\n        var delayMs = 1000;\n\n        for (var attempt = 1; attempt <= maxRetries; attempt++)\n        {\n            try\n            {\n                var result = await interpreter.Commands\n                    .RunAsync(command)\n                    .WaitAsync(TimeSpan.FromSeconds(perCallTimeoutSeconds));\n\n                lastResult = result;\n                var hasExpectedStdout = result.Logs.Stdout.Any(log =>\n                    log.Text.Contains(\"code-interpreter-ready\", StringComparison.Ordinal));\n                if (result.Error == null && hasExpectedStdout)\n                {\n                    return result;\n                }\n\n                if (attempt < maxRetries)\n                {\n                    await Task.Delay(delayMs);\n                    delayMs = (int)(delayMs * 1.5);\n                    continue;\n                }\n\n                return result;\n            }\n            catch (Exception ex) when (IsRetryable(ex) && attempt < maxRetries)\n            {\n                lastError = ex;\n                await Task.Delay(delayMs);\n                delayMs = (int)(delayMs * 1.5);\n            }\n            catch (Exception ex)\n            {\n                lastError = ex;\n                break;\n            }\n        }\n\n        if (lastResult != null)\n        {\n            return lastResult;\n        }\n\n        throw lastError ?? new TimeoutException(\"RunCommandWithRetryAsync failed unexpectedly.\");\n    }\n\n    private static bool IsRetryable(Exception ex)\n    {\n        if (ex is TimeoutException || ex is TaskCanceledException)\n        {\n            return true;\n        }\n\n        var message = ex.ToString();\n        var lowered = message.ToLowerInvariant();\n        return lowered.Contains(\"disconnected\", StringComparison.Ordinal) ||\n               lowered.Contains(\"connection\", StringComparison.Ordinal) ||\n               lowered.Contains(\"reset\", StringComparison.Ordinal) ||\n               lowered.Contains(\"closed\", StringComparison.Ordinal) ||\n               lowered.Contains(\"timeout\", StringComparison.Ordinal) ||\n               lowered.Contains(\"peer\", StringComparison.Ordinal) ||\n               lowered.Contains(\"response ended prematurely\", StringComparison.Ordinal);\n    }\n\n    private sealed record TrackedExecution(\n        Execution Execution,\n        IReadOnlyList<ExecutionInit> InitEvents,\n        IReadOnlyList<ExecutionComplete> CompleteEvents,\n        IReadOnlyList<ExecutionError> ErrorEvents);\n}\n\npublic sealed class CodeInterpreterE2ETestFixture : IAsyncLifetime\n{\n    private readonly E2ETestFixture _baseFixture = new();\n    private Sandbox? _sandbox;\n    private CodeInterpreterClient? _interpreter;\n\n    public Sandbox Sandbox => _sandbox ?? throw new InvalidOperationException(\"Sandbox is not initialized.\");\n    public CodeInterpreterClient Interpreter => _interpreter ?? throw new InvalidOperationException(\"Interpreter is not initialized.\");\n\n    public async Task InitializeAsync()\n    {\n        _sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _baseFixture.ConnectionConfig,\n            Image = _baseFixture.DefaultImage,\n            Entrypoint = new[] { \"/opt/opensandbox/code-interpreter.sh\" },\n            TimeoutSeconds = _baseFixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _baseFixture.DefaultReadyTimeoutSeconds,\n            Resource = new Dictionary<string, string>\n            {\n                [\"cpu\"] = \"2\",\n                [\"memory\"] = \"4Gi\"\n            },\n            Env = new Dictionary<string, string>\n            {\n                [\"E2E_TEST\"] = \"true\",\n                [\"GO_VERSION\"] = \"1.25\",\n                [\"JAVA_VERSION\"] = \"21\",\n                [\"NODE_VERSION\"] = \"22\",\n                [\"PYTHON_VERSION\"] = \"3.12\",\n                [\"EXECD_LOG_FILE\"] = \"/tmp/opensandbox-e2e/logs/execd.log\"\n            },\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"execd-log\",\n                    Host = new Host { Path = \"/tmp/opensandbox-e2e/logs\" },\n                    MountPath = \"/tmp/opensandbox-e2e/logs\",\n                    ReadOnly = false\n                }\n            },\n            Metadata = new Dictionary<string, string> { [\"tag\"] = \"csharp-code-interpreter-e2e\" },\n            HealthCheckPollingInterval = 500\n        });\n\n        _interpreter = await CodeInterpreterClient.CreateAsync(_sandbox);\n    }\n\n    public async Task DisposeAsync()\n    {\n        if (_sandbox == null)\n        {\n            return;\n        }\n\n        try\n        {\n            await _sandbox.KillAsync();\n        }\n        catch\n        {\n        }\n\n        await _sandbox.DisposeAsync();\n    }\n}\n"
  },
  {
    "path": "tests/csharp/OpenSandbox.E2ETests/E2ETestFixture.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Config;\nusing Xunit;\n\nnamespace OpenSandbox.E2ETests;\n\n/// <summary>\n/// Shared fixture for E2E tests providing common configuration.\n/// </summary>\npublic sealed class E2ETestFixture : IAsyncLifetime\n{\n    public string DefaultImage { get; }\n\n    public ConnectionConfig ConnectionConfig { get; }\n\n    public ConnectionConfig ServerProxyConnectionConfig { get; }\n\n    public int DefaultTimeoutSeconds { get; } = 1200;\n\n    public int DefaultReadyTimeoutSeconds { get; } = 90;\n\n    public E2ETestFixture()\n    {\n        DefaultImage =\n            Environment.GetEnvironmentVariable(\"OPENSANDBOX_SANDBOX_DEFAULT_IMAGE\")\n            ?? Environment.GetEnvironmentVariable(\"SANDBOX_IMAGE\")\n            ?? \"opensandbox/code-interpreter:latest\";\n\n        var domain =\n            Environment.GetEnvironmentVariable(\"OPENSANDBOX_TEST_DOMAIN\")\n            ?? Environment.GetEnvironmentVariable(\"SANDBOX_DOMAIN\")\n            ?? \"localhost:8080\";\n\n        var apiKey =\n            Environment.GetEnvironmentVariable(\"OPENSANDBOX_TEST_API_KEY\")\n            ?? Environment.GetEnvironmentVariable(\"SANDBOX_API_KEY\");\n\n        var protocolRaw =\n            Environment.GetEnvironmentVariable(\"OPENSANDBOX_TEST_PROTOCOL\")\n            ?? Environment.GetEnvironmentVariable(\"SANDBOX_PROTOCOL\")\n            ?? \"http\";\n\n        var protocol = protocolRaw.Equals(\"https\", StringComparison.OrdinalIgnoreCase)\n            ? ConnectionProtocol.Https\n            : ConnectionProtocol.Http;\n\n        ConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = domain,\n            Protocol = protocol,\n            ApiKey = apiKey,\n            RequestTimeoutSeconds = 180\n        });\n\n        ServerProxyConnectionConfig = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = domain,\n            Protocol = protocol,\n            ApiKey = apiKey,\n            RequestTimeoutSeconds = 180,\n            UseServerProxy = true\n        });\n    }\n\n    public Task InitializeAsync()\n    {\n        return Task.CompletedTask;\n    }\n\n    public Task DisposeAsync()\n    {\n        return Task.CompletedTask;\n    }\n}\n\n[CollectionDefinition(\"CSharp E2E Tests\")]\npublic sealed class E2ETestCollection : ICollectionFixture<E2ETestFixture>\n{\n}\n"
  },
  {
    "path": "tests/csharp/OpenSandbox.E2ETests/OpenSandbox.E2ETests.csproj",
    "content": "<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <LangVersion>12.0</LangVersion>\n    <Nullable>enable</Nullable>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <IsPackable>false</IsPackable>\n    <IsTestProject>true</IsTestProject>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"Microsoft.NET.Test.Sdk\" Version=\"17.11.1\" />\n    <PackageReference Include=\"xunit\" Version=\"2.9.2\" />\n    <PackageReference Include=\"xunit.runner.visualstudio\" Version=\"2.8.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n    <PackageReference Include=\"coverlet.collector\" Version=\"6.0.2\">\n      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>\n      <PrivateAssets>all</PrivateAssets>\n    </PackageReference>\n    <PackageReference Include=\"FluentAssertions\" Version=\"6.12.2\" />\n  </ItemGroup>\n\n  <ItemGroup>\n    <ProjectReference Include=\"..\\..\\..\\sdks\\sandbox\\csharp\\src\\OpenSandbox\\OpenSandbox.csproj\" />\n    <ProjectReference Include=\"..\\..\\..\\sdks\\code-interpreter\\csharp\\src\\OpenSandbox.CodeInterpreter\\OpenSandbox.CodeInterpreter.csproj\" />\n  </ItemGroup>\n\n</Project>\n"
  },
  {
    "path": "tests/csharp/OpenSandbox.E2ETests/SandboxE2ETests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing System.Collections.Concurrent;\nusing System.Text;\nusing OpenSandbox.Config;\nusing OpenSandbox.Core;\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.E2ETests;\n\n[Collection(\"CSharp E2E Tests\")]\npublic class SandboxE2ETests : IClassFixture<SandboxE2ETestFixture>\n{\n    private readonly SandboxE2ETestFixture _fixture;\n\n    public SandboxE2ETests(SandboxE2ETestFixture fixture)\n    {\n        _fixture = fixture;\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Lifecycle_Health_Endpoint_Metrics_Renew_Connect()\n    {\n        var sandbox = _fixture.Sandbox;\n        Assert.False(string.IsNullOrWhiteSpace(sandbox.Id));\n        Assert.True(await sandbox.IsHealthyAsync());\n\n        var info = await sandbox.GetInfoAsync();\n        Assert.Equal(sandbox.Id, info.Id);\n        Assert.Equal(SandboxStates.Running, info.Status.State);\n        Assert.Equal(Constants.DefaultEntrypoint, info.Entrypoint);\n        Assert.NotNull(info.Metadata);\n        Assert.Equal(\"csharp-e2e-test\", info.Metadata![\"tag\"]);\n        Assert.True(info.ExpiresAt > info.CreatedAt);\n\n        var endpoint = await sandbox.GetEndpointAsync(Constants.DefaultExecdPort);\n        AssertEndpointHasPort(endpoint.EndpointAddress, Constants.DefaultExecdPort);\n\n        var metrics = await sandbox.GetMetricsAsync();\n        Assert.True(metrics.CpuCount > 0);\n        Assert.True(metrics.CpuUsedPercentage is >= 0.0 and <= 100.0);\n        Assert.True(metrics.MemoryTotalMiB > 0);\n        Assert.True(metrics.MemoryUsedMiB <= metrics.MemoryTotalMiB);\n        AssertRecentTimestampMs(metrics.Timestamp, 120_000);\n\n        var renewResponse = await sandbox.RenewAsync(30 * 60);\n        Assert.NotNull(renewResponse);\n        Assert.NotNull(renewResponse.ExpiresAt);\n        var renewedInfo = await sandbox.GetInfoAsync();\n        Assert.True(renewedInfo.ExpiresAt > info.ExpiresAt);\n        Assert.True(renewResponse.ExpiresAt > info.ExpiresAt);\n\n        var sandbox2 = await Sandbox.ConnectAsync(new SandboxConnectOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            SandboxId = sandbox.Id\n        });\n\n        try\n        {\n            Assert.Equal(sandbox.Id, sandbox2.Id);\n            Assert.True(await sandbox2.IsHealthyAsync());\n            var result = await sandbox2.Commands.RunAsync(\"echo connect-ok\");\n            Assert.Null(result.Error);\n            Assert.Single(result.Logs.Stdout);\n            Assert.Equal(\"connect-ok\", result.Logs.Stdout[0].Text);\n        }\n        finally\n        {\n            await sandbox2.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_XRequestId_Passthrough_OnServerError()\n    {\n        var requestId = $\"e2e-csharp-server-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\";\n        var missingSandboxId = $\"missing-{requestId}\";\n        var baseConfig = _fixture.ConnectionConfig;\n        var config = new ConnectionConfig(new ConnectionConfigOptions\n        {\n            Domain = baseConfig.Domain,\n            Protocol = baseConfig.Protocol,\n            ApiKey = baseConfig.ApiKey,\n            RequestTimeoutSeconds = baseConfig.RequestTimeoutSeconds,\n            Headers = new Dictionary<string, string> { [\"X-Request-ID\"] = requestId }\n        });\n\n        var ex = await Assert.ThrowsAsync<SandboxApiException>(async () =>\n        {\n            var connected = await Sandbox.ConnectAsync(new SandboxConnectOptions\n            {\n                ConnectionConfig = config,\n                SandboxId = missingSandboxId\n            });\n            try\n            {\n                await connected.GetInfoAsync();\n            }\n            finally\n            {\n                await connected.DisposeAsync();\n            }\n        });\n\n        Assert.Equal(requestId, ex.RequestId);\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_ManualCleanup_Returns_Null_ExpiresAt()\n    {\n        var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            ManualCleanup = true,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Metadata = new Dictionary<string, string> { [\"tag\"] = \"manual-csharp-e2e-test\" }\n        });\n\n        try\n        {\n            var info = await sandbox.GetInfoAsync();\n            Assert.Null(info.ExpiresAt);\n            Assert.NotNull(info.Metadata);\n            Assert.Equal(\"manual-csharp-e2e-test\", info.Metadata![\"tag\"]);\n        }\n        finally\n        {\n            await sandbox.KillAsync();\n            await sandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress()\n    {\n        var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            NetworkPolicy = new NetworkPolicy\n            {\n                DefaultAction = NetworkRuleAction.Deny,\n                Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = \"pypi.org\" } }\n            }\n        });\n\n        try\n        {\n            await Task.Delay(5000);\n\n            var initialPolicy = await policySandbox.GetEgressPolicyAsync();\n            Assert.NotNull(initialPolicy);\n            Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction);\n            Assert.NotNull(initialPolicy.Egress);\n            Assert.Contains(\n                initialPolicy.Egress!,\n                rule => rule.Target == \"pypi.org\" && rule.Action == NetworkRuleAction.Allow);\n\n            var blocked = await policySandbox.Commands.RunAsync(\"curl -I https://www.github.com\");\n            Assert.NotNull(blocked.Error);\n\n            var allowed = await policySandbox.Commands.RunAsync(\"curl -I https://pypi.org\");\n            Assert.Null(allowed.Error);\n\n            await policySandbox.PatchEgressRulesAsync(new List<NetworkRule>\n            {\n                new() { Action = NetworkRuleAction.Allow, Target = \"www.github.com\" },\n                new() { Action = NetworkRuleAction.Deny, Target = \"pypi.org\" }\n            });\n            await Task.Delay(2000);\n\n            var patchedPolicy = await policySandbox.GetEgressPolicyAsync();\n            Assert.NotNull(patchedPolicy.Egress);\n            Assert.Contains(\n                patchedPolicy.Egress!,\n                rule => rule.Target == \"www.github.com\" && rule.Action == NetworkRuleAction.Allow);\n            Assert.Contains(\n                patchedPolicy.Egress!,\n                rule => rule.Target == \"pypi.org\" && rule.Action == NetworkRuleAction.Deny);\n\n            var githubAllowed = await policySandbox.Commands.RunAsync(\"curl -I https://www.github.com\");\n            Assert.Null(githubAllowed.Error);\n\n            var pypiDenied = await policySandbox.Commands.RunAsync(\"curl -I https://pypi.org\");\n            Assert.NotNull(pypiDenied.Error);\n        }\n        finally\n        {\n            try\n            {\n                await policySandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await policySandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_NetworkPolicy_Get_And_Patch_Egress_Via_ServerProxy()\n    {\n        var policySandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ServerProxyConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            NetworkPolicy = new NetworkPolicy\n            {\n                DefaultAction = NetworkRuleAction.Deny,\n                Egress = new List<NetworkRule> { new() { Action = NetworkRuleAction.Allow, Target = \"pypi.org\" } }\n            }\n        });\n\n        try\n        {\n            await Task.Delay(5000);\n\n            var egressEndpoint = await policySandbox.GetEndpointAsync(Constants.DefaultEgressPort);\n            Assert.Contains(\n                $\"/sandboxes/{policySandbox.Id}/proxy/{Constants.DefaultEgressPort}\",\n                egressEndpoint.EndpointAddress);\n\n            var initialPolicy = await policySandbox.GetEgressPolicyAsync();\n            Assert.NotNull(initialPolicy);\n            Assert.Equal(NetworkRuleAction.Deny, initialPolicy.DefaultAction);\n            Assert.NotNull(initialPolicy.Egress);\n            Assert.Contains(\n                initialPolicy.Egress!,\n                rule => rule.Target == \"pypi.org\" && rule.Action == NetworkRuleAction.Allow);\n\n            var blocked = await policySandbox.Commands.RunAsync(\"curl -I https://www.github.com\");\n            Assert.NotNull(blocked.Error);\n\n            var allowed = await policySandbox.Commands.RunAsync(\"curl -I https://pypi.org\");\n            Assert.Null(allowed.Error);\n\n            await policySandbox.PatchEgressRulesAsync(new List<NetworkRule>\n            {\n                new() { Action = NetworkRuleAction.Allow, Target = \"www.github.com\" },\n                new() { Action = NetworkRuleAction.Deny, Target = \"pypi.org\" }\n            });\n            await Task.Delay(2000);\n\n            var patchedPolicy = await policySandbox.GetEgressPolicyAsync();\n            Assert.NotNull(patchedPolicy.Egress);\n            Assert.Contains(\n                patchedPolicy.Egress!,\n                rule => rule.Target == \"www.github.com\" && rule.Action == NetworkRuleAction.Allow);\n            Assert.Contains(\n                patchedPolicy.Egress!,\n                rule => rule.Target == \"pypi.org\" && rule.Action == NetworkRuleAction.Deny);\n        }\n        finally\n        {\n            try\n            {\n                await policySandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await policySandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_HostVolumeMount()\n    {\n        var hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n        var containerMountPath = \"/mnt/host-data\";\n        var volumeSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"test-host-vol\",\n                    Host = new Host { Path = hostDir },\n                    MountPath = containerMountPath,\n                    ReadOnly = false\n                }\n            }\n        });\n\n        try\n        {\n            var marker = await volumeSandbox.Commands.RunAsync($\"cat {containerMountPath}/marker.txt\");\n            Assert.Null(marker.Error);\n            Assert.Single(marker.Logs.Stdout);\n            Assert.Equal(\"opensandbox-e2e-marker\", marker.Logs.Stdout[0].Text);\n\n            var write = await volumeSandbox.Commands.RunAsync(\n                $\"echo 'written-from-sandbox' > {containerMountPath}/sandbox-output.txt\");\n            Assert.Null(write.Error);\n\n            var readBack = await volumeSandbox.Commands.RunAsync($\"cat {containerMountPath}/sandbox-output.txt\");\n            Assert.Null(readBack.Error);\n            Assert.Single(readBack.Logs.Stdout);\n            Assert.Equal(\"written-from-sandbox\", readBack.Logs.Stdout[0].Text);\n        }\n        finally\n        {\n            try\n            {\n                await volumeSandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await volumeSandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_HostVolumeMount_ReadOnly()\n    {\n        var hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n        var containerMountPath = \"/mnt/host-data-ro\";\n        var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"test-host-vol-ro\",\n                    Host = new Host { Path = hostDir },\n                    MountPath = containerMountPath,\n                    ReadOnly = true\n                }\n            }\n        });\n\n        try\n        {\n            var marker = await roSandbox.Commands.RunAsync($\"cat {containerMountPath}/marker.txt\");\n            Assert.Null(marker.Error);\n            Assert.Single(marker.Logs.Stdout);\n            Assert.Equal(\"opensandbox-e2e-marker\", marker.Logs.Stdout[0].Text);\n\n            var write = await roSandbox.Commands.RunAsync($\"touch {containerMountPath}/should-fail.txt\");\n            Assert.NotNull(write.Error);\n        }\n        finally\n        {\n            try\n            {\n                await roSandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await roSandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_PvcVolumeMount()\n    {\n        var pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        var containerMountPath = \"/mnt/pvc-data\";\n        var pvcSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"test-pvc-vol\",\n                    Pvc = new PVC { ClaimName = pvcVolumeName },\n                    MountPath = containerMountPath,\n                    ReadOnly = false\n                }\n            }\n        });\n\n        try\n        {\n            var marker = await pvcSandbox.Commands.RunAsync($\"cat {containerMountPath}/marker.txt\");\n            Assert.Null(marker.Error);\n            Assert.Single(marker.Logs.Stdout);\n            Assert.Equal(\"pvc-marker-data\", marker.Logs.Stdout[0].Text);\n\n            var write = await pvcSandbox.Commands.RunAsync(\n                $\"echo 'written-to-pvc' > {containerMountPath}/pvc-output.txt\");\n            Assert.Null(write.Error);\n\n            var readBack = await pvcSandbox.Commands.RunAsync($\"cat {containerMountPath}/pvc-output.txt\");\n            Assert.Null(readBack.Error);\n            Assert.Single(readBack.Logs.Stdout);\n            Assert.Equal(\"written-to-pvc\", readBack.Logs.Stdout[0].Text);\n        }\n        finally\n        {\n            try\n            {\n                await pvcSandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await pvcSandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_PvcVolumeMount_ReadOnly()\n    {\n        var pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        var containerMountPath = \"/mnt/pvc-data-ro\";\n        var roSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"test-pvc-vol-ro\",\n                    Pvc = new PVC { ClaimName = pvcVolumeName },\n                    MountPath = containerMountPath,\n                    ReadOnly = true\n                }\n            }\n        });\n\n        try\n        {\n            var marker = await roSandbox.Commands.RunAsync($\"cat {containerMountPath}/marker.txt\");\n            Assert.Null(marker.Error);\n            Assert.Single(marker.Logs.Stdout);\n            Assert.Equal(\"pvc-marker-data\", marker.Logs.Stdout[0].Text);\n\n            var write = await roSandbox.Commands.RunAsync($\"touch {containerMountPath}/should-fail.txt\");\n            Assert.NotNull(write.Error);\n        }\n        finally\n        {\n            try\n            {\n                await roSandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await roSandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Sandbox_Create_With_PvcVolumeMount_SubPath()\n    {\n        var pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        var containerMountPath = \"/mnt/train\";\n        var subPathSandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _fixture.ConnectionConfig,\n            Image = _fixture.DefaultImage,\n            TimeoutSeconds = _fixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _fixture.DefaultReadyTimeoutSeconds,\n            Volumes = new[]\n            {\n                new Volume\n                {\n                    Name = \"test-pvc-subpath\",\n                    Pvc = new PVC { ClaimName = pvcVolumeName },\n                    MountPath = containerMountPath,\n                    ReadOnly = false,\n                    SubPath = \"datasets/train\"\n                }\n            }\n        });\n\n        try\n        {\n            var marker = await subPathSandbox.Commands.RunAsync($\"cat {containerMountPath}/marker.txt\");\n            Assert.Null(marker.Error);\n            Assert.Single(marker.Logs.Stdout);\n            Assert.Equal(\"pvc-subpath-marker\", marker.Logs.Stdout[0].Text);\n\n            var ls = await subPathSandbox.Commands.RunAsync($\"ls {containerMountPath}/\");\n            Assert.Null(ls.Error);\n            var lsText = string.Join(\"\\n\", ls.Logs.Stdout.Select(x => x.Text));\n            Assert.Contains(\"marker.txt\", lsText, StringComparison.Ordinal);\n            Assert.DoesNotContain(\"datasets\", lsText, StringComparison.Ordinal);\n\n            var write = await subPathSandbox.Commands.RunAsync(\n                $\"echo 'subpath-write-test' > {containerMountPath}/output.txt\");\n            Assert.Null(write.Error);\n\n            var readBack = await subPathSandbox.Commands.RunAsync($\"cat {containerMountPath}/output.txt\");\n            Assert.Null(readBack.Error);\n            Assert.Single(readBack.Logs.Stdout);\n            Assert.Equal(\"subpath-write-test\", readBack.Logs.Stdout[0].Text);\n        }\n        finally\n        {\n            try\n            {\n                await subPathSandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await subPathSandbox.DisposeAsync();\n        }\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Command_Execution_Success_Cwd_Background_Failure()\n    {\n        var sandbox = _fixture.Sandbox;\n\n        var stdoutMessages = new ConcurrentBag<OutputMessage>();\n        var stderrMessages = new ConcurrentBag<OutputMessage>();\n        var results = new ConcurrentBag<ExecutionResult>();\n        var errors = new ConcurrentBag<ExecutionError>();\n        var completedEvents = new ConcurrentBag<ExecutionComplete>();\n        var initEvents = new ConcurrentBag<ExecutionInit>();\n\n        var handlers = new ExecutionHandlers\n        {\n            OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; },\n            OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; },\n            OnResult = res => { results.Add(res); return Task.CompletedTask; },\n            OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },\n            OnError = err => { errors.Add(err); return Task.CompletedTask; },\n            OnInit = init => { initEvents.Add(init); return Task.CompletedTask; }\n        };\n\n        var echoResult = await sandbox.Commands.RunAsync(\"echo Hello OpenSandbox E2E\", handlers: handlers);\n        Assert.False(string.IsNullOrWhiteSpace(echoResult.Id));\n        Assert.Null(echoResult.Error);\n        Assert.Single(echoResult.Logs.Stdout);\n        Assert.Equal(\"Hello OpenSandbox E2E\", echoResult.Logs.Stdout[0].Text);\n        AssertRecentTimestampMs(echoResult.Logs.Stdout[0].Timestamp, 60_000);\n        AssertTerminalEventContract(initEvents, completedEvents, errors, echoResult.Id!);\n\n        var pwdResult = await sandbox.Commands.RunAsync(\n            \"pwd\",\n            options: new RunCommandOptions { WorkingDirectory = \"/tmp\" });\n        Assert.Null(pwdResult.Error);\n        Assert.Single(pwdResult.Logs.Stdout);\n        Assert.Equal(\"/tmp\", pwdResult.Logs.Stdout[0].Text);\n\n        var start = DateTime.UtcNow;\n        await sandbox.Commands.RunAsync(\n            \"sleep 30\",\n            options: new RunCommandOptions { Background = true });\n        var elapsed = DateTime.UtcNow - start;\n        Assert.True(elapsed.TotalSeconds < 10, \"Background command should return quickly.\");\n\n        stdoutMessages = new ConcurrentBag<OutputMessage>();\n        stderrMessages = new ConcurrentBag<OutputMessage>();\n        errors = new ConcurrentBag<ExecutionError>();\n        completedEvents = new ConcurrentBag<ExecutionComplete>();\n        initEvents = new ConcurrentBag<ExecutionInit>();\n\n        var failResult = await sandbox.Commands.RunAsync(\n            \"nonexistent-command-that-does-not-exist\",\n            handlers: new ExecutionHandlers\n            {\n                OnStdout = msg => { stdoutMessages.Add(msg); return Task.CompletedTask; },\n                OnStderr = msg => { stderrMessages.Add(msg); return Task.CompletedTask; },\n                OnError = err => { errors.Add(err); return Task.CompletedTask; },\n                OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },\n                OnInit = init => { initEvents.Add(init); return Task.CompletedTask; }\n            });\n\n        Assert.NotNull(failResult.Error);\n        Assert.Equal(\"CommandExecError\", failResult.Error!.Name);\n        Assert.True(failResult.Logs.Stderr.Count > 0);\n        Assert.Contains(\n            failResult.Logs.Stderr,\n            msg => msg.Text.Contains(\"nonexistent-command-that-does-not-exist\", StringComparison.Ordinal));\n        AssertTerminalEventContract(initEvents, completedEvents, errors, failResult.Id!);\n        Assert.Empty(completedEvents);\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Command_Status_And_Background_Logs()\n    {\n        var sandbox = _fixture.Sandbox;\n\n        var execResult = await sandbox.Commands.RunAsync(\n            \"sh -c 'echo log-line-1; echo log-line-2; sleep 2'\",\n            options: new RunCommandOptions { Background = true });\n        Assert.False(string.IsNullOrWhiteSpace(execResult.Id));\n        var commandId = execResult.Id!;\n\n        var status = await sandbox.Commands.GetCommandStatusAsync(commandId);\n        Assert.Equal(commandId, status.Id);\n        Assert.NotNull(status.Running);\n\n        var logsText = new StringBuilder();\n        long? cursor = null;\n        for (var i = 0; i < 20; i++)\n        {\n            var logs = await sandbox.Commands.GetBackgroundCommandLogsAsync(commandId, cursor);\n            logsText.Append(logs.Content);\n            cursor = logs.Cursor ?? cursor;\n            if (logsText.ToString().Contains(\"log-line-2\", StringComparison.Ordinal))\n            {\n                break;\n            }\n\n            await Task.Delay(1000);\n        }\n\n        var finalLogs = logsText.ToString();\n        Assert.Contains(\"log-line-1\", finalLogs, StringComparison.Ordinal);\n        Assert.Contains(\"log-line-2\", finalLogs, StringComparison.Ordinal);\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Command_Env_Injection()\n    {\n        var sandbox = _fixture.Sandbox;\n        var envKey = \"OPEN_SANDBOX_E2E_CMD_ENV\";\n        var envValue = $\"env-ok-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\";\n        var probeCommand =\n            $\"sh -c 'if [ -z \\\"${{{envKey}:-}}\\\" ]; then echo \\\"__EMPTY__\\\"; else echo \\\"${{{envKey}}}\\\"; fi'\";\n\n        var baseline = await sandbox.Commands.RunAsync(probeCommand);\n        Assert.Null(baseline.Error);\n        var baselineOutput = string.Join(\"\\n\", baseline.Logs.Stdout.Select(m => m.Text)).Trim();\n        Assert.Equal(\"__EMPTY__\", baselineOutput);\n\n        var injected = await sandbox.Commands.RunAsync(\n            probeCommand,\n            options: new RunCommandOptions\n            {\n                Envs = new Dictionary<string, string>\n                {\n                    [envKey] = envValue,\n                    [\"OPEN_SANDBOX_E2E_SECOND_ENV\"] = \"second-ok\"\n                }\n            });\n        Assert.Null(injected.Error);\n        var injectedOutput = string.Join(\"\\n\", injected.Logs.Stdout.Select(m => m.Text)).Trim();\n        Assert.Equal(envValue, injectedOutput);\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Filesystem_Operations_CRUD_Replace_Move_Delete()\n    {\n        var sandbox = _fixture.Sandbox;\n\n        var testDir1 = $\"/tmp/fs_test1_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\";\n        var testDir2 = $\"/tmp/fs_test2_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\";\n\n        await sandbox.Files.CreateDirectoriesAsync(new[]\n        {\n            new CreateDirectoryEntry { Path = testDir1, Mode = 755 },\n            new CreateDirectoryEntry { Path = testDir2, Mode = 644 }\n        });\n\n        var dirInfo = await sandbox.Files.GetFileInfoAsync(new[] { testDir1, testDir2 });\n        Assert.Equal(testDir1, dirInfo[testDir1].Path);\n        Assert.Equal(755, dirInfo[testDir1].Mode);\n        AssertTimesClose(dirInfo[testDir1].CreatedAt, dirInfo[testDir1].ModifiedAt, 2);\n\n        var testFile1 = $\"{testDir1}/test_file1.txt\";\n        var testFile2 = $\"{testDir1}/test_file2.txt\";\n        var testFile3 = $\"{testDir1}/test_file3.txt\";\n        var testContent = \"Hello Filesystem! Line 2. Line 3.\";\n\n        await sandbox.Files.WriteFilesAsync(new[]\n        {\n            new WriteEntry { Path = testFile1, Data = testContent, Mode = 644 },\n            new WriteEntry { Path = testFile2, Data = Encoding.UTF8.GetBytes(testContent), Mode = 755 },\n            new WriteEntry { Path = testFile3, Data = new MemoryStream(Encoding.UTF8.GetBytes(testContent)), Mode = 755 }\n        });\n\n        var readContent1 = await sandbox.Files.ReadFileAsync(\n            testFile1,\n            new ReadFileOptions { Encoding = \"utf-8\" });\n        var readContent1Partial = await sandbox.Files.ReadFileAsync(\n            testFile1,\n            new ReadFileOptions { Encoding = \"utf-8\", Range = \"bytes=0-9\" });\n        var readBytes2 = await sandbox.Files.ReadBytesAsync(testFile2);\n        var readContent2 = Encoding.UTF8.GetString(readBytes2);\n\n        var chunks = new List<byte>();\n        await foreach (var chunk in sandbox.Files.ReadBytesStreamAsync(testFile3))\n        {\n            chunks.AddRange(chunk);\n        }\n\n        var readContent3 = Encoding.UTF8.GetString(chunks.ToArray());\n\n        Assert.Equal(testContent, readContent1);\n        Assert.Equal(testContent, readContent2);\n        Assert.Equal(testContent, readContent3);\n        Assert.Equal(testContent.Substring(0, 10), readContent1Partial);\n\n        var fileInfoMap = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2, testFile3 });\n        var expectedSize = Encoding.UTF8.GetBytes(testContent).Length;\n        Assert.Equal(expectedSize, fileInfoMap[testFile1].Size);\n        Assert.Equal(expectedSize, fileInfoMap[testFile2].Size);\n        Assert.Equal(expectedSize, fileInfoMap[testFile3].Size);\n        AssertTimesClose(fileInfoMap[testFile1].CreatedAt, fileInfoMap[testFile1].ModifiedAt, 2);\n\n        var found = new HashSet<string>();\n        var searchResults = await sandbox.Files.SearchAsync(new SearchEntry { Path = testDir1, Pattern = \"*\" });\n        foreach (var entry in searchResults)\n        {\n            found.Add(entry.Path);\n        }\n        Assert.Equal(new HashSet<string> { testFile1, testFile2, testFile3 }, found);\n\n        await sandbox.Files.SetPermissionsAsync(new[]\n        {\n            new SetPermissionEntry { Path = testFile1, Mode = 755 },\n            new SetPermissionEntry { Path = testFile2, Mode = 600 }\n        });\n\n        var updatedInfo = await sandbox.Files.GetFileInfoAsync(new[] { testFile1, testFile2 });\n        Assert.Equal(755, updatedInfo[testFile1].Mode);\n        Assert.Equal(600, updatedInfo[testFile2].Mode);\n\n        var beforeUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1];\n        var updatedContent1 = testContent + \" Appended line.\";\n        await Task.Delay(50);\n        await sandbox.Files.WriteFilesAsync(new[]\n        {\n            new WriteEntry { Path = testFile1, Data = updatedContent1, Mode = 644 }\n        });\n\n        var newContent1 = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = \"utf-8\" });\n        Assert.Equal(updatedContent1, newContent1);\n        var afterUpdate = (await sandbox.Files.GetFileInfoAsync(new[] { testFile1 }))[testFile1];\n        AssertModifiedUpdated(beforeUpdate.ModifiedAt, afterUpdate.ModifiedAt, 1, 1000);\n\n        await Task.Delay(50);\n        await sandbox.Files.ReplaceContentsAsync(new[]\n        {\n            new ContentReplaceEntry\n            {\n                Path = testFile1,\n                OldContent = \"Appended line.\",\n                NewContent = \"Replaced line.\"\n            }\n        });\n\n        var replaced = await sandbox.Files.ReadFileAsync(testFile1, new ReadFileOptions { Encoding = \"utf-8\" });\n        Assert.Contains(\"Replaced line.\", replaced, StringComparison.Ordinal);\n        Assert.DoesNotContain(\"Appended line.\", replaced, StringComparison.Ordinal);\n\n        var movedPath = $\"{testDir2}/moved_file3.txt\";\n        await sandbox.Files.MoveFilesAsync(new[] { new MoveEntry { Src = testFile3, Dest = movedPath } });\n        var movedBytes = await sandbox.Files.ReadBytesAsync(movedPath);\n        Assert.Equal(testContent, Encoding.UTF8.GetString(movedBytes));\n        await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadBytesAsync(testFile3));\n\n        await sandbox.Files.DeleteFilesAsync(new[] { testFile2 });\n        await Assert.ThrowsAnyAsync<Exception>(() => sandbox.Files.ReadFileAsync(testFile2));\n\n        await sandbox.Files.DeleteDirectoriesAsync(new[] { testDir1, testDir2 });\n        var verify = await sandbox.Commands.RunAsync(\n            $\"test ! -d {testDir1} && test ! -d {testDir2} && echo OK\",\n            options: new RunCommandOptions { WorkingDirectory = \"/tmp\" });\n        Assert.Null(verify.Error);\n        Assert.Single(verify.Logs.Stdout);\n        Assert.Equal(\"OK\", verify.Logs.Stdout[0].Text);\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Command_Interrupt()\n    {\n        var sandbox = _fixture.Sandbox;\n\n        var initEvents = new ConcurrentBag<ExecutionInit>();\n        var completedEvents = new ConcurrentBag<ExecutionComplete>();\n        var errors = new ConcurrentBag<ExecutionError>();\n        var initLatch = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);\n\n        var handlers = new ExecutionHandlers\n        {\n            OnInit = init =>\n            {\n                initEvents.Add(init);\n                initLatch.TrySetResult(init.Id);\n                return Task.CompletedTask;\n            },\n            OnExecutionComplete = complete => { completedEvents.Add(complete); return Task.CompletedTask; },\n            OnError = err => { errors.Add(err); return Task.CompletedTask; }\n        };\n\n        var executionTask = sandbox.Commands.RunAsync(\"sleep 30\", handlers: handlers);\n        var id = await initLatch.Task.WaitAsync(TimeSpan.FromSeconds(15));\n\n        await Task.Delay(2000);\n        await sandbox.Commands.InterruptAsync(id);\n\n        var result = await executionTask.WaitAsync(TimeSpan.FromSeconds(30));\n        Assert.Equal(id, result.Id);\n        Assert.True((completedEvents.Count > 0) ^ (errors.Count > 0));\n        Assert.True(result.Error != null || result.Logs.Stderr.Count > 0);\n    }\n\n    [Fact(Timeout = 5 * 60 * 1000)]\n    public async Task Sandbox_Pause_And_Resume()\n    {\n        var sandbox = _fixture.Sandbox;\n\n        await Task.Delay(5000);\n        await sandbox.PauseAsync();\n\n        var pausedInfo = await WaitForStateAsync(sandbox, SandboxStates.Paused, TimeSpan.FromMinutes(5));\n        Assert.Equal(SandboxStates.Paused, pausedInfo.Status.State);\n\n        var healthy = true;\n        for (var i = 0; i < 10; i++)\n        {\n            healthy = await sandbox.IsHealthyAsync();\n            if (!healthy)\n            {\n                break;\n            }\n            await Task.Delay(500);\n        }\n        Assert.False(healthy, \"Sandbox should be unhealthy after pause.\");\n\n        var resumed = await sandbox.ResumeAsync(new SandboxResumeOptions\n        {\n            ReadyTimeoutSeconds = 60,\n            HealthCheckPollingInterval = 1000\n        });\n\n        var resumedInfo = await WaitForStateAsync(resumed, SandboxStates.Running, TimeSpan.FromMinutes(3));\n        Assert.Equal(SandboxStates.Running, resumedInfo.Status.State);\n\n        var isHealthy = false;\n        for (var i = 0; i < 30; i++)\n        {\n            isHealthy = await resumed.IsHealthyAsync();\n            if (isHealthy)\n            {\n                break;\n            }\n            await Task.Delay(1000);\n        }\n        Assert.True(isHealthy, \"Sandbox should be healthy after resume.\");\n\n        // Smoke-check command path after resume to ensure execd adapter is usable.\n        var echo = await resumed.Commands.RunAsync(\"echo resume-ok\");\n        Assert.Null(echo.Error);\n        Assert.Single(echo.Logs.Stdout);\n        Assert.Equal(\"resume-ok\", echo.Logs.Stdout[0].Text);\n    }\n\n    private static void AssertRecentTimestampMs(long ts, long toleranceMs)\n    {\n        Assert.True(ts > 0);\n        var delta = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts);\n        Assert.True(delta <= toleranceMs, $\"timestamp too far from now: delta={delta}ms (ts={ts})\");\n    }\n\n    private static void AssertEndpointHasPort(string endpoint, int expectedPort)\n    {\n        Assert.False(endpoint.Contains(\"://\", StringComparison.Ordinal), $\"unexpected scheme in endpoint: {endpoint}\");\n        if (endpoint.Contains('/'))\n        {\n            Assert.EndsWith($\"/{expectedPort}\", endpoint, StringComparison.Ordinal);\n            Assert.False(string.IsNullOrWhiteSpace(endpoint.Split('/', 2)[0]));\n            return;\n        }\n\n        var parts = endpoint.Split(':');\n        Assert.True(parts.Length >= 2, $\"missing host:port in endpoint: {endpoint}\");\n        var port = parts[^1];\n        Assert.True(int.TryParse(port, out var parsed));\n        Assert.Equal(expectedPort, parsed);\n    }\n\n    private static void AssertTimesClose(DateTime? createdAt, DateTime? modifiedAt, double toleranceSeconds)\n    {\n        Assert.NotNull(createdAt);\n        Assert.NotNull(modifiedAt);\n        var delta = Math.Abs((modifiedAt!.Value - createdAt!.Value).TotalSeconds);\n        Assert.True(delta <= toleranceSeconds, $\"created/modified skew too large: {delta}s\");\n    }\n\n    private static void AssertModifiedUpdated(DateTime? before, DateTime? after, int minDeltaMs, int allowSkewMs)\n    {\n        Assert.NotNull(before);\n        Assert.NotNull(after);\n        var deltaMs = (after!.Value - before!.Value).TotalMilliseconds;\n        Assert.True(deltaMs >= minDeltaMs - allowSkewMs, $\"modified_at did not update as expected: delta_ms={deltaMs}\");\n    }\n\n    private static void AssertTerminalEventContract(\n        IEnumerable<ExecutionInit> initEvents,\n        IEnumerable<ExecutionComplete> completedEvents,\n        IEnumerable<ExecutionError> errors,\n        string executionId)\n    {\n        var initList = initEvents.ToList();\n        var completeList = completedEvents.ToList();\n        var errorList = errors.ToList();\n\n        Assert.Single(initList);\n        Assert.False(string.IsNullOrWhiteSpace(initList[0].Id));\n        Assert.Equal(executionId, initList[0].Id);\n        AssertRecentTimestampMs(initList[0].Timestamp, 120_000);\n\n        var hasComplete = completeList.Count > 0;\n        var hasError = errorList.Count > 0;\n        Assert.True(hasComplete || hasError);\n\n        if (hasComplete)\n        {\n            Assert.Single(completeList);\n            AssertRecentTimestampMs(completeList[0].Timestamp, 180_000);\n            Assert.True(completeList[0].ExecutionTimeMs >= 0);\n        }\n\n        if (hasError)\n        {\n            Assert.False(string.IsNullOrWhiteSpace(errorList[0].Name));\n            Assert.False(string.IsNullOrWhiteSpace(errorList[0].Value));\n            AssertRecentTimestampMs(errorList[0].Timestamp, 180_000);\n        }\n    }\n\n    private static async Task<SandboxInfo> WaitForStateAsync(\n        Sandbox sandbox,\n        string expectedState,\n        TimeSpan timeout)\n    {\n        var deadline = DateTime.UtcNow + timeout;\n        SandboxInfo info;\n        while (true)\n        {\n            info = await sandbox.GetInfoAsync();\n            if (info.Status.State == expectedState)\n            {\n                return info;\n            }\n\n            if (DateTime.UtcNow > deadline)\n            {\n                throw new TimeoutException($\"Timed out waiting for state={expectedState}, last_state={info.Status.State}\");\n            }\n\n            await Task.Delay(1000);\n        }\n    }\n}\n\npublic sealed class SandboxE2ETestFixture : IAsyncLifetime\n{\n    private readonly E2ETestFixture _baseFixture = new();\n    private Sandbox? _sandbox;\n\n    public ConnectionConfig ConnectionConfig => _baseFixture.ConnectionConfig;\n    public ConnectionConfig ServerProxyConnectionConfig => _baseFixture.ServerProxyConnectionConfig;\n    public string DefaultImage => _baseFixture.DefaultImage;\n    public int DefaultTimeoutSeconds => _baseFixture.DefaultTimeoutSeconds;\n    public int DefaultReadyTimeoutSeconds => _baseFixture.DefaultReadyTimeoutSeconds;\n    public Sandbox Sandbox => _sandbox ?? throw new InvalidOperationException(\"Sandbox is not initialized.\");\n\n    public async Task InitializeAsync()\n    {\n        _sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _baseFixture.ConnectionConfig,\n            Image = _baseFixture.DefaultImage,\n            TimeoutSeconds = _baseFixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _baseFixture.DefaultReadyTimeoutSeconds,\n            Metadata = new Dictionary<string, string> { [\"tag\"] = \"csharp-e2e-test\" },\n            Env = new Dictionary<string, string> { [\"E2E_TEST\"] = \"true\" },\n            HealthCheckPollingInterval = 500\n        });\n    }\n\n    public async Task DisposeAsync()\n    {\n        if (_sandbox == null)\n        {\n            return;\n        }\n\n        try\n        {\n            await _sandbox.KillAsync();\n        }\n        catch\n        {\n        }\n\n        await _sandbox.DisposeAsync();\n    }\n}\n"
  },
  {
    "path": "tests/csharp/OpenSandbox.E2ETests/SandboxManagerE2ETests.cs",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nusing OpenSandbox.Models;\nusing Xunit;\n\nnamespace OpenSandbox.E2ETests;\n\n[Collection(\"CSharp E2E Tests\")]\npublic class SandboxManagerE2ETests : IClassFixture<SandboxManagerE2ETestFixture>\n{\n    private readonly SandboxManagerE2ETestFixture _fixture;\n\n    public SandboxManagerE2ETests(SandboxManagerE2ETestFixture fixture)\n    {\n        _fixture = fixture;\n    }\n\n    [Fact(Timeout = 10 * 60 * 1000)]\n    public async Task ListSandboxInfos_StatesFilter_IsOrLogic()\n    {\n        var manager = _fixture.Manager;\n        var tag = _fixture.Tag;\n        var s1 = _fixture.S1;\n        var s2 = _fixture.S2;\n        var s3 = _fixture.S3;\n\n        var result = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            States = new[] { SandboxStates.Running, SandboxStates.Paused },\n            Metadata = new Dictionary<string, string> { [\"tag\"] = tag },\n            PageSize = 50\n        });\n\n        var ids = result.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s1.Id, ids);\n        Assert.Contains(s2.Id, ids);\n        Assert.Contains(s3.Id, ids);\n\n        var pausedOnly = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            States = new[] { SandboxStates.Paused },\n            Metadata = new Dictionary<string, string> { [\"tag\"] = tag },\n            PageSize = 50\n        });\n\n        var pausedIds = pausedOnly.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s3.Id, pausedIds);\n        Assert.DoesNotContain(s1.Id, pausedIds);\n        Assert.DoesNotContain(s2.Id, pausedIds);\n\n        var runningOnly = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            States = new[] { SandboxStates.Running },\n            Metadata = new Dictionary<string, string> { [\"tag\"] = tag },\n            PageSize = 50\n        });\n\n        var runningIds = runningOnly.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s1.Id, runningIds);\n        Assert.Contains(s2.Id, runningIds);\n        Assert.DoesNotContain(s3.Id, runningIds);\n    }\n\n    [Fact(Timeout = 10 * 60 * 1000)]\n    public async Task ListSandboxInfos_MetadataFilter_IsAndLogic()\n    {\n        var manager = _fixture.Manager;\n        var tag = _fixture.Tag;\n        var s1 = _fixture.S1;\n        var s2 = _fixture.S2;\n        var s3 = _fixture.S3;\n\n        var tagAndTeam = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            Metadata = new Dictionary<string, string> { [\"tag\"] = tag, [\"team\"] = \"t1\" },\n            PageSize = 50\n        });\n\n        var tagAndTeamIds = tagAndTeam.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s1.Id, tagAndTeamIds);\n        Assert.Contains(s2.Id, tagAndTeamIds);\n        Assert.DoesNotContain(s3.Id, tagAndTeamIds);\n\n        var tagTeamEnv = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            Metadata = new Dictionary<string, string>\n            {\n                [\"tag\"] = tag,\n                [\"team\"] = \"t1\",\n                [\"env\"] = \"prod\"\n            },\n            PageSize = 50\n        });\n\n        var tagTeamEnvIds = tagTeamEnv.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s1.Id, tagTeamEnvIds);\n        Assert.DoesNotContain(s2.Id, tagTeamEnvIds);\n        Assert.DoesNotContain(s3.Id, tagTeamEnvIds);\n\n        var tagEnv = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            Metadata = new Dictionary<string, string>\n            {\n                [\"tag\"] = tag,\n                [\"env\"] = \"prod\"\n            },\n            PageSize = 50\n        });\n\n        var tagEnvIds = tagEnv.Items.Select(info => info.Id).ToHashSet();\n        Assert.Contains(s1.Id, tagEnvIds);\n        Assert.Contains(s3.Id, tagEnvIds);\n        Assert.DoesNotContain(s2.Id, tagEnvIds);\n\n        var noneMatch = await manager.ListSandboxInfosAsync(new SandboxFilter\n        {\n            Metadata = new Dictionary<string, string>\n            {\n                [\"tag\"] = tag,\n                [\"team\"] = \"t2\"\n            },\n            PageSize = 50\n        });\n        var createdIds = new HashSet<string> { s1.Id, s2.Id, s3.Id };\n        Assert.DoesNotContain(noneMatch.Items, info => createdIds.Contains(info.Id));\n    }\n\n    [Fact(Timeout = 2 * 60 * 1000)]\n    public async Task Manager_InvalidSandboxOperations_ShouldFail()\n    {\n        var manager = _fixture.Manager;\n        var fakeId = $\"sandbox-not-exist-{Guid.NewGuid():N}\";\n\n        await Assert.ThrowsAnyAsync<Exception>(() => manager.GetSandboxInfoAsync(fakeId));\n        await Assert.ThrowsAnyAsync<Exception>(() => manager.PauseSandboxAsync(fakeId));\n        await Assert.ThrowsAnyAsync<Exception>(() => manager.ResumeSandboxAsync(fakeId));\n        await Assert.ThrowsAnyAsync<Exception>(() => manager.KillSandboxAsync(fakeId));\n        await Assert.ThrowsAnyAsync<Exception>(() => manager.RenewSandboxAsync(fakeId, 300));\n    }\n}\n\npublic sealed class SandboxManagerE2ETestFixture : IAsyncLifetime\n{\n    private readonly E2ETestFixture _baseFixture = new();\n    private SandboxManager? _manager;\n    private Sandbox? _s1;\n    private Sandbox? _s2;\n    private Sandbox? _s3;\n    private string? _tag;\n\n    public SandboxManager Manager => _manager ?? throw new InvalidOperationException(\"Manager is not initialized.\");\n    public Sandbox S1 => _s1 ?? throw new InvalidOperationException(\"S1 is not initialized.\");\n    public Sandbox S2 => _s2 ?? throw new InvalidOperationException(\"S2 is not initialized.\");\n    public Sandbox S3 => _s3 ?? throw new InvalidOperationException(\"S3 is not initialized.\");\n    public string Tag => _tag ?? throw new InvalidOperationException(\"Tag is not initialized.\");\n\n    public async Task InitializeAsync()\n    {\n        _manager = SandboxManager.Create(new SandboxManagerOptions\n        {\n            ConnectionConfig = _baseFixture.ConnectionConfig\n        });\n\n        _tag = $\"csharp-manager-{Guid.NewGuid():N}\"[..20];\n\n        _s1 = await CreateSandboxAsync(new Dictionary<string, string>\n        {\n            [\"tag\"] = _tag,\n            [\"team\"] = \"t1\",\n            [\"env\"] = \"prod\"\n        });\n\n        _s2 = await CreateSandboxAsync(new Dictionary<string, string>\n        {\n            [\"tag\"] = _tag,\n            [\"team\"] = \"t1\",\n            [\"env\"] = \"dev\"\n        });\n\n        _s3 = await CreateSandboxAsync(new Dictionary<string, string>\n        {\n            [\"tag\"] = _tag,\n            [\"env\"] = \"prod\"\n        });\n\n        await _manager.PauseSandboxAsync(_s3.Id);\n        await WaitForStateAsync(_s3.Id, SandboxStates.Paused, TimeSpan.FromMinutes(3));\n    }\n\n    public async Task DisposeAsync()\n    {\n        foreach (var sandbox in new[] { _s1, _s2, _s3 })\n        {\n            if (sandbox == null)\n            {\n                continue;\n            }\n\n            try\n            {\n                await sandbox.KillAsync();\n            }\n            catch\n            {\n            }\n\n            await sandbox.DisposeAsync();\n        }\n\n        if (_manager != null)\n        {\n            await _manager.DisposeAsync();\n        }\n    }\n\n    private async Task<Sandbox> CreateSandboxAsync(IReadOnlyDictionary<string, string> metadata)\n    {\n        return await Sandbox.CreateAsync(new SandboxCreateOptions\n        {\n            ConnectionConfig = _baseFixture.ConnectionConfig,\n            Image = _baseFixture.DefaultImage,\n            TimeoutSeconds = _baseFixture.DefaultTimeoutSeconds,\n            ReadyTimeoutSeconds = _baseFixture.DefaultReadyTimeoutSeconds,\n            Metadata = metadata,\n            Env = new Dictionary<string, string> { [\"E2E_TEST\"] = \"true\" },\n            HealthCheckPollingInterval = 500\n        });\n    }\n\n    private async Task WaitForStateAsync(string sandboxId, string expectedState, TimeSpan timeout)\n    {\n        var deadline = DateTime.UtcNow + timeout;\n        while (true)\n        {\n            var info = await Manager.GetSandboxInfoAsync(sandboxId);\n            if (info.Status.State == expectedState)\n            {\n                return;\n            }\n\n            if (DateTime.UtcNow > deadline)\n            {\n                throw new TimeoutException($\"Timed out waiting for state={expectedState}, last_state={info.Status.State}\");\n            }\n\n            await Task.Delay(1000);\n        }\n    }\n}\n"
  },
  {
    "path": "tests/java/build.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    java\n    alias(libs.plugins.spotless)\n}\n\ngroup = \"com.alibaba.opensandbox\"\nversion = \"1.0.0\"\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nrepositories {\n    mavenLocal()\n    exclusiveContent {\n        forRepository {\n            mavenLocal()\n        }\n        filter {\n            includeGroup(\"com.alibaba.opensandbox\")\n        }\n    }\n    mavenCentral()\n}\n\nconfigurations.configureEach {\n    resolutionStrategy.cacheDynamicVersionsFor(0, \"seconds\")\n    resolutionStrategy.cacheChangingModulesFor(0, \"seconds\")\n}\n\ndependencies {\n    // OpenSandbox Kotlin SDKs\n    testImplementation(\"com.alibaba.opensandbox:sandbox:latest.integration\")\n    testImplementation(\"com.alibaba.opensandbox:code-interpreter:latest.integration\")\n\n    // Test frameworks\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.9.2\")\n    testRuntimeOnly(\"org.junit.platform:junit-platform-launcher:1.13.4\")\n}\n\ntasks.withType<Test> {\n    useJUnitPlatform()\n}\n\ntasks.register<Test>(\"e2eTest\") {\n    description = \"Runs end-to-end tests.\"\n    group = \"verification\"\n\n    useJUnitPlatform {\n        includeTags(\"e2e\")\n    }\n}\n\nspotless {\n    java {\n        googleJavaFormat(\"1.19.2\").aosp()\n        removeUnusedImports()\n        trimTrailingWhitespace()\n        endWithNewline()\n    }\n}\n"
  },
  {
    "path": "tests/java/gradle/libs.versions.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[versions]\nspotless = \"6.23.3\"\ngoogle-java-format = \"1.19.2\"\n\n[plugins]\nspotless = { id = \"com.diffplug.spotless\", version.ref = \"spotless\" }\n"
  },
  {
    "path": "tests/java/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.2.1-all.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "tests/java/gradle.properties",
    "content": "org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m\norg.gradle.parallel=true\norg.gradle.caching=true\n"
  },
  {
    "path": "tests/java/gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "tests/java/settings.gradle.kts",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nrootProject.name = \"opensandbox-java-e2e-tests\"\n"
  },
  {
    "path": "tests/java/src/test/java/com/alibaba/opensandbox/e2e/BaseE2ETest.java",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.e2e;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.time.Duration;\nimport java.time.OffsetDateTime;\nimport java.util.*;\nimport org.junit.jupiter.api.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/** Base class for all E2E tests providing common setup and teardown functionality. */\n@TestInstance(TestInstance.Lifecycle.PER_CLASS)\npublic abstract class BaseE2ETest {\n\n    protected static final Logger logger = LoggerFactory.getLogger(BaseE2ETest.class);\n\n    // ==========================================\n    // Configuration Keys\n    // ==========================================\n    private static final String PROP_API_KEY = \"opensandbox.test.api.key\";\n    private static final String PROP_DOMAIN = \"opensandbox.test.domain\";\n    private static final String PROP_PROTOCOL = \"opensandbox.test.protocol\";\n    private static final String PROP_IMG_DEFAULT = \"opensandbox.sandbox.default.image\";\n\n    // ==========================================\n    // Shared State (Static)\n    // ==========================================\n    protected static final Properties testProperties = new Properties();\n    protected static ConnectionConfig sharedConnectionConfig;\n\n    static {\n        loadTestProperties();\n        initializeSharedConfig();\n    }\n\n    protected static String getSandboxImage() {\n        return testProperties.getProperty(PROP_IMG_DEFAULT);\n    }\n\n    protected static ConnectionConfig createConnectionConfig(boolean useServerProxy) {\n        String protocol = testProperties.getProperty(PROP_PROTOCOL, \"https\");\n        return ConnectionConfig.builder()\n                .apiKey(testProperties.getProperty(PROP_API_KEY))\n                .domain(testProperties.getProperty(PROP_DOMAIN))\n                .requestTimeout(Duration.ofMinutes(1))\n                .protocol(protocol)\n                .useServerProxy(useServerProxy)\n                .build();\n    }\n\n    private static void loadTestProperties() {\n        try (InputStream input =\n                BaseE2ETest.class.getClassLoader().getResourceAsStream(\"test.properties\")) {\n            if (input != null) {\n                testProperties.load(input);\n            } else {\n                logger.warn(\"test.properties file not found, using default values.\");\n            }\n        } catch (IOException e) {\n            throw new RuntimeException(\"Failed to load test properties\", e);\n        }\n    }\n\n    private static void initializeSharedConfig() {\n        String protocol = testProperties.getProperty(PROP_PROTOCOL, \"https\");\n        sharedConnectionConfig =\n                ConnectionConfig.builder()\n                        .apiKey(testProperties.getProperty(PROP_API_KEY))\n                        .domain(testProperties.getProperty(PROP_DOMAIN))\n                        .requestTimeout(Duration.ofMinutes(1))\n                        .protocol(protocol)\n                        .build();\n    }\n\n    @BeforeEach\n    void beforeEach(TestInfo testInfo) {\n        logger.info(\"=== Starting test: {} ===\", testInfo.getDisplayName());\n    }\n\n    // ==========================================\n    // Shared assertion helpers (ported from python e2e style)\n    // ==========================================\n    protected static long nowMs() {\n        return System.currentTimeMillis();\n    }\n\n    protected static void assertRecentTimestampMs(long ts, long toleranceMs) {\n        assertTrue(ts > 0, \"timestamp must be > 0\");\n        long delta = Math.abs(nowMs() - ts);\n        assertTrue(\n                delta <= toleranceMs,\n                \"timestamp too far from now: delta=\" + delta + \"ms (ts=\" + ts + \")\");\n    }\n\n    protected static void assertEndpointHasPort(String endpoint, int expectedPort) {\n        assertNotNull(endpoint);\n        assertFalse(endpoint.contains(\"://\"), \"unexpected scheme in endpoint: \" + endpoint);\n        if (endpoint.contains(\"/\")) {\n            assertTrue(\n                    endpoint.endsWith(\"/\" + expectedPort),\n                    \"endpoint route must end with /\" + expectedPort + \": \" + endpoint);\n            String prefix = endpoint.split(\"/\", 2)[0];\n            assertFalse(prefix.isBlank(), \"missing domain in endpoint: \" + endpoint);\n            return;\n        }\n        int idx = endpoint.lastIndexOf(':');\n        assertTrue(idx > 0, \"missing host:port in endpoint: \" + endpoint);\n        String host = endpoint.substring(0, idx);\n        String port = endpoint.substring(idx + 1);\n        assertFalse(host.isBlank(), \"missing host in endpoint: \" + endpoint);\n        assertTrue(port.matches(\"\\\\d+\"), \"non-numeric port in endpoint: \" + endpoint);\n        assertEquals(expectedPort, Integer.parseInt(port), \"endpoint port mismatch: \" + endpoint);\n    }\n\n    protected static void assertTimesClose(\n            OffsetDateTime createdAt, OffsetDateTime modifiedAt, long toleranceSeconds) {\n        long delta = Math.abs(Duration.between(createdAt, modifiedAt).getSeconds());\n        assertTrue(delta <= toleranceSeconds, \"created/modified skew too large: \" + delta + \"s\");\n    }\n}\n"
  },
  {
    "path": "tests/java/src/test/java/com/alibaba/opensandbox/e2e/CodeInterpreterE2ETest.java",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.e2e;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport com.alibaba.opensandbox.codeinterpreter.CodeInterpreter;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.CodeContext;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.RunCodeRequest;\nimport com.alibaba.opensandbox.codeinterpreter.domain.models.execd.executions.SupportedLanguage;\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.*;\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.*;\nimport org.junit.jupiter.api.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * Comprehensive E2E tests for CodeInterpreter runCode functionality.\n *\n * <p>Tests code execution capabilities including: - Multi-language code execution (Java, Python,\n * Go, TypeScript) - Session state management and variable persistence - Context isolation between\n * different execution contexts - Error handling and recovery mechanisms - Event handling patterns\n * identical to runCommand\n *\n * <p>Uses the shared CodeInterpreter instance from BaseE2ETest.\n */\n@Tag(\"e2e\")\n@DisplayName(\"CodeInterpreter E2E Tests - RunCode Functionality\")\n@TestMethodOrder(MethodOrderer.OrderAnnotation.class)\npublic class CodeInterpreterE2ETest extends BaseE2ETest {\n\n    protected static final Logger logger = LoggerFactory.getLogger(CodeInterpreterE2ETest.class);\n\n    private Sandbox sandbox;\n    private CodeInterpreter codeInterpreter;\n\n    private static void assertTerminalEventContract(\n            List<ExecutionInit> initEvents,\n            List<ExecutionComplete> completedEvents,\n            List<ExecutionError> errors,\n            String executionId) {\n        assertEquals(1, initEvents.size(), \"init event must exist exactly once\");\n        assertNotNull(initEvents.get(0).getId());\n        assertFalse(initEvents.get(0).getId().isBlank());\n        assertEquals(executionId, initEvents.get(0).getId());\n        assertRecentTimestampMs(initEvents.get(0).getTimestamp(), 180_000);\n        assertTrue(\n                (!completedEvents.isEmpty()) || (!errors.isEmpty()),\n                \"expected at least one of complete/error\");\n        if (!completedEvents.isEmpty()) {\n            assertEquals(1, completedEvents.size());\n            assertRecentTimestampMs(completedEvents.get(0).getTimestamp(), 180_000);\n            assertTrue(completedEvents.get(0).getExecutionTimeInMillis() >= 0);\n        }\n        if (!errors.isEmpty()) {\n            assertNotNull(errors.get(0).getName());\n            assertFalse(errors.get(0).getName().isBlank());\n            assertNotNull(errors.get(0).getValue());\n            assertRecentTimestampMs(errors.get(0).getTimestamp(), 180_000);\n        }\n    }\n\n    @BeforeAll\n    void setup() {\n        Volume volume =\n                Volume.builder()\n                        .name(\"execd-logs\")\n                        .host(Host.of(\"/tmp/opensandbox-e2e/logs\"))\n                        .mountPath(\"/tmp/opensandbox-e2e/logs\")\n                        .readOnly(false)\n                        .build();\n        sandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .entrypoint(List.of(\"/opt/opensandbox/code-interpreter.sh\"))\n                        .image(getSandboxImage())\n                        .resource(java.util.Map.of(\"cpu\", \"2\", \"memory\", \"4Gi\"))\n                        .timeout(Duration.ofMinutes(20))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(java.util.Map.of(\"tag\", \"e2e-code-interpreter\"))\n                        .env(\"E2E_TEST\", \"true\")\n                        .env(\"GO_VERSION\", \"1.25\")\n                        .env(\"JAVA_VERSION\", \"21\")\n                        .env(\"NODE_VERSION\", \"22\")\n                        .env(\"PYTHON_VERSION\", \"3.12\")\n                        .env(\"EXECD_LOG_FILE\", \"/tmp/opensandbox-e2e/logs/execd.log\")\n                        .healthCheckPollingInterval(Duration.ofMillis(500))\n                        .volume(volume)\n                        .build();\n        codeInterpreter = CodeInterpreter.builder().fromSandbox(sandbox).build();\n        assertNotNull(codeInterpreter);\n        assertNotNull(codeInterpreter.getId());\n    }\n\n    @AfterAll\n    void teardown() {\n        if (sandbox != null) {\n            try {\n                sandbox.kill();\n            } catch (Exception ignored) {\n            }\n            try {\n                sandbox.close();\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    // ==========================================\n    // Basic Code Execution Tests\n    // ==========================================\n    @Test\n    @Order(1)\n    @DisplayName(\"CodeInterpreter Creation and Basic Functionality\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testCodeInterpreterBasicFunctionality() {\n        logger.info(\"Testing CodeInterpreter creation and basic functionality\");\n\n        assertNotNull(codeInterpreter);\n        assertNotNull(codeInterpreter.getId());\n\n        // 2. Verify service access\n        assertNotNull(codeInterpreter.codes());\n        assertNotNull(codeInterpreter.files());\n        assertNotNull(codeInterpreter.commands());\n        assertNotNull(codeInterpreter.metrics());\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Java Code Execution\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testJavaCodeExecution() {\n        logger.info(\"Testing Java code execution\");\n\n        CodeContext javaContext = codeInterpreter.codes().createContext(SupportedLanguage.JAVA);\n\n        assertNotNull(javaContext);\n        assertNotNull(javaContext.getId());\n        assertEquals(\"java\", javaContext.getLanguage());\n\n        // Event tracking for comprehensive validation\n        List<OutputMessage> stdoutMessages = Collections.synchronizedList(new ArrayList<>());\n        List<OutputMessage> stderrMessages = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionResult> results = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionInit> initEvents = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onStdout(\n                                (OutputMessage msg) -> {\n                                    stdoutMessages.add(msg);\n                                    logger.info(\"Java stdout: {}\", msg.getText());\n                                })\n                        .onStderr(\n                                (OutputMessage msg) -> {\n                                    stderrMessages.add(msg);\n                                    logger.warn(\"Java stderr: {}\", msg.getText());\n                                })\n                        .onResult(\n                                (ExecutionResult result) -> {\n                                    results.add(result);\n                                    logger.info(\"Java result: {}\", result.getText());\n                                })\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                    logger.info(\n                                            \"Java execution completed in {} ms\",\n                                            complete.getExecutionTimeInMillis());\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                    logger.error(\n                                            \"Java error: {} - {}\",\n                                            error.getName(),\n                                            error.getValue());\n                                })\n                        .onInit(\n                                (ExecutionInit init) -> {\n                                    initEvents.add(init);\n                                    logger.info(\n                                            \"Java execution initialized with ID: {}\", init.getId());\n                                })\n                        .build();\n\n        RunCodeRequest simpleRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"System.out.println(\\\"Hello from Java!\\\");\\n\"\n                                        + \"int result = 2 + 2;\\n\"\n                                        + \"System.out.println(\\\"2 + 2 = \\\" + result);\\n\"\n                                        + \"result\")\n                        .context(javaContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution simpleResult = codeInterpreter.codes().run(simpleRequest);\n\n        assertNotNull(simpleResult);\n        assertNotNull(simpleResult.getId());\n        assertFalse(simpleResult.getId().isBlank());\n        assertEquals(\"4\", simpleResult.getResult().get(0).getText());\n        assertTerminalEventContract(initEvents, completedEvents, errors, simpleResult.getId());\n        assertTrue(errors.isEmpty());\n        assertTrue(stdoutMessages.stream().anyMatch(m -> m.getText().contains(\"Hello from Java!\")));\n        assertTrue(\n                stdoutMessages.stream()\n                        .anyMatch(m -> m.getText().replace(\" \", \"\").contains(\"2+2=4\")));\n\n        RunCodeRequest varRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"import java.util.*;\\n\"\n                                        + \"List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);\\n\"\n                                        + \"int sum =\"\n                                        + \" numbers.stream().mapToInt(Integer::intValue).sum();\\n\"\n                                        + \"System.out.println(\\\"Numbers: \\\" + numbers);\\n\"\n                                        + \"System.out.println(\\\"Sum: \\\" + sum);\\n\"\n                                        + \"result\")\n                        .context(javaContext)\n                        .build();\n\n        Execution varResult = codeInterpreter.codes().run(varRequest);\n\n        assertNotNull(varResult);\n        assertNotNull(varResult.getId());\n        assertEquals(\"4\", varResult.getResult().get(0).getText());\n\n        // 3. Java error handling test (mutually exclusive contract)\n        stdoutMessages.clear();\n        stderrMessages.clear();\n        results.clear();\n        errors.clear();\n        completedEvents.clear();\n        initEvents.clear();\n        RunCodeRequest errorRequest =\n                RunCodeRequest.builder()\n                        .code(\"int x = 10 / 0; // This will cause ArithmeticException\")\n                        .context(javaContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution errorResult = codeInterpreter.codes().run(errorRequest);\n\n        assertNotNull(errorResult);\n        assertNotNull(errorResult.getId());\n        assertNotNull(errorResult.getError());\n        assertEquals(\"EvalException\", errorResult.getError().getName());\n        assertTerminalEventContract(initEvents, completedEvents, errors, errorResult.getId());\n    }\n\n    @Test\n    @Order(3)\n    @DisplayName(\"Python Code Execution\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testPythonCodeExecution() {\n        logger.info(\"Testing Python code execution\");\n\n        // Use class-scoped interpreter (created in @BeforeAll)\n        assertNotNull(codeInterpreter);\n        CodeContext pythonContext = codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n        assertNotNull(pythonContext);\n        assertEquals(\"python\", pythonContext.getLanguage());\n        Duration perExecTimeout = Duration.ofMinutes(2);\n\n        // Event tracking\n        List<OutputMessage> stdoutMessages = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onStdout(\n                                (OutputMessage msg) -> {\n                                    stdoutMessages.add(msg);\n                                    logger.info(\"Python stdout: {}\", msg.getText());\n                                })\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                    logger.info(\n                                            \"Python execution completed in {} ms\",\n                                            complete.getExecutionTimeInMillis());\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                    logger.error(\n                                            \"Python error: {} - {}\",\n                                            error.getName(),\n                                            error.getValue());\n                                })\n                        .build();\n\n        // 1. Simple Python execution\n        RunCodeRequest simpleRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"print('Hello from Python!')\\n\"\n                                        + \"result = 2 + 2\\n\"\n                                        + \"print(f'2 + 2 = {result}')\")\n                        .context(pythonContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution simpleResult =\n                runWithRetry(simpleRequest, perExecTimeout, 2, \"python-simple-execution\");\n\n        assertNotNull(simpleResult);\n        assertNotNull(simpleResult.getId());\n        assertFalse(completedEvents.isEmpty());\n        assertTrue(errors.isEmpty());\n\n        // 2. Python with variables and state persistence\n        RunCodeRequest varRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"x = 42\\n\"\n                                        + \"y = 'persistent variable'\\n\"\n                                        + \"my_list = [1, 2, 3, 4, 5]\\n\"\n                                        + \"print(f'x={x}, y=\\\"{y}\\\", list={my_list}')\\n\"\n                                        + \"result\")\n                        .context(pythonContext)\n                        .build();\n\n        Execution varResult = runWithRetry(varRequest, perExecTimeout, 2, \"python-state-setup\");\n\n        assertNotNull(varResult);\n        assertNotNull(varResult.getId());\n        assertEquals(\"4\", varResult.getResult().get(0).getText());\n\n        // 3. Test variable persistence across executions\n        RunCodeRequest persistRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"print(f'Previously set variables: x={x}, y={y}')\\n\"\n                                        + \"z = sum(my_list)\\n\"\n                                        + \"print(f'Sum of list: {z}')\")\n                        .context(pythonContext)\n                        .build();\n\n        Execution persistResult =\n                runWithRetry(persistRequest, perExecTimeout, 2, \"python-state-persistence\");\n\n        assertNotNull(persistResult);\n        assertNotNull(persistResult.getId());\n\n        // 4. Python error handling\n        RunCodeRequest errorRequest =\n                RunCodeRequest.builder()\n                        .code(\"print(undefined_variable)  # This will cause NameError\")\n                        .context(pythonContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution errorResult =\n                runWithRetry(errorRequest, perExecTimeout, 2, \"python-runtime-error\");\n\n        assertNotNull(errorResult);\n        assertNotNull(errorResult.getId());\n        assertTrue(\n                errorResult.getError() != null || !errorResult.getLogs().getStderr().isEmpty(),\n                \"Python error execution should capture runtime errors\");\n\n        logger.info(\"Python code execution tests completed\");\n    }\n\n    @Test\n    @Order(4)\n    @DisplayName(\"Go Code Execution\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testGoCodeExecution() {\n        logger.info(\"Testing Go code execution\");\n\n        assertNotNull(codeInterpreter);\n        CodeContext goContext = codeInterpreter.codes().createContext(SupportedLanguage.GO);\n\n        assertNotNull(goContext);\n        assertEquals(\"go\", goContext.getLanguage());\n\n        // Event tracking\n        List<OutputMessage> stdoutMessages = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onStdout(\n                                (OutputMessage msg) -> {\n                                    stdoutMessages.add(msg);\n                                    logger.info(\"Go stdout: {}\", msg.getText());\n                                })\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                    logger.info(\n                                            \"Go execution completed in {} ms\",\n                                            complete.getExecutionTimeInMillis());\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                    logger.error(\n                                            \"Go error: {} - {}\", error.getName(), error.getValue());\n                                })\n                        .build();\n\n        // 1. Simple Go execution\n        RunCodeRequest simpleRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"package main\\n\"\n                                        + \"func main() {\\n\"\n                                        + \"    println(\\\"Hello from Go!\\\")\\n\"\n                                        + \"    result := 2 + 2\\n\"\n                                        + \"    println(\\\"2 + 2 =\\\", result)\\n\"\n                                        + \"}\")\n                        .context(goContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution simpleResult = codeInterpreter.codes().run(simpleRequest);\n\n        assertNotNull(simpleResult);\n        assertNotNull(simpleResult.getId());\n        assertFalse(completedEvents.isEmpty());\n\n        // 2. Go with data structures and functions\n        RunCodeRequest dataRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"package main\\n\"\n                                        + \"func calculate(numbers []int) int {\\n\"\n                                        + \"    sum := 0\\n\"\n                                        + \"    for _, num := range numbers {\\n\"\n                                        + \"        sum += num\\n\"\n                                        + \"    }\\n\"\n                                        + \"    return sum\\n\"\n                                        + \"}\\n\"\n                                        + \"func main() {\\n\"\n                                        + \"    numbers := []int{1, 2, 3, 4, 5}\\n\"\n                                        + \"    sum := calculate(numbers)\\n\"\n                                        + \"    println(\\\"Numbers:\\\", numbers)\\n\"\n                                        + \"    println(\\\"Sum:\\\", sum)\\n\"\n                                        + \"}\")\n                        .context(goContext)\n                        .build();\n\n        Execution dataResult = codeInterpreter.codes().run(dataRequest);\n\n        assertNotNull(dataResult);\n        assertNotNull(dataResult.getId());\n\n        // 3. Go compilation error test\n        RunCodeRequest errorRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"package main\\n\"\n                                        + \"func main() {\\n\"\n                                        + \"    undeclaredVariable++  // This will cause compilation\"\n                                        + \" error\\n\"\n                                        + \"}\")\n                        .context(goContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution errorResult = codeInterpreter.codes().run(errorRequest);\n\n        assertNotNull(errorResult);\n        assertNotNull(errorResult.getId());\n        assertTrue(\n                errorResult.getError() != null || errorResult.getLogs().getStderr().size() > 0,\n                \"Go error execution should capture compilation errors\");\n\n        logger.info(\"Go code execution tests completed\");\n    }\n\n    @Test\n    @Order(5)\n    @DisplayName(\"TypeScript Code Execution\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testTypeScriptCodeExecution() {\n        logger.info(\"Testing TypeScript code execution\");\n\n        assertNotNull(codeInterpreter);\n\n        // Create TypeScript execution context\n        CodeContext tsContext = codeInterpreter.codes().createContext(SupportedLanguage.TYPESCRIPT);\n\n        assertNotNull(tsContext);\n        assertEquals(\"typescript\", tsContext.getLanguage());\n\n        // Event tracking\n        List<OutputMessage> stdoutMessages = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onStdout(\n                                (OutputMessage msg) -> {\n                                    stdoutMessages.add(msg);\n                                    logger.info(\"TypeScript stdout: {}\", msg.getText());\n                                })\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                    logger.info(\n                                            \"TypeScript execution completed in {} ms\",\n                                            complete.getExecutionTimeInMillis());\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                    logger.error(\n                                            \"TypeScript error: {} - {}\",\n                                            error.getName(),\n                                            error.getValue());\n                                })\n                        .build();\n\n        // 1. Simple TypeScript execution\n        RunCodeRequest simpleRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"console.log('Hello from TypeScript!');\\n\"\n                                        + \"const result: number = 2 + 2;\\n\"\n                                        + \"console.log(`2 + 2 = ${result}`);\")\n                        .context(tsContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution simpleResult = codeInterpreter.codes().run(simpleRequest);\n\n        assertNotNull(simpleResult);\n        assertNotNull(simpleResult.getId());\n        assertFalse(completedEvents.isEmpty());\n\n        // 2. TypeScript with types and interfaces\n        RunCodeRequest typesRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"interface Person {\\n\"\n                                        + \"  name: string;\\n\"\n                                        + \"  age: number;\\n\"\n                                        + \"}\\n\"\n                                        + \"const person: Person = { name: 'John', age: 30 };\\n\"\n                                        + \"const numbers: number[] = [1, 2, 3, 4, 5];\\n\"\n                                        + \"const sum: number = numbers.reduce((a, b) => a + b, 0);\\n\"\n                                        + \"console.log(`Person: ${person.name}, Age: ${person.age}`);\\n\"\n                                        + \"console.log(`Numbers: ${numbers}`);\\n\"\n                                        + \"console.log(`Sum: ${sum}`);\")\n                        .context(tsContext)\n                        .build();\n\n        Execution typesResult = codeInterpreter.codes().run(typesRequest);\n\n        assertNotNull(typesResult);\n        assertNotNull(typesResult.getId());\n\n        // 3. TypeScript error test: use deterministic runtime error.\n        RunCodeRequest errorRequest =\n                RunCodeRequest.builder()\n                        .code(\"throw new Error('ts-runtime-error');\")\n                        .context(tsContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution errorResult = codeInterpreter.codes().run(errorRequest);\n\n        assertNotNull(errorResult);\n        assertNotNull(errorResult.getId());\n        assertTrue(\n                errorResult.getError() != null || errorResult.getLogs().getStderr().size() > 0,\n                \"TypeScript error execution should capture type errors\");\n\n        logger.info(\"TypeScript code execution tests completed\");\n    }\n\n    /**\n     * Run a code request with a per-execution timeout so that a single hanging SSE stream cannot\n     * block the entire test for the full JUnit timeout.\n     */\n    private Execution runWithTimeout(RunCodeRequest request, Duration timeout) {\n        CompletableFuture<Execution> future =\n                CompletableFuture.supplyAsync(() -> codeInterpreter.codes().run(request));\n        try {\n            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);\n        } catch (TimeoutException e) {\n            future.cancel(true);\n            throw new AssertionError(\"Code execution did not complete within \" + timeout, e);\n        } catch (ExecutionException e) {\n            Throwable cause = e.getCause();\n            if (cause instanceof RuntimeException) {\n                throw (RuntimeException) cause;\n            }\n            throw new RuntimeException(cause);\n        } catch (InterruptedException e) {\n            future.cancel(true);\n            Thread.currentThread().interrupt();\n            throw new RuntimeException(e);\n        }\n    }\n\n    private Execution runWithRetry(\n            RunCodeRequest request, Duration timeout, int attempts, String label) {\n        AssertionError lastAssertionError = null;\n        RuntimeException lastRuntimeException = null;\n        for (int attempt = 1; attempt <= attempts; attempt++) {\n            try {\n                return runWithTimeout(request, timeout);\n            } catch (AssertionError e) {\n                lastAssertionError = e;\n                logger.warn(\"{} attempt {}/{} timed out\", label, attempt, attempts, e);\n            } catch (RuntimeException e) {\n                lastRuntimeException = e;\n                logger.warn(\"{} attempt {}/{} failed\", label, attempt, attempts, e);\n            }\n        }\n        if (lastAssertionError != null) {\n            throw lastAssertionError;\n        }\n        if (lastRuntimeException != null) {\n            throw lastRuntimeException;\n        }\n        throw new AssertionError(label + \" failed without a captured exception\");\n    }\n\n    @Test\n    @Order(6)\n    @DisplayName(\"Multi-Language Support and Context Isolation\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testMultiLanguageAndContextIsolation() {\n        logger.info(\"Testing multi-language support and context isolation\");\n\n        assertNotNull(codeInterpreter);\n\n        // Per-execution timeout: if a single run() call hangs (sandbox gone, network\n        // issue), fail fast instead of blocking the entire 10-minute JUnit timeout.\n        Duration perExecTimeout = Duration.ofMinutes(2);\n\n        // Create separate contexts for different languages\n        CodeContext python1 = codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n        CodeContext python2 = codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n\n        // 1. Set different variables in each Python context to test isolation\n        RunCodeRequest python1Setup =\n                RunCodeRequest.builder()\n                        .code(\n                                \"secret_value1 = 'python1_secret'\\n\"\n                                        + \"print(f'Python1 secret: {secret_value1}')\")\n                        .context(python1)\n                        .build();\n\n        RunCodeRequest python2Setup =\n                RunCodeRequest.builder()\n                        .code(\n                                \"secret_value2 = 'python2_secret'\\n\"\n                                        + \"print(f'Python2 secret: {secret_value2}')\")\n                        .context(python2)\n                        .build();\n\n        Execution result1 = runWithTimeout(python1Setup, perExecTimeout);\n        Execution result2 = runWithTimeout(python2Setup, perExecTimeout);\n\n        assertNotNull(result1);\n        assertNotNull(result1.getId());\n        assertNotNull(result2);\n        assertNotNull(result2.getId());\n\n        // 2. Verify isolation - each context should only see its own variables\n        RunCodeRequest python1Check =\n                RunCodeRequest.builder()\n                        .code(\"print(f'Python1 still has: {secret_value1}')\")\n                        .context(python1)\n                        .build();\n\n        RunCodeRequest python2Check =\n                RunCodeRequest.builder()\n                        .code(\"print(f'Python2 has no: {secret_value1}')\")\n                        .context(python2)\n                        .build();\n\n        Execution check1 = runWithTimeout(python1Check, perExecTimeout);\n        Execution check2 = runWithTimeout(python2Check, perExecTimeout);\n\n        assertNotNull(check1);\n        assertNotNull(check1.getId());\n        assertNotNull(check2);\n        assertNotNull(check2.getId());\n        assertNotNull(check2.getError());\n        assertEquals(\"NameError\", check2.getError().getName());\n    }\n\n    @Test\n    @Order(7)\n    @DisplayName(\"Concurrent Code Execution\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testConcurrentCodeExecution() {\n        logger.info(\"Testing concurrent code execution\");\n\n        assertNotNull(codeInterpreter);\n        ExecutorService executor = Executors.newFixedThreadPool(4);\n        long timestamp = System.currentTimeMillis();\n\n        // Create multiple contexts for concurrent execution\n        CodeContext pythonConcurrent1 =\n                codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n        CodeContext pythonConcurrent2 =\n                codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n        CodeContext javaConcurrent = codeInterpreter.codes().createContext(SupportedLanguage.JAVA);\n        CodeContext goConcurrent = codeInterpreter.codes().createContext(SupportedLanguage.GO);\n\n        // Track futures with labels for diagnostics\n        List<String> taskLabels = List.of(\"Python1\", \"Python2\", \"Java\", \"Go\");\n        List<Future<Execution>> futures = new ArrayList<>();\n\n        try {\n            // Submit concurrent executions\n            futures.add(\n                    executor.submit(\n                            () -> {\n                                RunCodeRequest request =\n                                        RunCodeRequest.builder()\n                                                .code(\n                                                        \"import time\\n\"\n                                                                + \"for i in range(3):\\n\"\n                                                                + \"    print(f'Python1 iteration\"\n                                                                + \" {i}')\\n\"\n                                                                + \"    time.sleep(0.1)\\n\"\n                                                                + \"print('Python1 completed')\")\n                                                .context(pythonConcurrent1)\n                                                .build();\n                                return codeInterpreter.codes().run(request);\n                            }));\n\n            futures.add(\n                    executor.submit(\n                            () -> {\n                                RunCodeRequest request =\n                                        RunCodeRequest.builder()\n                                                .code(\n                                                        \"import time\\n\"\n                                                                + \"for i in range(3):\\n\"\n                                                                + \"    print(f'Python2 iteration\"\n                                                                + \" {i}')\\n\"\n                                                                + \"    time.sleep(0.1)\\n\"\n                                                                + \"print('Python2 completed')\")\n                                                .context(pythonConcurrent2)\n                                                .build();\n                                return codeInterpreter.codes().run(request);\n                            }));\n\n            futures.add(\n                    executor.submit(\n                            () -> {\n                                RunCodeRequest request =\n                                        RunCodeRequest.builder()\n                                                .code(\n                                                        \"for (int i = 0; i < 3; i++) {\\n\"\n                                                                + \"    System.out.println(\\\"Java\"\n                                                                + \" iteration \\\" + i);\\n\"\n                                                                + \"    Thread.sleep(100);\\n\"\n                                                                + \"}\\n\"\n                                                                + \"System.out.println(\\\"Java\"\n                                                                + \" completed\\\");\")\n                                                .context(javaConcurrent)\n                                                .build();\n                                return codeInterpreter.codes().run(request);\n                            }));\n\n            futures.add(\n                    executor.submit(\n                            () -> {\n                                RunCodeRequest request =\n                                        RunCodeRequest.builder()\n                                                .code(\n                                                        \"package main\\n\"\n                                                                + \"func main() {\\n\"\n                                                                + \"    for i := 0; i < 3; i++ {\\n\"\n                                                                + \"        println(\\\"Go iteration\\\",\"\n                                                                + \" i)\\n\"\n                                                                + \"    }\\n\"\n                                                                + \"    println(\\\"Go completed\\\")\\n\"\n                                                                + \"}\")\n                                                .context(goConcurrent)\n                                                .build();\n                                return codeInterpreter.codes().run(request);\n                            }));\n\n            // Collect results with per-task diagnostics\n            int succeeded = 0;\n            List<String> failures = new ArrayList<>();\n            for (int i = 0; i < futures.size(); i++) {\n                String label = taskLabels.get(i);\n                try {\n                    Execution result = futures.get(i).get(5, TimeUnit.MINUTES);\n                    if (result == null) {\n                        String msg = label + \": returned null Execution\";\n                        logger.error(msg);\n                        failures.add(msg);\n                    } else if (result.getId() == null) {\n                        // Log available fields to aid debugging\n                        String detail =\n                                label\n                                        + \": Execution has null id (error=\"\n                                        + (result.getError() != null\n                                                ? result.getError().getName()\n                                                        + \": \"\n                                                        + result.getError().getValue()\n                                                : \"none\")\n                                        + \")\";\n                        logger.warn(detail);\n                        failures.add(detail);\n                    } else {\n                        logger.info(\n                                \"Concurrent execution [{}] completed: {}\", label, result.getId());\n                        succeeded++;\n                    }\n                } catch (TimeoutException te) {\n                    String msg = label + \": timed out waiting for result\";\n                    logger.error(msg, te);\n                    failures.add(msg);\n                    futures.get(i).cancel(true);\n                } catch (ExecutionException ee) {\n                    String msg = label + \": execution threw \" + ee.getCause();\n                    logger.error(msg, ee.getCause());\n                    failures.add(msg);\n                }\n            }\n\n            // At least 2 of 4 concurrent executions must succeed.\n            // Java/Go compilation overhead in CI can occasionally cause\n            // timeouts or incomplete responses, so we tolerate partial\n            // failure while still asserting that concurrency works.\n            assertTrue(\n                    succeeded >= 2,\n                    \"Expected at least 2 of 4 concurrent executions to succeed, but only \"\n                            + succeeded\n                            + \" did. Failures: \"\n                            + failures);\n            logger.info(\n                    \"Concurrent execution: {}/{} succeeded (failures: {})\",\n                    succeeded,\n                    futures.size(),\n                    failures);\n\n        } catch (Exception e) {\n            logger.error(\"Concurrent execution test failed unexpectedly\", e);\n            fail(\"Concurrent execution failed: \" + e);\n        } finally {\n            executor.shutdown();\n        }\n\n        logger.info(\"Concurrent code execution tests completed\");\n    }\n\n    @Test\n    @Order(8)\n    @DisplayName(\"Code Execution Interrupt\")\n    @Timeout(value = 10, unit = TimeUnit.MINUTES)\n    void testCodeExecutionInterrupt() throws InterruptedException, ExecutionException {\n        logger.info(\"Testing code execution interrupt functionality\");\n\n        CodeContext pythonContext = codeInterpreter.codes().createContext(SupportedLanguage.PYTHON);\n        CodeContext javaContext = codeInterpreter.codes().createContext(SupportedLanguage.JAVA);\n\n        // Event tracking for interrupt testing\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionInit> initEvents = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                    logger.info(\n                                            \"Execution completed in {} ms\",\n                                            complete.getExecutionTimeInMillis());\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                    logger.error(\n                                            \"Execution error: {} - {}\",\n                                            error.getName(),\n                                            error.getValue());\n                                })\n                        .onInit(\n                                (ExecutionInit init) -> {\n                                    initEvents.add(init);\n                                    logger.info(\"Execution initialized with ID: {}\", init.getId());\n                                })\n                        .build();\n\n        // Test 1: Python long-running execution with interrupt\n        logger.info(\"Testing Python interrupt functionality\");\n\n        RunCodeRequest pythonLongRunningRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"import time\\n\"\n                                        + \"print('Starting long-running Python execution')\\n\"\n                                        + \"for i in range(100):\\n\"\n                                        + \"    print(f'Python iteration {i}')\\n\"\n                                        + \"    time.sleep(0.2)  # Sleep 200ms per iteration (20 seconds\"\n                                        + \" total)\\n\"\n                                        + \"print('Python execution completed - this should not be\"\n                                        + \" seen')\")\n                        .context(pythonContext)\n                        .handlers(handlers)\n                        .build();\n\n        // Start Python execution in background\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        long start = System.currentTimeMillis();\n        Future<Execution> pythonFuture =\n                executor.submit(() -> codeInterpreter.codes().run(pythonLongRunningRequest));\n\n        // Wait for init\n        long deadline = System.currentTimeMillis() + 15_000;\n        while (initEvents.isEmpty() && System.currentTimeMillis() < deadline) {\n            Thread.sleep(100);\n        }\n        assertFalse(initEvents.isEmpty(), \"Execution should have been initialized\");\n        String pythonExecutionId = initEvents.get(initEvents.size() - 1).getId();\n        assertNotNull(pythonExecutionId, \"Execution ID should not be null\");\n\n        // Interrupt the execution after letting it run briefly\n        logger.info(\"Interrupting Python execution with ID: {}\", pythonExecutionId);\n        assertDoesNotThrow(() -> codeInterpreter.codes().interrupt(pythonExecutionId));\n\n        // Wait for execution to complete (should be interrupted).\n        // The SSE stream may close abruptly after interrupt, so handle both\n        // a clean result and an exception from a broken connection.\n        Execution pythonResult = null;\n        try {\n            pythonResult = pythonFuture.get(60, TimeUnit.SECONDS);\n        } catch (TimeoutException e) {\n            pythonFuture.cancel(true);\n            logger.warn(\"Python execution did not complete within 60s after interrupt\");\n        } catch (ExecutionException e) {\n            // SSE stream broken by interrupt — acceptable\n            logger.warn(\"Python execution raised after interrupt: {}\", e.getCause().getMessage());\n        }\n        executor.shutdown();\n\n        long elapsed = System.currentTimeMillis() - start;\n\n        if (pythonResult != null) {\n            assertNotNull(pythonResult.getId());\n            assertEquals(pythonExecutionId, pythonResult.getId());\n        }\n\n        // Verify the interrupt was effective: execution finished much faster\n        // than the full 20 s run.  Terminal events (complete/error) may or may\n        // not arrive depending on how quickly the server closed the stream.\n        assertTrue(\n                elapsed < 90_000,\n                \"Execution should have finished promptly after interrupt (elapsed=\"\n                        + elapsed\n                        + \"ms)\");\n\n        // Test 2: Java long-running execution with interrupt\n        logger.info(\"Testing Java interrupt functionality\");\n\n        // Clear event lists for Java test\n        completedEvents.clear();\n        errors.clear();\n        initEvents.clear();\n\n        RunCodeRequest javaLongRunningRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"System.out.println(\\\"Starting long-running Java execution\\\");\\n\"\n                                        + \"for (int i = 0; i < 100; i++) {\\n\"\n                                        + \"    System.out.println(\\\"Java iteration \\\" + i);\\n\"\n                                        + \"    try {\\n\"\n                                        + \"        Thread.sleep(200);  // Sleep 200ms per iteration\\n\"\n                                        + \"    } catch (InterruptedException e) {\\n\"\n                                        + \"        System.out.println(\\\"Java execution\"\n                                        + \" interrupted\\\");\\n\"\n                                        + \"        break;\\n\"\n                                        + \"    }\\n\"\n                                        + \"}\\n\"\n                                        + \"System.out.println(\\\"Java execution completed - this should\"\n                                        + \" not be seen\\\");\")\n                        .context(javaContext)\n                        .handlers(handlers)\n                        .build();\n\n        // Start Java execution in background\n        ExecutorService javaExecutor = Executors.newSingleThreadExecutor();\n        Future<Execution> javaFuture =\n                javaExecutor.submit(() -> codeInterpreter.codes().run(javaLongRunningRequest));\n\n        // Wait for execution to start\n        Thread.sleep(1000);\n\n        // Verify Java execution was initialized\n        assertFalse(initEvents.isEmpty(), \"Java execution should have been initialized\");\n        String javaExecutionId = initEvents.get(initEvents.size() - 1).getId();\n        assertNotNull(javaExecutionId, \"Java execution ID should not be null\");\n\n        // Interrupt the Java execution\n        logger.info(\"Interrupting Java execution with ID: {}\", javaExecutionId);\n        assertDoesNotThrow(() -> codeInterpreter.codes().interrupt(javaExecutionId));\n\n        // Wait for execution to complete, with a timeout to avoid hanging\n        // if the SSE stream doesn't close promptly after interrupt.\n        Execution javaResult = null;\n        try {\n            javaResult = javaFuture.get(60, TimeUnit.SECONDS);\n        } catch (TimeoutException e) {\n            javaFuture.cancel(true);\n            logger.warn(\"Java execution did not complete within 60s after interrupt\");\n        } catch (ExecutionException e) {\n            logger.warn(\"Java execution raised after interrupt: {}\", e.getCause().getMessage());\n        }\n        javaExecutor.shutdown();\n\n        if (javaResult != null) {\n            assertNotNull(javaResult.getId());\n            logger.info(\n                    \"Java execution result: ID={}, Error={}\",\n                    javaResult.getId(),\n                    javaResult.getError() != null ? javaResult.getError().getName() : \"none\");\n        }\n\n        // Test 4: Quick execution that completes before interrupt\n        logger.info(\"Testing interrupt of already completed execution\");\n\n        RunCodeRequest quickRequest =\n                RunCodeRequest.builder()\n                        .code(\n                                \"print('Quick Python execution')\\n\"\n                                        + \"result = 2 + 2\\n\"\n                                        + \"print(f'Result: {result}')\")\n                        .context(pythonContext)\n                        .handlers(handlers)\n                        .build();\n\n        Execution quickResult = runWithTimeout(quickRequest, Duration.ofMinutes(1));\n        assertNotNull(quickResult);\n        assertNotNull(quickResult.getId());\n\n        // Try to interrupt already completed execution\n        try {\n            codeInterpreter.codes().interrupt(quickResult.getId());\n        } catch (Exception ignored) {\n        }\n\n        logger.info(\"Code execution interrupt tests completed\");\n    }\n}\n"
  },
  {
    "path": "tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxE2ETest.java",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.e2e;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.config.ConnectionConfig;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.executions.*;\nimport com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.*;\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*;\nimport java.io.ByteArrayInputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.OffsetDateTime;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport org.junit.jupiter.api.*;\n\n/**\n * Comprehensive E2E tests for Sandbox functionality.\n *\n * <p>Tests all sandbox capabilities including - Lifecycle management (creation, health,\n * termination) - Command execution with various shells and scenarios - Filesystem operations (CRUD,\n * permissions, search) - Resource management and monitoring - Error handling and recovery -\n * Concurrent operations and stress testing\n */\n@Tag(\"e2e\")\n@DisplayName(\"Sandbox E2E Tests (Java SDK) - Strict Coverage\")\n@TestMethodOrder(MethodOrderer.OrderAnnotation.class)\npublic class SandboxE2ETest extends BaseE2ETest {\n\n    private Sandbox sandbox;\n\n    @BeforeAll\n    void setup() {\n        Map<String, String> resourceMap = new HashMap<>();\n        resourceMap.put(\"cpu\", \"2\");\n        resourceMap.put(\"memory\", \"4Gi\");\n\n        Map<String, String> metadataMap = new HashMap<>();\n        metadataMap.put(\"tag\", \"e2e-test\");\n\n        sandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .resource(resourceMap)\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(metadataMap)\n                        .env(\"E2E_TEST\", \"true\")\n                        .healthCheckPollingInterval(Duration.ofMillis(500))\n                        .build();\n    }\n\n    @AfterAll\n    void teardown() {\n        if (sandbox != null) {\n            try {\n                sandbox.kill();\n            } catch (Exception ignored) {\n            }\n            try {\n                sandbox.close();\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    private static void assertModifiedUpdated(\n            OffsetDateTime before, OffsetDateTime after, long minDeltaMs, long allowSkewMs) {\n        long deltaMs = Duration.between(before, after).toMillis();\n        assertTrue(\n                deltaMs >= minDeltaMs - allowSkewMs,\n                \"modifiedAt did not update as expected: deltaMs=\"\n                        + deltaMs\n                        + \" (minDeltaMs=\"\n                        + minDeltaMs\n                        + \", allowSkewMs=\"\n                        + allowSkewMs\n                        + \")\");\n    }\n\n    private static void assertTerminalEventContract(\n            List<ExecutionInit> initEvents,\n            List<ExecutionComplete> completedEvents,\n            List<ExecutionError> errors,\n            String executionId) {\n        assertEquals(1, initEvents.size(), \"Execution must have exactly one init event\");\n        assertNotNull(initEvents.get(0).getId());\n        assertFalse(initEvents.get(0).getId().isBlank());\n        assertEquals(executionId, initEvents.get(0).getId(), \"init.id must match execution.id\");\n        assertRecentTimestampMs(initEvents.get(0).getTimestamp(), 120_000);\n\n        boolean hasComplete = !completedEvents.isEmpty();\n        boolean hasError = !errors.isEmpty();\n        assertTrue(\n                hasComplete || hasError,\n                \"expected at least one of complete/error, got complete=\"\n                        + completedEvents.size()\n                        + \" error=\"\n                        + errors.size());\n        if (hasComplete) {\n            assertEquals(1, completedEvents.size());\n            assertRecentTimestampMs(completedEvents.get(0).getTimestamp(), 180_000);\n            assertTrue(completedEvents.get(0).getExecutionTimeInMillis() >= 0);\n        }\n        if (hasError) {\n            assertNotNull(errors.get(0).getName());\n            assertFalse(errors.get(0).getName().isBlank());\n            assertNotNull(errors.get(0).getValue());\n            assertRecentTimestampMs(errors.get(0).getTimestamp(), 180_000);\n        }\n    }\n\n    @Test\n    @Order(1)\n    @DisplayName(\"Sandbox lifecycle, health, endpoint, metrics, renew, connect\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxLifecycleAndHealth() {\n        assertNotNull(sandbox);\n        assertNotNull(sandbox.getId());\n        assertTrue(sandbox.isHealthy(), \"Sandbox should be healthy\");\n\n        SandboxInfo info = sandbox.getInfo();\n        assertEquals(sandbox.getId(), info.getId());\n        assertEquals(\"Running\", info.getStatus().getState());\n        assertNotNull(info.getCreatedAt());\n        assertNotNull(info.getExpiresAt());\n        assertTrue(info.getExpiresAt().isAfter(info.getCreatedAt()));\n        assertEquals(List.of(\"tail\", \"-f\", \"/dev/null\"), info.getEntrypoint());\n\n        Duration duration = Duration.between(info.getCreatedAt(), info.getExpiresAt());\n        assertTrue(duration.compareTo(Duration.ofMinutes(1)) >= 0);\n        assertTrue(duration.compareTo(Duration.ofMinutes(3)) <= 0);\n\n        assertNotNull(info.getMetadata());\n        assertEquals(\"e2e-test\", info.getMetadata().get(\"tag\"));\n\n        SandboxEndpoint endpoint = sandbox.getEndpoint(44772);\n        assertNotNull(endpoint);\n        assertEndpointHasPort(endpoint.getEndpoint(), 44772);\n\n        SandboxMetrics metrics = sandbox.getMetrics();\n        assertNotNull(metrics);\n        assertTrue(metrics.getCpuCount() > 0);\n        assertTrue(\n                metrics.getCpuUsedPercentage() >= 0.0 && metrics.getCpuUsedPercentage() <= 100.0);\n        assertTrue(metrics.getMemoryTotalInMiB() > 0);\n        assertTrue(\n                metrics.getMemoryUsedInMiB() >= 0.0\n                        && metrics.getMemoryUsedInMiB() <= metrics.getMemoryTotalInMiB());\n        assertRecentTimestampMs(metrics.getTimestamp(), 120_000);\n\n        // Renew: validate remaining TTL is close to requested duration.\n        SandboxRenewResponse renewResp = sandbox.renew(Duration.ofMinutes(5));\n        assertNotNull(renewResp, \"renew() should return a response\");\n        assertNotNull(renewResp.getExpiresAt(), \"renew().expiresAt should not be null\");\n        SandboxInfo renewedInfo = sandbox.getInfo();\n        assertTrue(renewedInfo.getExpiresAt().isAfter(info.getExpiresAt()));\n        assertTrue(\n                renewResp.getExpiresAt().isAfter(info.getExpiresAt()),\n                \"renew().expiresAt should be after previous expiresAt\");\n        // Allow small skew between renew response and subsequent getInfo() (backend timing).\n        assertTrue(\n                Math.abs(\n                                Duration.between(\n                                                renewResp.getExpiresAt(),\n                                                renewedInfo.getExpiresAt())\n                                        .toSeconds())\n                        < 10,\n                \"renew response expiresAt should be close to getInfo().expiresAt\");\n        Duration remaining = Duration.between(OffsetDateTime.now(), renewedInfo.getExpiresAt());\n        assertTrue(\n                remaining.compareTo(Duration.ofMinutes(3)) > 0,\n                \"Remaining TTL too small: \" + remaining);\n        assertTrue(\n                remaining.compareTo(Duration.ofMinutes(6)) < 0,\n                \"Remaining TTL too large: \" + remaining);\n\n        assertNotNull(sandbox.files());\n        assertNotNull(sandbox.commands());\n        assertNotNull(sandbox.metrics());\n        assertNotNull(sandbox.httpClientProvider());\n\n        // Connect to existing sandbox by ID and run a basic command.\n        Sandbox sandbox2 =\n                Sandbox.connector()\n                        .connectionConfig(sharedConnectionConfig)\n                        .sandboxId(sandbox.getId())\n                        .connect();\n        try {\n            assertEquals(sandbox.getId(), sandbox2.getId());\n            assertTrue(sandbox2.isHealthy());\n            Execution r =\n                    sandbox2.commands()\n                            .run(RunCommandRequest.builder().command(\"echo connect-ok\").build());\n            assertNotNull(r);\n            assertNull(r.getError());\n            assertEquals(1, r.getLogs().getStdout().size());\n            assertEquals(\"connect-ok\", r.getLogs().getStdout().get(0).getText());\n        } finally {\n            sandbox2.close();\n        }\n    }\n\n    @Test\n    @Order(1)\n    @DisplayName(\"Sandbox manual cleanup returns null expiresAt\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxManualCleanup() {\n        Sandbox manualSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .manualCleanup()\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(Map.of(\"tag\", \"manual-java-e2e-test\"))\n                        .build();\n\n        try {\n            SandboxInfo info = manualSandbox.getInfo();\n            assertNull(info.getExpiresAt());\n            assertNotNull(info.getMetadata());\n            assertEquals(\"manual-java-e2e-test\", info.getMetadata().get(\"tag\"));\n        } finally {\n            manualSandbox.kill();\n            manualSandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with networkPolicy + get/patch egress\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithNetworkPolicy() {\n        NetworkPolicy networkPolicy =\n                NetworkPolicy.builder()\n                        .defaultAction(NetworkPolicy.DefaultAction.DENY)\n                        .addEgress(\n                                NetworkRule.builder()\n                                        .action(NetworkRule.Action.ALLOW)\n                                        .target(\"pypi.org\")\n                                        .build())\n                        .build();\n\n        Sandbox policySandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .networkPolicy(networkPolicy)\n                        .build();\n        // Wait for NetworkPolicy sidecar to be fully initialized\n        try {\n            Thread.sleep(2000);\n        } catch (InterruptedException ignored) {\n        }\n\n        try {\n            NetworkPolicy initialPolicy = policySandbox.getEgressPolicy();\n            assertNotNull(initialPolicy);\n            assertEquals(NetworkPolicy.DefaultAction.DENY, initialPolicy.getDefaultAction());\n            assertNotNull(initialPolicy.getEgress());\n            assertTrue(\n                    initialPolicy.getEgress().stream()\n                            .anyMatch(\n                                    r ->\n                                            \"pypi.org\".equals(r.getTarget())\n                                                    && r.getAction() == NetworkRule.Action.ALLOW));\n\n            Execution r =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://www.github.com\")\n                                            .build());\n            assertNotNull(r);\n            assertNotNull(r.getError());\n\n            r =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://pypi.org\")\n                                            .build());\n            assertNotNull(r);\n            assertNull(r.getError());\n\n            policySandbox.patchEgressRules(\n                    List.of(\n                            NetworkRule.builder()\n                                    .action(NetworkRule.Action.ALLOW)\n                                    .target(\"www.github.com\")\n                                    .build(),\n                            NetworkRule.builder()\n                                    .action(NetworkRule.Action.DENY)\n                                    .target(\"pypi.org\")\n                                    .build()));\n\n            try {\n                Thread.sleep(2000);\n            } catch (InterruptedException ignored) {\n            }\n\n            NetworkPolicy patchedPolicy = policySandbox.getEgressPolicy();\n            assertNotNull(patchedPolicy);\n            assertNotNull(patchedPolicy.getEgress());\n            assertTrue(\n                    patchedPolicy.getEgress().stream()\n                            .anyMatch(\n                                    rule ->\n                                            \"www.github.com\".equals(rule.getTarget())\n                                                    && rule.getAction()\n                                                            == NetworkRule.Action.ALLOW));\n            assertTrue(\n                    patchedPolicy.getEgress().stream()\n                            .anyMatch(\n                                    rule ->\n                                            \"pypi.org\".equals(rule.getTarget())\n                                                    && rule.getAction()\n                                                            == NetworkRule.Action.DENY));\n\n            Execution githubAllowed =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://www.github.com\")\n                                            .build());\n            assertNotNull(githubAllowed);\n            assertNull(githubAllowed.getError());\n\n            Execution pypiDenied =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://pypi.org\")\n                                            .build());\n            assertNotNull(pypiDenied);\n            assertNotNull(pypiDenied.getError());\n        } finally {\n            try {\n                policySandbox.kill();\n            } catch (Exception ignored) {\n            }\n            policySandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with networkPolicy + get/patch egress via server proxy\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithNetworkPolicyViaServerProxy() {\n        NetworkPolicy networkPolicy =\n                NetworkPolicy.builder()\n                        .defaultAction(NetworkPolicy.DefaultAction.DENY)\n                        .addEgress(\n                                NetworkRule.builder()\n                                        .action(NetworkRule.Action.ALLOW)\n                                        .target(\"pypi.org\")\n                                        .build())\n                        .build();\n\n        Sandbox policySandbox =\n                Sandbox.builder()\n                        .connectionConfig(createConnectionConfig(true))\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .networkPolicy(networkPolicy)\n                        .build();\n        try {\n            Thread.sleep(2000);\n        } catch (InterruptedException ignored) {\n        }\n\n        try {\n            SandboxEndpoint egressEndpoint = policySandbox.getEndpoint(18080);\n            assertTrue(\n                    egressEndpoint.getEndpoint().contains(\n                            \"/sandboxes/\" + policySandbox.getId() + \"/proxy/18080\"));\n\n            NetworkPolicy initialPolicy = policySandbox.getEgressPolicy();\n            assertNotNull(initialPolicy);\n            assertEquals(NetworkPolicy.DefaultAction.DENY, initialPolicy.getDefaultAction());\n            assertNotNull(initialPolicy.getEgress());\n            assertTrue(\n                    initialPolicy.getEgress().stream()\n                            .anyMatch(\n                                    r ->\n                                            \"pypi.org\".equals(r.getTarget())\n                                                    && r.getAction() == NetworkRule.Action.ALLOW));\n\n            Execution blocked =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://www.github.com\")\n                                            .build());\n            assertNotNull(blocked);\n            assertNotNull(blocked.getError());\n\n            Execution allowed =\n                    policySandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"curl -I https://pypi.org\")\n                                            .build());\n            assertNotNull(allowed);\n            assertNull(allowed.getError());\n\n            policySandbox.patchEgressRules(\n                    List.of(\n                            NetworkRule.builder()\n                                    .action(NetworkRule.Action.ALLOW)\n                                    .target(\"www.github.com\")\n                                    .build(),\n                            NetworkRule.builder()\n                                    .action(NetworkRule.Action.DENY)\n                                    .target(\"pypi.org\")\n                                    .build()));\n\n            try {\n                Thread.sleep(2000);\n            } catch (InterruptedException ignored) {\n            }\n\n            NetworkPolicy patchedPolicy = policySandbox.getEgressPolicy();\n            assertNotNull(patchedPolicy.getEgress());\n            assertTrue(\n                    patchedPolicy.getEgress().stream()\n                            .anyMatch(\n                                    rule ->\n                                            \"www.github.com\".equals(rule.getTarget())\n                                                    && rule.getAction()\n                                                            == NetworkRule.Action.ALLOW));\n            assertTrue(\n                    patchedPolicy.getEgress().stream()\n                            .anyMatch(\n                                    rule ->\n                                            \"pypi.org\".equals(rule.getTarget())\n                                                    && rule.getAction()\n                                                            == NetworkRule.Action.DENY));\n        } finally {\n            try {\n                policySandbox.kill();\n            } catch (Exception ignored) {\n            }\n            policySandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with host volume mount (read-write)\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithHostVolumeMount() {\n        String hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n        String containerMountPath = \"/mnt/host-data\";\n\n        Volume volume =\n                Volume.builder()\n                        .name(\"test-host-vol\")\n                        .host(Host.of(hostDir))\n                        .mountPath(containerMountPath)\n                        .readOnly(false)\n                        .build();\n\n        Sandbox volumeSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .volume(volume)\n                        .build();\n\n        try {\n            assertTrue(volumeSandbox.isHealthy(), \"Volume sandbox should be healthy\");\n\n            // Step 1: Verify the host marker file is visible inside the sandbox\n            Execution readMarker =\n                    volumeSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/marker.txt\")\n                                            .build());\n            assertNull(readMarker.getError(), \"Failed to read marker file\");\n            assertEquals(1, readMarker.getLogs().getStdout().size());\n            assertEquals(\n                    \"opensandbox-e2e-marker\", readMarker.getLogs().getStdout().get(0).getText());\n\n            // Step 2: Write a file from inside the sandbox to the mounted path\n            Execution writeResult =\n                    volumeSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"echo 'written-from-sandbox' > \"\n                                                            + containerMountPath\n                                                            + \"/sandbox-output.txt\")\n                                            .build());\n            assertNull(writeResult.getError(), \"Failed to write file\");\n\n            // Step 3: Verify the written file is readable\n            Execution readBack =\n                    volumeSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"cat \"\n                                                            + containerMountPath\n                                                            + \"/sandbox-output.txt\")\n                                            .build());\n            assertNull(readBack.getError());\n            assertEquals(1, readBack.getLogs().getStdout().size());\n            assertEquals(\"written-from-sandbox\", readBack.getLogs().getStdout().get(0).getText());\n\n            // Step 4: Verify the mount path is a proper directory\n            Execution dirCheck =\n                    volumeSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"test -d \" + containerMountPath)\n                                            .build());\n            assertNull(dirCheck.getError());\n        } finally {\n            try {\n                volumeSandbox.kill();\n            } catch (Exception ignored) {\n            }\n            volumeSandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with host volume mount (read-only)\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithHostVolumeMountReadOnly() {\n        String hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n        String containerMountPath = \"/mnt/host-data-ro\";\n\n        Volume volume =\n                Volume.builder()\n                        .name(\"test-host-vol-ro\")\n                        .host(Host.of(hostDir))\n                        .mountPath(containerMountPath)\n                        .readOnly(true)\n                        .build();\n\n        Sandbox roSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .volume(volume)\n                        .build();\n\n        try {\n            assertTrue(roSandbox.isHealthy(), \"Read-only volume sandbox should be healthy\");\n\n            // Step 1: Verify the host marker file is readable\n            Execution readMarker =\n                    roSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/marker.txt\")\n                                            .build());\n            assertNull(readMarker.getError(), \"Failed to read marker file on read-only mount\");\n            assertEquals(1, readMarker.getLogs().getStdout().size());\n            assertEquals(\n                    \"opensandbox-e2e-marker\", readMarker.getLogs().getStdout().get(0).getText());\n\n            // Step 2: Verify writing is denied on read-only mount\n            Execution writeResult =\n                    roSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"touch \"\n                                                            + containerMountPath\n                                                            + \"/should-fail.txt\")\n                                            .build());\n            assertNotNull(writeResult.getError(), \"Write should fail on read-only mount\");\n        } finally {\n            try {\n                roSandbox.kill();\n            } catch (Exception ignored) {\n            }\n            roSandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with PVC named volume mount (read-write)\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithPvcVolumeMount() {\n        String pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        String containerMountPath = \"/mnt/pvc-data\";\n\n        Volume volume =\n                Volume.builder()\n                        .name(\"test-pvc-vol\")\n                        .pvc(PVC.of(pvcVolumeName))\n                        .mountPath(containerMountPath)\n                        .readOnly(false)\n                        .build();\n\n        Sandbox pvcSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .volume(volume)\n                        .build();\n\n        try {\n            assertTrue(pvcSandbox.isHealthy(), \"PVC volume sandbox should be healthy\");\n\n            // Step 1: Verify the marker file seeded into the named volume is readable\n            Execution readMarker =\n                    pvcSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/marker.txt\")\n                                            .build());\n            assertNull(readMarker.getError(), \"Failed to read marker file from PVC volume\");\n            assertEquals(1, readMarker.getLogs().getStdout().size());\n            assertEquals(\"pvc-marker-data\", readMarker.getLogs().getStdout().get(0).getText());\n\n            // Step 2: Write a file from inside the sandbox to the named volume\n            Execution writeResult =\n                    pvcSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"echo 'written-to-pvc' > \"\n                                                            + containerMountPath\n                                                            + \"/pvc-output.txt\")\n                                            .build());\n            assertNull(writeResult.getError(), \"Failed to write file to PVC volume\");\n\n            // Step 3: Verify the written file is readable\n            Execution readBack =\n                    pvcSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"cat \" + containerMountPath + \"/pvc-output.txt\")\n                                            .build());\n            assertNull(readBack.getError());\n            assertEquals(1, readBack.getLogs().getStdout().size());\n            assertEquals(\"written-to-pvc\", readBack.getLogs().getStdout().get(0).getText());\n\n            // Step 4: Verify the mount path is a proper directory\n            Execution dirCheck =\n                    pvcSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"test -d \" + containerMountPath)\n                                            .build());\n            assertNull(dirCheck.getError());\n        } finally {\n            try {\n                pvcSandbox.kill();\n            } catch (Exception ignored) {\n            }\n            pvcSandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with PVC named volume mount (read-only)\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithPvcVolumeMountReadOnly() {\n        String pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        String containerMountPath = \"/mnt/pvc-data-ro\";\n\n        Volume volume =\n                Volume.builder()\n                        .name(\"test-pvc-vol-ro\")\n                        .pvc(PVC.of(pvcVolumeName))\n                        .mountPath(containerMountPath)\n                        .readOnly(true)\n                        .build();\n\n        Sandbox roSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .volume(volume)\n                        .build();\n\n        try {\n            assertTrue(roSandbox.isHealthy(), \"Read-only PVC volume sandbox should be healthy\");\n\n            // Step 1: Verify the marker file is readable\n            Execution readMarker =\n                    roSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/marker.txt\")\n                                            .build());\n            assertNull(readMarker.getError(), \"Failed to read marker file on read-only PVC mount\");\n            assertEquals(1, readMarker.getLogs().getStdout().size());\n            assertEquals(\"pvc-marker-data\", readMarker.getLogs().getStdout().get(0).getText());\n\n            // Step 2: Verify writing is denied on read-only mount\n            Execution writeResult =\n                    roSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"touch \"\n                                                            + containerMountPath\n                                                            + \"/should-fail.txt\")\n                                            .build());\n            assertNotNull(writeResult.getError(), \"Write should fail on read-only PVC mount\");\n        } finally {\n            try {\n                roSandbox.kill();\n            } catch (Exception ignored) {\n            }\n            roSandbox.close();\n        }\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"Sandbox create with PVC named volume subPath mount\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testSandboxCreateWithPvcVolumeMountSubPath() {\n        String pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n        String containerMountPath = \"/mnt/train\";\n\n        Volume volume =\n                Volume.builder()\n                        .name(\"test-pvc-subpath\")\n                        .pvc(PVC.of(pvcVolumeName))\n                        .mountPath(containerMountPath)\n                        .readOnly(false)\n                        .subPath(\"datasets/train\")\n                        .build();\n\n        Sandbox subpathSandbox =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .timeout(Duration.ofMinutes(2))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .volume(volume)\n                        .build();\n\n        try {\n            assertTrue(subpathSandbox.isHealthy(), \"PVC subPath sandbox should be healthy\");\n\n            // Step 1: Verify the subpath marker file is readable\n            Execution readMarker =\n                    subpathSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/marker.txt\")\n                                            .build());\n            assertNull(readMarker.getError(), \"Failed to read subpath marker file\");\n            assertEquals(1, readMarker.getLogs().getStdout().size());\n            assertEquals(\"pvc-subpath-marker\", readMarker.getLogs().getStdout().get(0).getText());\n\n            // Step 2: Verify only subPath contents are visible (not the full volume)\n            Execution lsResult =\n                    subpathSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"ls \" + containerMountPath + \"/\")\n                                            .build());\n            assertNull(lsResult.getError());\n            String lsOutput =\n                    lsResult.getLogs().getStdout().stream()\n                            .map(m -> m.getText())\n                            .reduce(\"\", (a, b) -> a + \"\\n\" + b);\n            assertTrue(lsOutput.contains(\"marker.txt\"), \"Should contain marker.txt\");\n            assertFalse(lsOutput.contains(\"datasets\"), \"Should not contain datasets dir\");\n\n            // Step 3: Write a file and verify\n            Execution writeResult =\n                    subpathSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\n                                                    \"echo 'subpath-write-test' > \"\n                                                            + containerMountPath\n                                                            + \"/output.txt\")\n                                            .build());\n            assertNull(writeResult.getError(), \"Failed to write file to PVC subPath\");\n\n            Execution readBack =\n                    subpathSandbox\n                            .commands()\n                            .run(\n                                    RunCommandRequest.builder()\n                                            .command(\"cat \" + containerMountPath + \"/output.txt\")\n                                            .build());\n            assertNull(readBack.getError());\n            assertEquals(1, readBack.getLogs().getStdout().size());\n            assertEquals(\"subpath-write-test\", readBack.getLogs().getStdout().get(0).getText());\n        } finally {\n            try {\n                subpathSandbox.kill();\n            } catch (Exception ignored) {\n            }\n            subpathSandbox.close();\n        }\n    }\n\n    // ==========================================\n    // Command Execution Tests\n    // ==========================================\n\n    @Test\n    @Order(3)\n    @DisplayName(\"Command execution: success, cwd, background, failure\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testBasicCommandExecution() {\n        assertNotNull(sandbox);\n\n        List<OutputMessage> stdoutMessages = Collections.synchronizedList(new ArrayList<>());\n        List<OutputMessage> stderrMessages = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionResult> results = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionInit> initEvents = Collections.synchronizedList(new ArrayList<>());\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onStdout(\n                                (OutputMessage msg) -> {\n                                    stdoutMessages.add(msg);\n                                    logger.info(\"Stdout: {}\", msg.getText());\n                                })\n                        .onStderr(\n                                (OutputMessage msg) -> {\n                                    stderrMessages.add(msg);\n                                    logger.warn(\"Stderr: {}\", msg.getText());\n                                })\n                        .onResult(\n                                (ExecutionResult result) -> {\n                                    results.add(result);\n                                })\n                        .onExecutionComplete(\n                                (ExecutionComplete complete) -> {\n                                    completedEvents.add(complete);\n                                })\n                        .onError(\n                                (ExecutionError error) -> {\n                                    errors.add(error);\n                                })\n                        .onInit(\n                                (ExecutionInit init) -> {\n                                    initEvents.add(init);\n                                })\n                        .build();\n\n        RunCommandRequest echoRequest =\n                RunCommandRequest.builder()\n                        .command(\"echo 'Hello OpenSandbox E2E'\")\n                        .handlers(handlers)\n                        .build();\n        Execution echoResult = sandbox.commands().run(echoRequest);\n\n        assertNotNull(echoResult);\n        assertNotNull(echoResult.getId());\n        assertFalse(echoResult.getId().isBlank());\n        assertNull(echoResult.getError());\n        assertEquals(1, echoResult.getLogs().getStdout().size());\n        assertEquals(\"Hello OpenSandbox E2E\", echoResult.getLogs().getStdout().get(0).getText());\n        assertFalse(echoResult.getLogs().getStdout().get(0).isError());\n        assertRecentTimestampMs(echoResult.getLogs().getStdout().get(0).getTimestamp(), 60_000);\n        assertEquals(0, echoResult.getLogs().getStderr().size());\n\n        assertTerminalEventContract(initEvents, completedEvents, errors, echoResult.getId());\n        assertEquals(1, stdoutMessages.size());\n        assertEquals(\"Hello OpenSandbox E2E\", stdoutMessages.get(0).getText());\n        assertFalse(stdoutMessages.get(0).isError());\n        assertRecentTimestampMs(stdoutMessages.get(0).getTimestamp(), 60_000);\n        assertTrue(stderrMessages.isEmpty());\n\n        RunCommandRequest pwdRequest =\n                RunCommandRequest.builder().command(\"pwd\").workingDirectory(\"/tmp\").build();\n\n        Execution pwdResult = sandbox.commands().run(pwdRequest);\n        assertNotNull(pwdResult);\n        assertNotNull(pwdResult.getId());\n        assertNull(pwdResult.getError());\n        assertEquals(1, pwdResult.getLogs().getStdout().size());\n        assertEquals(\"/tmp\", pwdResult.getLogs().getStdout().get(0).getText());\n        assertFalse(pwdResult.getLogs().getStdout().get(0).isError());\n        assertRecentTimestampMs(pwdResult.getLogs().getStdout().get(0).getTimestamp(), 60_000);\n\n        long startTime = System.currentTimeMillis();\n        RunCommandRequest backgroundRequest =\n                RunCommandRequest.builder().command(\"sleep 30\").background(true).build();\n\n        sandbox.commands().run(backgroundRequest);\n        long endTime = System.currentTimeMillis();\n\n        long executionTime = endTime - startTime;\n        assertTrue(\n                executionTime < 10000,\n                String.format(\n                        \"Background command should return quickly, but took %d ms\", executionTime));\n\n        // Failure case: contract error OR complete (mutually exclusive) and error must be present.\n        stdoutMessages.clear();\n        stderrMessages.clear();\n        results.clear();\n        errors.clear();\n        completedEvents.clear();\n        initEvents.clear();\n        RunCommandRequest failRequest =\n                RunCommandRequest.builder()\n                        .command(\"nonexistent-command-that-does-not-exist\")\n                        .handlers(handlers)\n                        .build();\n        Execution failResult = sandbox.commands().run(failRequest);\n        assertNotNull(failResult);\n        assertNotNull(failResult.getId());\n        assertFalse(failResult.getId().isBlank());\n        assertNotNull(failResult.getError());\n        assertEquals(\"CommandExecError\", failResult.getError().getName());\n        assertTrue(failResult.getLogs().getStderr().size() > 0);\n        assertTrue(\n                failResult.getLogs().getStderr().stream()\n                        .anyMatch(\n                                m ->\n                                        m.getText()\n                                                .contains(\n                                                        \"nonexistent-command-that-does-not-exist\")));\n        assertTrue(failResult.getLogs().getStderr().stream().allMatch(OutputMessage::isError));\n        assertRecentTimestampMs(failResult.getLogs().getStderr().get(0).getTimestamp(), 60_000);\n\n        assertTerminalEventContract(initEvents, completedEvents, errors, failResult.getId());\n        assertTrue(completedEvents.isEmpty(), \"Failing command should not emit completion event\");\n    }\n\n    @Test\n    @Order(4)\n    @DisplayName(\"Command execution with env injection\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testRunCommandWithEnvInjection() {\n        assertNotNull(sandbox);\n\n        String envKey = \"OPEN_SANDBOX_E2E_CMD_ENV\";\n        String envValue = \"env-ok-\" + System.currentTimeMillis();\n        String probeCommand =\n                \"sh -c 'if [ -z \\\"${\"\n                        + envKey\n                        + \"-}\\\" ]; then echo \\\"__EMPTY__\\\"; else echo \\\"${\"\n                        + envKey\n                        + \"}\\\"; fi'\";\n\n        // Baseline: variable should be empty when not injected.\n        Execution baseline =\n                sandbox.commands().run(RunCommandRequest.builder().command(probeCommand).build());\n        assertNotNull(baseline);\n        assertNull(baseline.getError());\n        String baselineOutput =\n                baseline.getLogs().getStdout().stream()\n                        .map(OutputMessage::getText)\n                        .reduce(\"\", (a, b) -> a.isEmpty() ? b : a + \"\\n\" + b)\n                        .trim();\n        assertEquals(\"__EMPTY__\", baselineOutput);\n\n        // Inject env vars for this command and verify visibility.\n        Execution injected =\n                sandbox.commands()\n                        .run(\n                                RunCommandRequest.builder()\n                                        .command(probeCommand)\n                                        .env(envKey, envValue)\n                                        .env(\"OPEN_SANDBOX_E2E_SECOND_ENV\", \"second-ok\")\n                                        .build());\n        assertNotNull(injected);\n        assertNull(injected.getError());\n        String injectedOutput =\n                injected.getLogs().getStdout().stream()\n                        .map(OutputMessage::getText)\n                        .reduce(\"\", (a, b) -> a.isEmpty() ? b : a + \"\\n\" + b)\n                        .trim();\n        assertEquals(envValue, injectedOutput);\n    }\n\n    // ==========================================\n    // Filesystem Operations Tests\n    // ==========================================\n\n    @Test\n    @Order(4)\n    @DisplayName(\"Command status + background logs\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testCommandStatusAndLogs() throws Exception {\n        assertNotNull(sandbox);\n\n        RunCommandRequest backgroundRequest =\n                RunCommandRequest.builder()\n                        .command(\"sh -c 'echo log-line-1; echo log-line-2; sleep 2'\")\n                        .background(true)\n                        .build();\n        Execution exec = sandbox.commands().run(backgroundRequest);\n        assertNotNull(exec.getId());\n        String commandId = exec.getId();\n\n        CommandStatus status = sandbox.commands().getCommandStatus(commandId);\n        String statusId = status.getId();\n        Boolean runningValue = status.getRunning();\n        assertEquals(commandId, statusId);\n        assertNotNull(runningValue);\n\n        StringBuilder logsText = new StringBuilder();\n        Long cursor = null;\n        for (int i = 0; i < 20; i++) {\n            CommandLogs logs = sandbox.commands().getBackgroundCommandLogs(commandId, cursor);\n            String content = logs.getContent();\n            cursor = logs.getCursor();\n            logsText.append(content);\n            if (logsText.toString().contains(\"log-line-2\")) {\n                break;\n            }\n            Thread.sleep(1000);\n        }\n\n        assertTrue(logsText.toString().contains(\"log-line-1\"));\n        assertTrue(logsText.toString().contains(\"log-line-2\"));\n    }\n\n    @Test\n    @Order(5)\n    @DisplayName(\"Filesystem operations: CRUD + replace/move/delete + mtime checks\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testBasicFilesystemOperations() {\n        assertNotNull(sandbox);\n        String testDir1 = \"/tmp/fs_test1_\" + System.currentTimeMillis();\n        String testDir2 = \"/tmp/fs_test2_\" + System.currentTimeMillis();\n\n        WriteEntry dirEntry1 = WriteEntry.builder().path(testDir1).mode(755).build();\n        WriteEntry dirEntry2 = WriteEntry.builder().path(testDir2).mode(644).build();\n\n        sandbox.files().createDirectories(List.of(dirEntry1, dirEntry2));\n\n        Map<String, EntryInfo> dirInfo = sandbox.files().readFileInfo(List.of(testDir1, testDir2));\n        assertEquals(testDir1, dirInfo.get(testDir1).getPath());\n        assertEquals(755, dirInfo.get(testDir1).getMode());\n        assertTimesClose(\n                dirInfo.get(testDir1).getCreatedAt(), dirInfo.get(testDir1).getModifiedAt(), 2);\n\n        Execution lsResult =\n                sandbox.commands()\n                        .run(\n                                RunCommandRequest.builder()\n                                        .command(\"ls -la |grep fs_test\")\n                                        .workingDirectory(\"/tmp\")\n                                        .build());\n\n        assertEquals(2, lsResult.getLogs().getStdout().size());\n\n        String testFile1 = testDir1 + \"/test_file1.txt\";\n        String testFile2 = testDir1 + \"/test_file2.txt\";\n        String testFile3 = testDir1 + \"/test_file3.txt\";\n        String testContent = \"Hello Filesystem!\\nLine 2 with special chars: åäö\\nLine 3\";\n\n        WriteEntry writeEntry1 =\n                WriteEntry.builder().path(testFile1).data(testContent).mode(644).build();\n        WriteEntry writeEntry2 =\n                WriteEntry.builder()\n                        .path(testFile2)\n                        .data(testContent.getBytes(StandardCharsets.UTF_8))\n                        .mode(755)\n                        .build();\n        WriteEntry writeEntry3 =\n                WriteEntry.builder()\n                        .path(testFile3)\n                        .data(\n                                new ByteArrayInputStream(\n                                        testContent.getBytes(StandardCharsets.UTF_8)))\n                        .group(\"nogroup\")\n                        .owner(\"nobody\")\n                        .mode(755)\n                        .build();\n\n        sandbox.files().write(List.of(writeEntry1, writeEntry2, writeEntry3));\n\n        String readContent1 =\n                sandbox.files().readFile(testFile1, StandardCharsets.UTF_8.name(), null);\n        String readContent1Partial =\n                sandbox.files().readFile(testFile1, StandardCharsets.UTF_8.name(), \"bytes=0-9\");\n\n        byte[] readBytes2 = sandbox.files().readByteArray(testFile2, null);\n        String readContent2 = new String(readBytes2, StandardCharsets.UTF_8);\n\n        try (java.io.InputStream inputStream = sandbox.files().readStream(testFile3, null)) {\n            byte[] streamBytes = inputStream.readAllBytes();\n            String readContent3 = new String(streamBytes, StandardCharsets.UTF_8);\n\n            // Verify content matches original for all files\n            assertEquals(testContent, readContent1, \"Content of testFile1 should match\");\n            assertEquals(testContent, readContent2, \"Content of testFile2 should match\");\n            assertEquals(testContent, readContent3, \"Content of testFile3 should match\");\n\n            // Verify partial read works correctly\n            assertEquals(\n                    testContent.substring(0, 10),\n                    readContent1Partial,\n                    \"Partial read should match first 10 characters\");\n        } catch (java.io.IOException e) {\n            throw new RuntimeException(\"Failed to read stream\", e);\n        }\n\n        List<String> allTestFiles = List.of(testFile1, testFile2, testFile3);\n        Map<String, EntryInfo> fileInfoMap = sandbox.files().readFileInfo(allTestFiles);\n        long expectedSize = testContent.getBytes(StandardCharsets.UTF_8).length;\n\n        EntryInfo fileInfo1 = fileInfoMap.get(testFile1);\n        assertNotNull(fileInfo1, \"FileInfo for testFile1 should not be null\");\n        assertEquals(testFile1, fileInfo1.getPath());\n        assertEquals(expectedSize, fileInfo1.getSize(), \"File1 size should match content length\");\n        assertEquals(644, fileInfo1.getMode(), \"File1 mode should be 644\");\n        assertNotNull(fileInfo1.getOwner(), \"File1 owner should not be null\");\n        assertNotNull(fileInfo1.getGroup(), \"File1 group should not be null\");\n        assertTimesClose(fileInfo1.getCreatedAt(), fileInfo1.getModifiedAt(), 2);\n\n        EntryInfo fileInfo2 = fileInfoMap.get(testFile2);\n        assertNotNull(fileInfo2, \"FileInfo for testFile2 should not be null\");\n        assertEquals(testFile2, fileInfo2.getPath());\n        assertEquals(expectedSize, fileInfo2.getSize(), \"File2 size should match content length\");\n        assertEquals(755, fileInfo2.getMode(), \"File2 mode should be 755\");\n        assertNotNull(fileInfo2.getOwner(), \"File2 owner should not be null\");\n        assertNotNull(fileInfo2.getGroup(), \"File2 group should not be null\");\n        assertTimesClose(fileInfo2.getCreatedAt(), fileInfo2.getModifiedAt(), 2);\n\n        EntryInfo fileInfo3 = fileInfoMap.get(testFile3);\n        assertNotNull(fileInfo3, \"FileInfo for testFile3 should not be null\");\n        assertEquals(testFile3, fileInfo3.getPath());\n        assertEquals(expectedSize, fileInfo3.getSize(), \"File3 size should match content length\");\n        assertEquals(755, fileInfo3.getMode(), \"File3 mode should be 755\");\n        assertEquals(\"nobody\", fileInfo3.getOwner(), \"File3 owner should be nobody\");\n        assertEquals(\"nogroup\", fileInfo3.getGroup(), \"File3 group should be nogroup\");\n        assertTimesClose(fileInfo3.getCreatedAt(), fileInfo3.getModifiedAt(), 2);\n\n        SearchEntry searchAllEntry = SearchEntry.builder().path(testDir1).pattern(\"*\").build();\n        Set<String> found = new HashSet<>();\n        for (EntryInfo e : sandbox.files().search(searchAllEntry)) {\n            found.add(e.getPath());\n        }\n        assertEquals(Set.of(testFile1, testFile2, testFile3), found);\n\n        SetPermissionEntry permEntry1 =\n                SetPermissionEntry.builder()\n                        .path(testFile1)\n                        .mode(755)\n                        .owner(\"nobody\")\n                        .group(\"nogroup\")\n                        .build();\n        SetPermissionEntry permEntry2 =\n                SetPermissionEntry.builder()\n                        .path(testFile2)\n                        .mode(600)\n                        .owner(\"nobody\")\n                        .group(\"nogroup\")\n                        .build();\n        sandbox.files().setPermissions(List.of(permEntry1, permEntry2));\n\n        // Verify permission changes for both files in single call\n        Map<String, EntryInfo> updatedInfoMap =\n                sandbox.files().readFileInfo(List.of(testFile1, testFile2));\n        EntryInfo updatedInfo1 = updatedInfoMap.get(testFile1);\n        EntryInfo updatedInfo2 = updatedInfoMap.get(testFile2);\n\n        assertNotNull(updatedInfo1, \"Updated info for testFile1 should not be null\");\n        assertEquals(755, updatedInfo1.getMode(), \"testFile1 mode should be updated to 755\");\n        assertEquals(\n                \"nobody\", updatedInfo1.getOwner(), \"testFile1 owner should be updated to nobody\");\n        assertEquals(\n                \"nogroup\", updatedInfo1.getGroup(), \"testFile1 group should be updated to nogroup\");\n\n        assertNotNull(updatedInfo2, \"Updated info for testFile2 should not be null\");\n        assertEquals(600, updatedInfo2.getMode(), \"testFile2 mode should be updated to 600\");\n        assertEquals(\n                \"nobody\", updatedInfo2.getOwner(), \"testFile2 owner should be updated to nobody\");\n        assertEquals(\n                \"nogroup\", updatedInfo2.getGroup(), \"testFile2 group should be updated to nogroup\");\n\n        EntryInfo beforeUpdate = sandbox.files().readFileInfo(List.of(testFile1)).get(testFile1);\n        String updatedContent1 = testContent + \"\\nAppended line to file1\";\n        String updatedContent2 = testContent + \"\\nAppended line to file2\";\n        try {\n            Thread.sleep(50);\n        } catch (InterruptedException ignored) {\n        }\n        WriteEntry updateEntry1 =\n                WriteEntry.builder().path(testFile1).data(updatedContent1).mode(644).build();\n        WriteEntry updateEntry2 =\n                WriteEntry.builder().path(testFile2).data(updatedContent2).mode(755).build();\n        sandbox.files().write(List.of(updateEntry1, updateEntry2));\n\n        String newContent1 = sandbox.files().readFile(testFile1, \"UTF-8\", null);\n        String newContent2 = sandbox.files().readFile(testFile2, \"UTF-8\", null);\n        assertEquals(updatedContent1, newContent1);\n        assertEquals(updatedContent2, newContent2);\n\n        EntryInfo afterUpdate = sandbox.files().readFileInfo(List.of(testFile1)).get(testFile1);\n        assertEquals(\n                updatedContent1.getBytes(StandardCharsets.UTF_8).length, afterUpdate.getSize());\n        assertModifiedUpdated(beforeUpdate.getModifiedAt(), afterUpdate.getModifiedAt(), 1, 1000);\n\n        // Replace contents\n        EntryInfo beforeReplace = afterUpdate;\n        try {\n            Thread.sleep(50);\n        } catch (InterruptedException ignored) {\n        }\n        sandbox.files()\n                .replaceContents(\n                        List.of(\n                                ContentReplaceEntry.builder()\n                                        .path(testFile1)\n                                        .oldContent(\"Appended line to file1\")\n                                        .newContent(\"Replaced line in file1\")\n                                        .build()));\n        String replaced = sandbox.files().readFile(testFile1, \"UTF-8\", null);\n        assertTrue(replaced.contains(\"Replaced line in file1\"));\n        assertFalse(replaced.contains(\"Appended line to file1\"));\n        EntryInfo afterReplace = sandbox.files().readFileInfo(List.of(testFile1)).get(testFile1);\n        assertModifiedUpdated(beforeReplace.getModifiedAt(), afterReplace.getModifiedAt(), 1, 1000);\n\n        // Move file3\n        String movedPath = testDir2 + \"/moved_file3.txt\";\n        sandbox.files()\n                .moveFiles(List.of(MoveEntry.builder().src(testFile3).dest(movedPath).build()));\n        String moved =\n                new String(sandbox.files().readByteArray(movedPath, null), StandardCharsets.UTF_8);\n        assertEquals(testContent, moved);\n        assertThrows(Exception.class, () -> sandbox.files().readByteArray(testFile3, null));\n\n        // Delete file2\n        sandbox.files().deleteFiles(List.of(testFile2));\n        assertThrows(Exception.class, () -> sandbox.files().readFile(testFile2, \"UTF-8\", null));\n        Set<String> after = new HashSet<>();\n        for (EntryInfo e :\n                sandbox.files().search(SearchEntry.builder().path(testDir1).pattern(\"*\").build())) {\n            after.add(e.getPath());\n        }\n        assertEquals(Set.of(testFile1), after);\n\n        // Delete directories\n        sandbox.files().deleteDirectories(List.of(testDir1, testDir2));\n        Execution verify =\n                sandbox.commands()\n                        .run(\n                                RunCommandRequest.builder()\n                                        .command(\n                                                \"test ! -d \"\n                                                        + testDir1\n                                                        + \" && test ! -d \"\n                                                        + testDir2\n                                                        + \" && echo OK\")\n                                        .workingDirectory(\"/tmp\")\n                                        .build());\n        assertNull(verify.getError());\n        assertEquals(1, verify.getLogs().getStdout().size());\n        assertEquals(\"OK\", verify.getLogs().getStdout().get(0).getText());\n    }\n\n    @Test\n    @Order(6)\n    @DisplayName(\"Interrupt command\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testInterruptCommand() throws Exception {\n        assertNotNull(sandbox);\n\n        List<ExecutionInit> initEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionComplete> completedEvents = Collections.synchronizedList(new ArrayList<>());\n        List<ExecutionError> errors = Collections.synchronizedList(new ArrayList<>());\n        CountDownLatch initLatch = new CountDownLatch(1);\n\n        ExecutionHandlers handlers =\n                ExecutionHandlers.builder()\n                        .onInit(\n                                (ExecutionInit init) -> {\n                                    initEvents.add(init);\n                                    initLatch.countDown();\n                                })\n                        .onExecutionComplete(completedEvents::add)\n                        .onError(errors::add)\n                        .build();\n\n        ExecutorService ex = Executors.newSingleThreadExecutor();\n        long start = System.currentTimeMillis();\n        Future<Execution> future =\n                ex.submit(\n                        () ->\n                                sandbox.commands()\n                                        .run(\n                                                RunCommandRequest.builder()\n                                                        .command(\"sleep 30\")\n                                                        .handlers(handlers)\n                                                        .build()));\n        assertTrue(initLatch.await(15, TimeUnit.SECONDS), \"did not receive init event\");\n        assertEquals(1, initEvents.size());\n        String id = initEvents.get(0).getId();\n        assertNotNull(id);\n        Thread.sleep(2000);\n        sandbox.commands().interrupt(id);\n        Execution result = future.get(30, TimeUnit.SECONDS);\n        long elapsed = System.currentTimeMillis() - start;\n        assertNotNull(result);\n        assertEquals(id, result.getId());\n        assertTrue(elapsed < 20_000, \"Interrupted command took too long: \" + elapsed + \"ms\");\n        assertTrue((!completedEvents.isEmpty()) ^ (!errors.isEmpty()));\n        assertTrue(result.getError() != null || !result.getLogs().getStderr().isEmpty());\n        ex.shutdownNow();\n    }\n\n    @Test\n    @Order(7)\n    @DisplayName(\"Sandbox Pause Operation\")\n    @Timeout(value = 5, unit = TimeUnit.MINUTES)\n    void testSandboxPause() throws InterruptedException {\n        assertNotNull(sandbox);\n\n        Thread.sleep(20000);\n        sandbox.pause();\n\n        int pollCount = 0;\n        SandboxStatus finalStatus = null;\n\n        while (pollCount < 300) {\n            Thread.sleep(1000);\n            pollCount++;\n\n            SandboxInfo info = sandbox.getInfo();\n            SandboxStatus currentStatus = info.getStatus();\n            if (\"Pausing\".equals(currentStatus.getState())) {\n                continue;\n            }\n            finalStatus = currentStatus;\n            break;\n        }\n\n        assertNotNull(finalStatus, \"Failed to get final status after resume operation\");\n        assertEquals(\"Paused\", finalStatus.getState(), \"Sandbox should be in Paused state\");\n\n        // pause => unhealthy\n        boolean healthy = true;\n        for (int i = 0; i < 10; i++) {\n            healthy = sandbox.isHealthy();\n            if (!healthy) break;\n            Thread.sleep(500);\n        }\n        assertFalse(healthy, \"Sandbox should be unhealthy after pause\");\n    }\n\n    @Test\n    @Order(8)\n    @DisplayName(\"Sandbox Resume Operation\")\n    @Timeout(value = 3, unit = TimeUnit.MINUTES)\n    void testSandboxResume() throws InterruptedException {\n        assertNotNull(sandbox);\n\n        Sandbox resumedSandbox =\n                Sandbox.resumer()\n                        .sandboxId(sandbox.getId())\n                        .connectionConfig(sharedConnectionConfig)\n                        .resumeTimeout(Duration.ofMinutes(1))\n                        .healthCheckPollingInterval(Duration.ofSeconds(1))\n                        .resume();\n\n        SandboxStatus status = resumedSandbox.getInfo().getStatus();\n\n        assertNotNull(status, \"Failed to get final status after resume operation\");\n        assertEquals(\"Running\", status.getState());\n\n        boolean healthy = false;\n        for (int i = 0; i < 30; i++) {\n            healthy = sandbox.isHealthy();\n            if (healthy) break;\n            Thread.sleep(1000);\n        }\n        assertTrue(healthy, \"Sandbox should be healthy after resume\");\n    }\n\n    @Test\n    @Order(9)\n    @DisplayName(\"X-Request-ID passthrough on server error\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testXRequestIdPassthroughOnServerError() {\n        String requestId = \"e2e-java-server-\" + System.currentTimeMillis();\n        String missingSandboxId = \"missing-\" + requestId;\n\n        ConnectionConfig cfg =\n                ConnectionConfig.builder()\n                        .apiKey(sharedConnectionConfig.getApiKey())\n                        .domain(sharedConnectionConfig.getDomain())\n                        .protocol(sharedConnectionConfig.getProtocol())\n                        .requestTimeout(sharedConnectionConfig.getRequestTimeout())\n                        .headers(Map.of(\"X-Request-ID\", requestId))\n                        .build();\n\n        SandboxApiException ex =\n                assertThrows(\n                        SandboxApiException.class,\n                        () -> {\n                            Sandbox connected =\n                                    Sandbox.connector()\n                                            .connectionConfig(cfg)\n                                            .sandboxId(missingSandboxId)\n                                            .connect();\n                            try {\n                                connected.getInfo();\n                            } finally {\n                                connected.close();\n                            }\n                        });\n        assertEquals(requestId, ex.getRequestId());\n    }\n}\n"
  },
  {
    "path": "tests/java/src/test/java/com/alibaba/opensandbox/e2e/SandboxManagerE2ETest.java",
    "content": "/*\n * Copyright 2025 Alibaba Group Holding Ltd.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.alibaba.opensandbox.e2e;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport com.alibaba.opensandbox.sandbox.Sandbox;\nimport com.alibaba.opensandbox.sandbox.SandboxManager;\nimport com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException;\nimport com.alibaba.opensandbox.sandbox.domain.models.sandboxes.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.*;\n\n/**\n * E2E tests for SandboxManager list/filter semantics.\n *\n * <p>Focus:\n *\n * <ul>\n *   <li>states filter uses OR logic\n *   <li>metadata filter uses AND logic\n * </ul>\n *\n * <p>We create 3 dedicated sandboxes per run to keep assertions deterministic and avoid impacting\n * the shared sandbox used by other tests.\n */\n@Tag(\"e2e\")\n@DisplayName(\"SandboxManager E2E Tests (Java SDK) - List/Filter Semantics\")\n@TestMethodOrder(MethodOrderer.OrderAnnotation.class)\npublic class SandboxManagerE2ETest extends BaseE2ETest {\n\n    private SandboxManager sandboxManager;\n    private Sandbox s1;\n    private Sandbox s2;\n    private Sandbox s3;\n    private String tag;\n\n    @BeforeAll\n    void setup() throws InterruptedException {\n        sandboxManager = SandboxManager.builder().connectionConfig(sharedConnectionConfig).build();\n        tag = \"e2e-sandbox-manager-\" + UUID.randomUUID().toString().substring(0, 8);\n        Map<String, String> resourceMap = new HashMap<>();\n        resourceMap.put(\"cpu\", \"1\");\n        resourceMap.put(\"memory\", \"2Gi\");\n\n        s1 =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .resource(resourceMap)\n                        .timeout(Duration.ofMinutes(5))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(Map.of(\"tag\", tag, \"team\", \"t1\", \"env\", \"prod\"))\n                        .env(\"E2E_TEST\", \"true\")\n                        .healthCheckPollingInterval(Duration.ofMillis(500))\n                        .build();\n        s2 =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .resource(resourceMap)\n                        .timeout(Duration.ofMinutes(5))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(Map.of(\"tag\", tag, \"team\", \"t1\", \"env\", \"dev\"))\n                        .env(\"E2E_TEST\", \"true\")\n                        .healthCheckPollingInterval(Duration.ofMillis(500))\n                        .build();\n        s3 =\n                Sandbox.builder()\n                        .connectionConfig(sharedConnectionConfig)\n                        .image(getSandboxImage())\n                        .resource(resourceMap)\n                        .timeout(Duration.ofMinutes(5))\n                        .readyTimeout(Duration.ofSeconds(60))\n                        .metadata(Map.of(\"tag\", tag, \"env\", \"prod\"))\n                        .env(\"E2E_TEST\", \"true\")\n                        .healthCheckPollingInterval(Duration.ofMillis(500))\n                        .build();\n\n        assertTrue(s1.isHealthy());\n        assertTrue(s2.isHealthy());\n        assertTrue(s3.isHealthy());\n\n        // Pause s3 to create a deterministic non-Running state.\n        sandboxManager.pauseSandbox(s3.getId());\n        long deadline = System.currentTimeMillis() + 180_000;\n        while (System.currentTimeMillis() < deadline) {\n            SandboxInfo info = sandboxManager.getSandboxInfo(s3.getId());\n            if (\"Paused\".equals(info.getStatus().getState())) {\n                break;\n            }\n            Thread.sleep(1000);\n        }\n        assertEquals(\"Paused\", sandboxManager.getSandboxInfo(s3.getId()).getStatus().getState());\n    }\n\n    @AfterAll\n    void teardown() {\n        for (Sandbox s : List.of(s1, s2, s3)) {\n            if (s == null) continue;\n            try {\n                s.kill();\n            } catch (Exception ignored) {\n            }\n            try {\n                s.close();\n            } catch (Exception ignored) {\n            }\n        }\n        if (sandboxManager != null) {\n            try {\n                sandboxManager.close();\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    @Test\n    @Order(1)\n    @DisplayName(\"states filter uses OR semantics\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testStatesFilterOrLogic() {\n        SandboxFilter filter =\n                SandboxFilter.builder()\n                        .states(\"Running\", \"Paused\")\n                        .metadata(Map.of(\"tag\", tag))\n                        .pageSize(50)\n                        .build();\n        PagedSandboxInfos infos = sandboxManager.listSandboxInfos(filter);\n        Set<String> ids = new HashSet<>();\n        for (SandboxInfo info : infos.getSandboxInfos()) {\n            ids.add(info.getId());\n        }\n        assertTrue(ids.containsAll(Set.of(s1.getId(), s2.getId(), s3.getId())));\n\n        PagedSandboxInfos pausedOnly =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .states(\"Paused\")\n                                .metadata(Map.of(\"tag\", tag))\n                                .pageSize(50)\n                                .build());\n        Set<String> pausedIds = new HashSet<>();\n        for (SandboxInfo info : pausedOnly.getSandboxInfos()) {\n            pausedIds.add(info.getId());\n        }\n        assertTrue(pausedIds.contains(s3.getId()));\n        assertFalse(pausedIds.contains(s1.getId()));\n        assertFalse(pausedIds.contains(s2.getId()));\n\n        PagedSandboxInfos runningOnly =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .states(\"Running\")\n                                .metadata(Map.of(\"tag\", tag))\n                                .pageSize(50)\n                                .build());\n        Set<String> runningIds = new HashSet<>();\n        for (SandboxInfo info : runningOnly.getSandboxInfos()) {\n            runningIds.add(info.getId());\n        }\n        assertTrue(runningIds.contains(s1.getId()));\n        assertTrue(runningIds.contains(s2.getId()));\n        assertFalse(runningIds.contains(s3.getId()));\n    }\n\n    @Test\n    @Order(2)\n    @DisplayName(\"metadata filter uses AND semantics\")\n    @Timeout(value = 2, unit = TimeUnit.MINUTES)\n    void testMetadataFilterAndLogic() {\n        PagedSandboxInfos tagAndTeam =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .metadata(Map.of(\"tag\", tag, \"team\", \"t1\"))\n                                .pageSize(50)\n                                .build());\n        Set<String> ids = new HashSet<>();\n        for (SandboxInfo info : tagAndTeam.getSandboxInfos()) {\n            ids.add(info.getId());\n        }\n        assertTrue(ids.contains(s1.getId()));\n        assertTrue(ids.contains(s2.getId()));\n        assertFalse(ids.contains(s3.getId()));\n\n        PagedSandboxInfos tagTeamEnv =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .metadata(Map.of(\"tag\", tag, \"team\", \"t1\", \"env\", \"prod\"))\n                                .pageSize(50)\n                                .build());\n        Set<String> ids2 = new HashSet<>();\n        for (SandboxInfo info : tagTeamEnv.getSandboxInfos()) {\n            ids2.add(info.getId());\n        }\n        assertTrue(ids2.contains(s1.getId()));\n        assertFalse(ids2.contains(s2.getId()));\n        assertFalse(ids2.contains(s3.getId()));\n\n        PagedSandboxInfos tagEnv =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .metadata(Map.of(\"tag\", tag, \"env\", \"prod\"))\n                                .pageSize(50)\n                                .build());\n        Set<String> ids3 = new HashSet<>();\n        for (SandboxInfo info : tagEnv.getSandboxInfos()) {\n            ids3.add(info.getId());\n        }\n        assertTrue(ids3.contains(s1.getId()));\n        assertTrue(ids3.contains(s3.getId()));\n        assertFalse(ids3.contains(s2.getId()));\n\n        PagedSandboxInfos noneMatch =\n                sandboxManager.listSandboxInfos(\n                        SandboxFilter.builder()\n                                .metadata(Map.of(\"tag\", tag, \"team\", \"t2\"))\n                                .pageSize(50)\n                                .build());\n        for (SandboxInfo info : noneMatch.getSandboxInfos()) {\n            assertFalse(Set.of(s1.getId(), s2.getId(), s3.getId()).contains(info.getId()));\n        }\n    }\n\n    @Test\n    @Order(3)\n    @DisplayName(\"invalid operations raise SandboxException\")\n    @Timeout(value = 1, unit = TimeUnit.MINUTES)\n    void testInvalidOperations() {\n        String nonExistentId = \"non-existent-\" + System.nanoTime();\n        assertThrows(SandboxException.class, () -> sandboxManager.getSandboxInfo(nonExistentId));\n        assertThrows(SandboxException.class, () -> sandboxManager.pauseSandbox(nonExistentId));\n        assertThrows(SandboxException.class, () -> sandboxManager.resumeSandbox(nonExistentId));\n        assertThrows(SandboxException.class, () -> sandboxManager.killSandbox(nonExistentId));\n        assertThrows(\n                SandboxException.class,\n                () -> sandboxManager.renewSandbox(nonExistentId, Duration.ofMinutes(5)));\n    }\n}\n"
  },
  {
    "path": "tests/java/src/test/resources/test.properties",
    "content": "# OpenSandbox E2E Test Configuration\n# Default values for local/CI runs. Override via editing this file or by providing your own build.\nopensandbox.test.domain=localhost:8080\nopensandbox.test.protocol=http\nopensandbox.test.api.key=e2e-test\nopensandbox.sandbox.default.image=sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\n"
  },
  {
    "path": "tests/javascript/README.md",
    "content": "# OpenSandbox JavaScript E2E Tests\n\nThis folder contains strict E2E tests for the JavaScript/TypeScript SDKs, aligned with `OpenSandbox/tests/python` and `OpenSandbox/tests/java`.\n\n## Prerequisites\n\n- Node.js (via nvm): **>= 20**\n- pnpm (via corepack or global install)\n- OpenSandbox server running\n\n## Environment variables\n\nThese tests follow the same naming as Python tests:\n\n- `OPENSANDBOX_TEST_DOMAIN` (default: `localhost:8080`)\n- `OPENSANDBOX_TEST_PROTOCOL` (default: `http`)\n- `OPENSANDBOX_TEST_API_KEY` (default: `e2e-test`)\n- `OPENSANDBOX_SANDBOX_DEFAULT_IMAGE` (default: code-interpreter image)\n\n## Run\n\n```bash\ncd OpenSandbox/tests/javascript\n\n# Node >= 20 is required (SDK engines: node >= 20)\nsource ~/.nvm/nvm.sh\nnvm use 22\n\n# Ensure pnpm is available (repo pins pnpm@9.x)\ncorepack enable\ncorepack prepare pnpm@9.15.0 --activate\n\n# Install test dependencies (vitest, typescript)\npnpm install\n\n# Run tests (also builds SDKs)\npnpm test\n```\n\n\n"
  },
  {
    "path": "tests/javascript/eslint.config.mjs",
    "content": "import js from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\n\nexport default tseslint.config(\n  {\n    ignores: [\"node_modules/**\", \"build/**\", \"**/*.d.ts\"],\n  },\n  js.configs.recommended,\n  ...tseslint.configs.recommended,\n  {\n    files: [\"**/*.ts\"],\n    languageOptions: {\n      parserOptions: {\n        // Keep tests lint lightweight: do not require type-aware linting.\n        // This avoids needing to include tool configs (e.g. vitest.config.ts) in tsconfig.\n      },\n      globals: {\n        console: \"readonly\",\n        process: \"readonly\",\n        setTimeout: \"readonly\",\n        clearTimeout: \"readonly\",\n      },\n    },\n    rules: {\n      \"@typescript-eslint/no-explicit-any\": \"off\",\n      \"@typescript-eslint/no-unused-vars\": [\"error\", { argsIgnorePattern: \"^_\", varsIgnorePattern: \"^_\" }],\n    },\n  },\n);\n\n"
  },
  {
    "path": "tests/javascript/package.json",
    "content": "{\n  \"name\": \"opensandbox-javascript-e2e-tests\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@9.15.0\",\n  \"scripts\": {\n    \"pretest\": \"pnpm install --prefer-offline\",\n    \"prep:sdk\": \"pnpm -C ../../sdks install --prefer-offline && pnpm -C ../../sdks run build:js\",\n    \"lint\": \"eslint . --max-warnings 0\",\n    \"test\": \"pnpm run prep:sdk && pnpm exec vitest run\",\n    \"pretest:ci\": \"pnpm install --prefer-offline\",\n    \"test:ci\": \"pnpm run prep:sdk && pnpm exec vitest run --reporter=default --reporter=junit --outputFile=build/test-results/junit.xml\"\n  },\n  \"dependencies\": {\n    \"@alibaba-group/opensandbox\": \"link:../../sdks/sandbox/javascript\",\n    \"@alibaba-group/opensandbox-code-interpreter\": \"link:../../sdks/code-interpreter/javascript\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"@types/node\": \"^20.11.30\",\n    \"eslint\": \"^9.39.2\",\n    \"typescript\": \"^5.7.2\",\n    \"typescript-eslint\": \"^8.52.0\",\n    \"vitest\": \"^2.1.9\"\n  }\n}\n"
  },
  {
    "path": "tests/javascript/tests/base_e2e.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { ConnectionConfig } from \"@alibaba-group/opensandbox\";\n\nexport const DEFAULT_DOMAIN = \"localhost:8080\";\nexport const DEFAULT_PROTOCOL = \"http\";\nexport const DEFAULT_API_KEY = \"e2e-test\";\nexport const DEFAULT_IMAGE =\n  \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\";\n\nexport const TEST_DOMAIN = process.env.OPENSANDBOX_TEST_DOMAIN ?? DEFAULT_DOMAIN;\nexport const TEST_PROTOCOL = process.env.OPENSANDBOX_TEST_PROTOCOL ?? DEFAULT_PROTOCOL;\nexport const TEST_API_KEY = process.env.OPENSANDBOX_TEST_API_KEY ?? DEFAULT_API_KEY;\nexport const TEST_IMAGE = process.env.OPENSANDBOX_SANDBOX_DEFAULT_IMAGE ?? DEFAULT_IMAGE;\n\nexport function getSandboxImage(): string {\n  return TEST_IMAGE;\n}\n\nexport function createConnectionConfig(useServerProxy = false): ConnectionConfig {\n  return new ConnectionConfig({\n    domain: TEST_DOMAIN,\n    protocol: TEST_PROTOCOL === \"https\" ? \"https\" : \"http\",\n    apiKey: TEST_API_KEY,\n    requestTimeoutSeconds: 180,\n    useServerProxy\n  });\n}\n\nexport function nowMs(): number {\n  return Date.now();\n}\n\nexport function assertRecentTimestampMs(ts: number, toleranceMs = 180_000): void {\n  if (typeof ts !== \"number\" || ts <= 0) throw new Error(`invalid timestamp: ${ts}`);\n  const delta = Math.abs(nowMs() - ts);\n  if (delta > toleranceMs) {\n    throw new Error(`timestamp too far from now: delta=${delta}ms (ts=${ts})`);\n  }\n}\n\nexport function assertEndpointHasPort(endpoint: string, expectedPort: number): void {\n  if (!endpoint) throw new Error(\"endpoint is empty\");\n  if (endpoint.includes(\"://\")) throw new Error(`unexpected scheme in endpoint: ${endpoint}`);\n\n  if (endpoint.includes(\"/\")) {\n    if (!endpoint.endsWith(`/${expectedPort}`)) {\n      throw new Error(`endpoint route must end with /${expectedPort}: ${endpoint}`);\n    }\n    const domain = endpoint.split(\"/\", 1)[0];\n    if (!domain) throw new Error(`missing domain in endpoint: ${endpoint}`);\n    return;\n  }\n\n  const idx = endpoint.lastIndexOf(\":\");\n  if (idx < 0) throw new Error(`missing :port in endpoint: ${endpoint}`);\n  const host = endpoint.slice(0, idx);\n  const port = endpoint.slice(idx + 1);\n  if (!host) throw new Error(`missing host in endpoint: ${endpoint}`);\n  if (!/^\\d+$/.test(port)) throw new Error(`non-numeric port in endpoint: ${endpoint}`);\n  if (Number(port) !== expectedPort) throw new Error(`endpoint port mismatch: ${endpoint} != :${expectedPort}`);\n}\n"
  },
  {
    "path": "tests/javascript/tests/test_code_interpreter_e2e.test.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { afterAll, beforeAll, beforeEach, expect, test } from \"vitest\";\n\nimport { Sandbox, type ExecutionHandlers } from \"@alibaba-group/opensandbox\";\n\nimport {\n  CodeInterpreter,\n  SupportedLanguages,\n} from \"@alibaba-group/opensandbox-code-interpreter\";\n\nimport {\n  assertEndpointHasPort,\n  assertRecentTimestampMs,\n  createConnectionConfig,\n  getSandboxImage,\n} from \"./base_e2e.ts\";\n\nlet sandbox: Sandbox | null = null;\nlet ci: CodeInterpreter | null = null;\n\n// ---------------------------------------------------------------------------\n// Helpers: sandbox lifecycle & retry\n// ---------------------------------------------------------------------------\n\nfunction sandboxCreateOptions() {\n  return {\n    connectionConfig: createConnectionConfig(),\n    image: getSandboxImage(),\n    entrypoint: [\"/opt/opensandbox/code-interpreter.sh\"],\n    timeoutSeconds: 15 * 60,\n    readyTimeoutSeconds: 60,\n    metadata: { tag: \"e2e-code-interpreter\" },\n    env: {\n      E2E_TEST: \"true\",\n      GO_VERSION: \"1.25\",\n      JAVA_VERSION: \"21\",\n      NODE_VERSION: \"22\",\n      PYTHON_VERSION: \"3.12\",\n      EXECD_LOG_FILE: \"/tmp/opensandbox-e2e/logs/execd.log\",\n    },\n    healthCheckPollingInterval: 200,\n    volumes: [\n      {\n        name: \"execd-log\",\n        host: { path: \"/tmp/opensandbox-e2e/logs\" },\n        mountPath: \"/tmp/opensandbox-e2e/logs\",\n        readOnly: false,\n      },\n    ],\n  };\n}\n\nasync function recreateSandbox() {\n  if (sandbox) {\n    try {\n      await sandbox.kill();\n    } catch {\n      /* ignore */\n    }\n  }\n  sandbox = await Sandbox.create(sandboxCreateOptions());\n  ci = await CodeInterpreter.create(sandbox);\n}\n\n/** Check sandbox health; recreate if dead. */\nasync function ensureSandboxAlive() {\n  if (sandbox && ci) {\n    try {\n      if (await sandbox.isHealthy()) return;\n    } catch {\n      /* health-check failed */\n    }\n  }\n  console.warn(\"  ensureSandboxAlive: sandbox unhealthy — recreating …\");\n  await recreateSandbox();\n}\n\nfunction isRetryableError(err: unknown): boolean {\n  const msg = String(err);\n  return (\n    msg.includes(\"terminated\") ||\n    msg.includes(\"other side closed\") ||\n    msg.includes(\"fetch failed\") ||\n    msg.includes(\"session is busy\") ||\n    msg.includes(\"UND_ERR_SOCKET\")\n  );\n}\n\nfunction sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Retry an async operation up to ``maxRetries`` times.  On retryable socket /\n * session errors the sandbox is health-checked (and recreated if dead) before\n * the next attempt.\n */\nasync function withRetry<T>(\n  fn: () => Promise<T>,\n  maxRetries = 2,\n  delayMs = 3000,\n): Promise<T> {\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    try {\n      return await fn();\n    } catch (err) {\n      if (!isRetryableError(err) || attempt === maxRetries) throw err;\n      console.warn(\n        `  withRetry: attempt ${attempt + 1} failed, retrying in ${delayMs}ms …`,\n        String(err).slice(0, 120),\n      );\n      await sleep(delayMs);\n      await ensureSandboxAlive();\n    }\n  }\n  throw new Error(\"unreachable\");\n}\n\n// ---------------------------------------------------------------------------\n// Setup / teardown\n// ---------------------------------------------------------------------------\n\nbeforeAll(async () => {\n  await recreateSandbox();\n}, 10 * 60_000);\n\nbeforeEach(async () => {\n  await ensureSandboxAlive();\n}, 5 * 60_000);\n\nafterAll(async () => {\n  if (!sandbox) return;\n  try {\n    await sandbox.kill();\n  } catch {\n    // ignore\n  }\n}, 5 * 60_000);\n\ntest(\"01 creation and basic functionality\", async () => {\n  if (!sandbox || !ci) throw new Error(\"not initialized\");\n\n  expect(ci.id).toBe(sandbox.id);\n  expect(await sandbox.isHealthy()).toBe(true);\n\n  const info = await sandbox.getInfo();\n  expect(info.status.state).toBe(\"Running\");\n\n  const ep = await sandbox.getEndpoint(44772);\n  assertEndpointHasPort(ep.endpoint, 44772);\n\n  const metrics = await sandbox.getMetrics();\n  assertRecentTimestampMs(metrics.timestamp);\n});\n\ntest(\"01b context management: get/list/delete/deleteContexts\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  const ctx = await ci.codes.createContext(SupportedLanguages.PYTHON);\n  expect(ctx.id).toBeTruthy();\n  expect(ctx.language).toBe(\"python\");\n\n  const got = await ci.codes.getContext(ctx.id!);\n  expect(got.id).toBe(ctx.id);\n  expect(got.language).toBe(\"python\");\n\n  const all = await ci.codes.listContexts();\n  expect(all.some((c) => c.id === ctx.id)).toBe(true);\n\n  const pyOnly = await ci.codes.listContexts(SupportedLanguages.PYTHON);\n  expect(pyOnly.some((c) => c.id === ctx.id)).toBe(true);\n\n  await ci.codes.deleteContext(ctx.id!);\n  await expect(ci.codes.getContext(ctx.id!)).rejects.toBeTruthy();\n\n  // Bulk cleanup should not throw.\n  await ci.codes.deleteContexts(SupportedLanguages.PYTHON);\n});\n\ntest(\"02 java code execution\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  const javaCtx = await ci.codes.createContext(SupportedLanguages.JAVA);\n  expect(javaCtx.id).toBeTruthy();\n  expect(javaCtx.language).toBe(\"java\");\n\n  const stdout: string[] = [];\n  const errors: string[] = [];\n  const initIds: string[] = [];\n\n  const handlers: ExecutionHandlers = {\n    onStdout: (m) => {\n      stdout.push(m.text);\n    },\n    onError: (e) => {\n      errors.push(e.name);\n    },\n    onInit: (i) => {\n      initIds.push(i.id);\n    },\n  };\n\n  const r = await ci.codes.run(\n    'System.out.println(\"Hello from Java!\");\\nint result = 2 + 2;\\nSystem.out.println(\"2 + 2 = \" + result);\\nresult',\n    { context: javaCtx, handlers }\n  );\n  expect(r.id).toBeTruthy();\n  expect(r.error).toBeUndefined();\n  const resultText = r.result[0]?.text?.trim();\n  const hasResultFromStdout = stdout.some((s) => s.includes(\"2 + 2 = 4\"));\n  expect(resultText === \"4\" || hasResultFromStdout).toBe(true);\n  expect(initIds).toHaveLength(1);\n  expect(errors).toHaveLength(0);\n  expect(stdout.some((s) => s.includes(\"Hello from Java!\"))).toBe(true);\n\n  const err = await ci.codes.run(\"int x = 10 / 0; // ArithmeticException\", {\n    context: javaCtx,\n  });\n  expect(err.error).toBeTruthy();\n  expect(err.error?.name).toBe(\"EvalException\");\n});\n\ntest(\"03 python code execution + direct language + persistence\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  const direct = await withRetry(() =>\n    ci!.codes.run(\"result = 2 + 2\\nresult\", {\n      language: SupportedLanguages.PYTHON,\n    }),\n  );\n  expect(direct.error).toBeUndefined();\n  expect(direct.result[0]?.text).toBe(\"4\");\n\n  // Persistence: retry the whole block as a unit so that a sandbox restart\n  // mid-way gets a fresh context instead of a stale one.\n  const r = await withRetry(async () => {\n    const ctx = await ci!.codes.createContext(SupportedLanguages.PYTHON);\n    await ci!.codes.run(\"x = 42\", { context: ctx });\n    return ci!.codes.run(\"result = x\\nresult\", { context: ctx });\n  });\n  expect(r.result[0]?.text).toBe(\"42\");\n\n  const bad = await withRetry(async () => {\n    const ctx2 = await ci!.codes.createContext(SupportedLanguages.PYTHON);\n    return ci!.codes.run(\"print(undefined_variable)\", { context: ctx2 });\n  });\n  expect(bad.error).toBeTruthy();\n});\n\ntest(\"04 go and typescript execution (smoke)\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  const go = await withRetry(async () => {\n    const goCtx = await ci!.codes.createContext(SupportedLanguages.GO);\n    return ci!.codes.run(\n      'package main\\nimport \"fmt\"\\nfunc main() { fmt.Print(\"hi\"); result := 2+2; fmt.Print(result) }',\n      { context: goCtx },\n    );\n  });\n  expect(go.id).toBeTruthy();\n\n  const ts = await withRetry(async () => {\n    const tsCtx = await ci!.codes.createContext(SupportedLanguages.TYPESCRIPT);\n    return ci!.codes.run(\n      \"console.log('Hello from TypeScript!');\\nconst result: number = 2 + 2;\\nresult\",\n      { context: tsCtx },\n    );\n  });\n  expect(ts.id).toBeTruthy();\n});\n\ntest(\"05 context isolation\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  // Retry entire isolation block as a unit — contexts must come from the same\n  // sandbox for the assertion to make sense.\n  const { ok, bad } = await withRetry(async () => {\n    const python1 = await ci!.codes.createContext(SupportedLanguages.PYTHON);\n    const python2 = await ci!.codes.createContext(SupportedLanguages.PYTHON);\n    await ci!.codes.run(\"secret_value1 = 'python1_secret'\", {\n      context: python1,\n    });\n\n    const okRes = await ci!.codes.run(\"result = secret_value1\\nresult\", {\n      context: python1,\n    });\n    const badRes = await ci!.codes.run(\"result = secret_value1\\nresult\", {\n      context: python2,\n    });\n    return { ok: okRes, bad: badRes };\n  });\n\n  expect(ok.error).toBeUndefined();\n  expect(bad.error).toBeTruthy();\n  expect(bad.error?.name).toBe(\"NameError\");\n});\n\ntest(\"06 concurrent execution\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  // Create contexts with retry; run concurrently and tolerate partial failure.\n  const py = await withRetry(() =>\n    ci!.codes.createContext(SupportedLanguages.PYTHON),\n  );\n  const java = await withRetry(() =>\n    ci!.codes.createContext(SupportedLanguages.JAVA),\n  );\n  const go = await withRetry(() =>\n    ci!.codes.createContext(SupportedLanguages.GO),\n  );\n\n  const results = await Promise.allSettled([\n    ci.codes.run(\n      \"import time\\nfor i in range(3):\\n  print(i)\\n  time.sleep(0.1)\",\n      { context: py },\n    ),\n    ci.codes.run(\n      \"for (int i=0;i<3;i++){ System.out.println(i); try{Thread.sleep(100);}catch(Exception e){} }\",\n      { context: java },\n    ),\n    ci.codes.run(\n      'package main\\nimport \"fmt\"\\nfunc main(){ for i:=0;i<3;i++{ fmt.Print(i) } }',\n      { context: go },\n    ),\n  ]);\n\n  const succeeded = results.filter((r) => r.status === \"fulfilled\");\n  // At least 2 of 3 concurrent runs should succeed (tolerate CI flakiness).\n  expect(succeeded.length).toBeGreaterThanOrEqual(2);\n  for (const r of succeeded) {\n    expect((r as PromiseFulfilledResult<any>).value.id).toBeTruthy();\n  }\n});\n\ntest(\"07 interrupt code execution + fake id\", async () => {\n  if (!ci) throw new Error(\"not initialized\");\n\n  const ctx = await withRetry(() =>\n    ci!.codes.createContext(SupportedLanguages.PYTHON),\n  );\n\n  let initId: string | null = null;\n  let runTask: Promise<unknown> | null = null;\n  const initReceived = new Promise<void>((resolve) => {\n    const handlers: ExecutionHandlers = {\n      onInit: (i) => {\n        initId = i.id;\n        assertRecentTimestampMs(i.timestamp);\n        resolve();\n      },\n    };\n\n    runTask = ci!.codes.run(\n      \"import time\\nfor i in range(100):\\n  print(i)\\n  time.sleep(0.2)\",\n      { context: ctx, handlers },\n    );\n  });\n\n  await initReceived;\n  if (!initId) throw new Error(\"missing init id\");\n  await ci!.codes.interrupt(initId);\n\n  // Important: always await/catch the execution task to avoid Vitest reporting\n  // unhandled rejections when the server closes the streaming connection.\n  if (runTask) {\n    try {\n      await runTask;\n    } catch {\n      // Expected in some environments: interrupt may terminate the stream abruptly.\n    }\n  }\n\n  await expect(ci!.codes.interrupt(`fake-${Date.now()}`)).rejects.toBeTruthy();\n});\n"
  },
  {
    "path": "tests/javascript/tests/test_sandbox_e2e.test.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { afterAll, beforeAll, expect, test } from \"vitest\";\n\nimport {\n  ConnectionConfig,\n  SandboxApiException,\n  Sandbox,\n  DEFAULT_EXECD_PORT,\n  DEFAULT_EGRESS_PORT,\n  SandboxManager,\n  type ExecutionHandlers,\n  type ExecutionComplete,\n  type ExecutionError,\n  type ExecutionInit,\n  type ExecutionResult,\n  type OutputMessage,\n} from \"@alibaba-group/opensandbox\";\n\nimport {\n  TEST_API_KEY,\n  TEST_DOMAIN,\n  TEST_PROTOCOL,\n  assertEndpointHasPort,\n  assertRecentTimestampMs,\n  createConnectionConfig,\n  getSandboxImage,\n} from \"./base_e2e.ts\";\n\nlet sandbox: Sandbox | null = null;\n\nbeforeAll(async () => {\n  const connectionConfig = createConnectionConfig();\n\n  sandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 20 * 60,\n    readyTimeoutSeconds: 60,\n    metadata: { tag: \"e2e-test\" },\n    entrypoint: [\"tail\", \"-f\", \"/dev/null\"],\n    env: {\n      E2E_TEST: \"true\",\n      GO_VERSION: \"1.25\",\n      JAVA_VERSION: \"21\",\n      NODE_VERSION: \"22\",\n      PYTHON_VERSION: \"3.12\",\n    },\n    healthCheckPollingInterval: 200,\n  });\n}, 5 * 60_000);\n\nafterAll(async () => {\n  if (!sandbox) return;\n  try {\n    // keep teardown best-effort\n    await sandbox.kill();\n  } catch {\n    // ignore\n  }\n}, 5 * 60_000);\n\ntest(\"01 sandbox lifecycle, health, endpoint, metrics, renew, connect\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  expect(typeof sandbox.id).toBe(\"string\");\n  expect(await sandbox.isHealthy()).toBe(true);\n\n  await new Promise((resolve) => setTimeout(resolve, 5000));\n  const info = await sandbox.getInfo();\n  expect(info.id).toBe(sandbox.id);\n  expect(info.status.state).toBe(\"Running\");\n  expect(info.entrypoint).toEqual([\"tail\", \"-f\", \"/dev/null\"]);\n  expect(info.metadata?.tag).toBe(\"e2e-test\");\n\n  const ep = await sandbox.getEndpoint(DEFAULT_EXECD_PORT);\n  expect(ep).toBeTruthy();\n  expect(typeof ep.endpoint).toBe(\"string\");\n  assertEndpointHasPort(ep.endpoint, DEFAULT_EXECD_PORT);\n\n  const metrics = await sandbox.getMetrics();\n  expect(metrics.cpuCount).toBeGreaterThan(0);\n  expect(metrics.cpuUsedPercentage).toBeGreaterThanOrEqual(0);\n  expect(metrics.cpuUsedPercentage).toBeLessThanOrEqual(100);\n  expect(metrics.memoryTotalMiB).toBeGreaterThan(0);\n  expect(metrics.memoryUsedMiB).toBeGreaterThanOrEqual(0);\n  expect(metrics.memoryUsedMiB).toBeLessThanOrEqual(metrics.memoryTotalMiB);\n  assertRecentTimestampMs(metrics.timestamp, 120_000);\n\n  const renewResp = await sandbox.renew(20 * 60);\n  expect(renewResp.expiresAt).toBeTruthy();\n  expect(renewResp.expiresAt).toBeInstanceOf(Date);\n\n  const connectionConfig = sandbox.connectionConfig;\n  const sandbox2 = await Sandbox.connect({\n    sandboxId: sandbox.id,\n    connectionConfig,\n  });\n  try {\n    expect(sandbox2.id).toBe(sandbox.id);\n    expect(await sandbox2.isHealthy()).toBe(true);\n    const r = await sandbox2.commands.run(\"echo connect-ok\");\n    expect(r.error).toBeUndefined();\n    expect(r.logs.stdout[0]?.text).toBe(\"connect-ok\");\n  } finally {\n    // no local resources to close\n  }\n});\n\ntest(\"01b manual cleanup sandbox returns null expiresAt\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const manualSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: null,\n    readyTimeoutSeconds: 60,\n    metadata: { tag: \"manual-e2e-test\" },\n    entrypoint: [\"tail\", \"-f\", \"/dev/null\"],\n    healthCheckPollingInterval: 200,\n  });\n\n  try {\n    const info = await manualSandbox.getInfo();\n    expect(info.expiresAt).toBeNull();\n    expect(info.metadata?.tag).toBe(\"manual-e2e-test\");\n  } finally {\n    await manualSandbox.kill();\n    await manualSandbox.close();\n  }\n});\n\ntest(\"01a sandbox create with networkPolicy\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const networkPolicySandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    networkPolicy: {\n      defaultAction: \"deny\",\n      egress: [{ action: \"allow\", target: \"pypi.org\" }],\n    },\n  });\n  await new Promise((r) => setTimeout(r, 5000));\n  try {\n    const initialPolicy = await networkPolicySandbox.getEgressPolicy();\n    expect(initialPolicy.defaultAction).toBe(\"deny\");\n    expect(initialPolicy.egress?.some((r) => r.target === \"pypi.org\" && r.action === \"allow\")).toBe(true);\n\n    const blocked = await networkPolicySandbox.commands.run(\"curl -I https://www.github.com\");\n    expect(blocked.error).toBeTruthy();\n    const allowed = await networkPolicySandbox.commands.run(\"curl -I https://pypi.org\");\n    expect(allowed.error).toBeUndefined();\n\n    await networkPolicySandbox.patchEgressRules([\n      { action: \"allow\", target: \"www.github.com\" },\n      { action: \"deny\", target: \"pypi.org\" },\n    ]);\n    await new Promise((r) => setTimeout(r, 2000));\n\n    const patchedPolicy = await networkPolicySandbox.getEgressPolicy();\n    expect(patchedPolicy.egress?.some((r) => r.target === \"www.github.com\" && r.action === \"allow\")).toBe(true);\n    expect(patchedPolicy.egress?.some((r) => r.target === \"pypi.org\" && r.action === \"deny\")).toBe(true);\n\n    const githubAllowed = await networkPolicySandbox.commands.run(\"curl -I https://www.github.com\");\n    expect(githubAllowed.error).toBeUndefined();\n    const pypiDenied = await networkPolicySandbox.commands.run(\"curl -I https://pypi.org\");\n    expect(pypiDenied.error).toBeTruthy();\n  } finally {\n    try {\n      await networkPolicySandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01aa sandbox create with networkPolicy via server proxy\", async () => {\n  const connectionConfig = createConnectionConfig(true);\n  const networkPolicySandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    networkPolicy: {\n      defaultAction: \"deny\",\n      egress: [{ action: \"allow\", target: \"pypi.org\" }],\n    },\n  });\n  await new Promise((r) => setTimeout(r, 5000));\n  try {\n    const egressEndpoint = await networkPolicySandbox.getEndpoint(DEFAULT_EGRESS_PORT);\n    expect(egressEndpoint.endpoint).toContain(\n      `/sandboxes/${networkPolicySandbox.id}/proxy/${DEFAULT_EGRESS_PORT}`\n    );\n\n    const initialPolicy = await networkPolicySandbox.getEgressPolicy();\n    expect(initialPolicy.defaultAction).toBe(\"deny\");\n    expect(initialPolicy.egress?.some((r) => r.target === \"pypi.org\" && r.action === \"allow\")).toBe(true);\n\n    const blocked = await networkPolicySandbox.commands.run(\"curl -I https://www.github.com\");\n    expect(blocked.error).toBeTruthy();\n    const allowed = await networkPolicySandbox.commands.run(\"curl -I https://pypi.org\");\n    expect(allowed.error).toBeUndefined();\n\n    await networkPolicySandbox.patchEgressRules([\n      { action: \"allow\", target: \"www.github.com\" },\n      { action: \"deny\", target: \"pypi.org\" },\n    ]);\n    await new Promise((r) => setTimeout(r, 2000));\n\n    const patchedPolicy = await networkPolicySandbox.getEgressPolicy();\n    expect(patchedPolicy.egress?.some((r) => r.target === \"www.github.com\" && r.action === \"allow\")).toBe(true);\n    expect(patchedPolicy.egress?.some((r) => r.target === \"pypi.org\" && r.action === \"deny\")).toBe(true);\n  } finally {\n    try {\n      await networkPolicySandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01b sandbox create with host volume mount (read-write)\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n  const containerMountPath = \"/mnt/host-data\";\n\n  const volumeSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    volumes: [\n      {\n        name: \"test-host-vol\",\n        host: { path: hostDir },\n        mountPath: containerMountPath,\n        readOnly: false,\n      },\n    ],\n  });\n\n  try {\n    expect(await volumeSandbox.isHealthy()).toBe(true);\n\n    // Step 1: Verify the host marker file is visible inside the sandbox\n    const readMarker = await volumeSandbox.commands.run(\n      `cat ${containerMountPath}/marker.txt`\n    );\n    expect(readMarker.error).toBeUndefined();\n    expect(readMarker.logs.stdout).toHaveLength(1);\n    expect(readMarker.logs.stdout[0]?.text).toBe(\"opensandbox-e2e-marker\");\n\n    // Step 2: Write a file from inside the sandbox to the mounted path\n    const writeResult = await volumeSandbox.commands.run(\n      `echo 'written-from-sandbox' > ${containerMountPath}/sandbox-output.txt`\n    );\n    expect(writeResult.error).toBeUndefined();\n\n    // Step 3: Verify the written file is readable\n    const readBack = await volumeSandbox.commands.run(\n      `cat ${containerMountPath}/sandbox-output.txt`\n    );\n    expect(readBack.error).toBeUndefined();\n    expect(readBack.logs.stdout).toHaveLength(1);\n    expect(readBack.logs.stdout[0]?.text).toBe(\"written-from-sandbox\");\n\n    // Step 4: Verify the mount path is a proper directory\n    let dirCheck = await volumeSandbox.commands.run(\n      `test -d ${containerMountPath} && echo OK`\n    );\n    for (let attempt = 0; attempt < 3; attempt++) {\n      expect(dirCheck.error).toBeUndefined();\n      if (dirCheck.logs.stdout[0]?.text === \"OK\") break;\n      await new Promise((r) => setTimeout(r, 1000));\n      dirCheck = await volumeSandbox.commands.run(\n        `test -d ${containerMountPath} && echo OK`\n      );\n    }\n    expect(dirCheck.logs.stdout[0]?.text).toBe(\"OK\");\n  } finally {\n    try {\n      await volumeSandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01c sandbox create with host volume mount (read-only)\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const hostDir = \"/tmp/opensandbox-e2e/host-volume-test\";\n  const containerMountPath = \"/mnt/host-data-ro\";\n\n  const roSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    volumes: [\n      {\n        name: \"test-host-vol-ro\",\n        host: { path: hostDir },\n        mountPath: containerMountPath,\n        readOnly: true,\n      },\n    ],\n  });\n\n  try {\n    expect(await roSandbox.isHealthy()).toBe(true);\n\n    // Step 1: Verify the host marker file is readable\n    const readMarker = await roSandbox.commands.run(\n      `cat ${containerMountPath}/marker.txt`\n    );\n    expect(readMarker.error).toBeUndefined();\n    expect(readMarker.logs.stdout).toHaveLength(1);\n    expect(readMarker.logs.stdout[0]?.text).toBe(\"opensandbox-e2e-marker\");\n\n    // Step 2: Verify writing is denied on read-only mount\n    const writeResult = await roSandbox.commands.run(\n      `touch ${containerMountPath}/should-fail.txt`\n    );\n    const statResult = await roSandbox.commands.run(\n      `test ! -e ${containerMountPath}/should-fail.txt && echo OK`\n    );\n    const writeWasRejected =\n      writeResult.error != null || writeResult.logs.stderr.length > 0;\n    const fileWasNotCreated = statResult.logs.stdout[0]?.text === \"OK\";\n    expect(writeWasRejected || fileWasNotCreated).toBe(true);\n  } finally {\n    try {\n      await roSandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01d sandbox create with PVC named volume mount (read-write)\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n  const containerMountPath = \"/mnt/pvc-data\";\n\n  const pvcSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    volumes: [\n      {\n        name: \"test-pvc-vol\",\n        pvc: { claimName: pvcVolumeName },\n        mountPath: containerMountPath,\n        readOnly: false,\n      },\n    ],\n  });\n\n  try {\n    expect(await pvcSandbox.isHealthy()).toBe(true);\n\n    // Step 1: Verify the marker file seeded into the named volume is readable\n    const readMarker = await pvcSandbox.commands.run(\n      `cat ${containerMountPath}/marker.txt`\n    );\n    expect(readMarker.error).toBeUndefined();\n    expect(readMarker.logs.stdout).toHaveLength(1);\n    expect(readMarker.logs.stdout[0]?.text).toBe(\"pvc-marker-data\");\n\n    // Step 2: Write a file from inside the sandbox to the named volume\n    const writeResult = await pvcSandbox.commands.run(\n      `echo 'written-to-pvc' > ${containerMountPath}/pvc-output.txt`\n    );\n    expect(writeResult.error).toBeUndefined();\n\n    // Step 3: Verify the written file is readable\n    const readBack = await pvcSandbox.commands.run(\n      `cat ${containerMountPath}/pvc-output.txt`\n    );\n    expect(readBack.error).toBeUndefined();\n    expect(readBack.logs.stdout).toHaveLength(1);\n    expect(readBack.logs.stdout[0]?.text).toBe(\"written-to-pvc\");\n\n    // Step 4: Verify the mount path is a proper directory\n    let dirCheck = await pvcSandbox.commands.run(\n      `test -d ${containerMountPath} && echo OK`\n    );\n    for (let attempt = 0; attempt < 3; attempt++) {\n      expect(dirCheck.error).toBeUndefined();\n      if (dirCheck.logs.stdout[0]?.text === \"OK\") break;\n      await new Promise((r) => setTimeout(r, 1000));\n      dirCheck = await pvcSandbox.commands.run(\n        `test -d ${containerMountPath} && echo OK`\n      );\n    }\n    expect(dirCheck.logs.stdout[0]?.text).toBe(\"OK\");\n  } finally {\n    try {\n      await pvcSandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01e sandbox create with PVC named volume mount (read-only)\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n  const containerMountPath = \"/mnt/pvc-data-ro\";\n\n  const roSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    volumes: [\n      {\n        name: \"test-pvc-vol-ro\",\n        pvc: { claimName: pvcVolumeName },\n        mountPath: containerMountPath,\n        readOnly: true,\n      },\n    ],\n  });\n\n  try {\n    expect(await roSandbox.isHealthy()).toBe(true);\n\n    // Step 1: Verify the marker file is readable\n    const readMarker = await roSandbox.commands.run(\n      `cat ${containerMountPath}/marker.txt`\n    );\n    expect(readMarker.error).toBeUndefined();\n    expect(readMarker.logs.stdout).toHaveLength(1);\n    expect(readMarker.logs.stdout[0]?.text).toBe(\"pvc-marker-data\");\n\n    // Step 2: Verify writing is denied on read-only mount\n    const writeResult = await roSandbox.commands.run(\n      `touch ${containerMountPath}/should-fail.txt`\n    );\n    const statResult = await roSandbox.commands.run(\n      `test ! -e ${containerMountPath}/should-fail.txt && echo OK`\n    );\n    const writeWasRejected =\n      writeResult.error != null || writeResult.logs.stderr.length > 0;\n    const fileWasNotCreated = statResult.logs.stdout[0]?.text === \"OK\";\n    expect(writeWasRejected || fileWasNotCreated).toBe(true);\n  } finally {\n    try {\n      await roSandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01f sandbox create with PVC named volume subPath mount\", async () => {\n  const connectionConfig = createConnectionConfig();\n  const pvcVolumeName = \"opensandbox-e2e-pvc-test\";\n  const containerMountPath = \"/mnt/train\";\n\n  const subpathSandbox = await Sandbox.create({\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 2 * 60,\n    readyTimeoutSeconds: 60,\n    volumes: [\n      {\n        name: \"test-pvc-subpath\",\n        pvc: { claimName: pvcVolumeName },\n        mountPath: containerMountPath,\n        readOnly: false,\n        subPath: \"datasets/train\",\n      },\n    ],\n  });\n\n  try {\n    expect(await subpathSandbox.isHealthy()).toBe(true);\n\n    // Step 1: Verify the subpath marker file is readable\n    const readMarker = await subpathSandbox.commands.run(\n      `cat ${containerMountPath}/marker.txt`\n    );\n    expect(readMarker.error).toBeUndefined();\n    expect(readMarker.logs.stdout).toHaveLength(1);\n    expect(readMarker.logs.stdout[0]?.text).toBe(\"pvc-subpath-marker\");\n\n    // Step 2: Verify only subPath contents are visible (not the full volume)\n    const lsResult = await subpathSandbox.commands.run(\n      `ls ${containerMountPath}/`\n    );\n    expect(lsResult.error).toBeUndefined();\n    const lsOutput = lsResult.logs.stdout.map((m) => m.text).join(\"\\n\");\n    expect(lsOutput).toContain(\"marker.txt\");\n    expect(lsOutput).not.toContain(\"datasets\");\n\n    // Step 3: Write a file and verify (retry read-back for transient SSE drops)\n    const writeResult = await subpathSandbox.commands.run(\n      `echo 'subpath-write-test' > ${containerMountPath}/output.txt`\n    );\n    expect(writeResult.error).toBeUndefined();\n\n    let readBack: Awaited<ReturnType<typeof subpathSandbox.commands.run>> | undefined;\n    for (let attempt = 0; attempt < 3; attempt++) {\n      readBack = await subpathSandbox.commands.run(\n        `cat ${containerMountPath}/output.txt`\n      );\n      if (readBack.logs.stdout.length > 0) break;\n      await new Promise<void>((resolve) => setTimeout(resolve, 1000));\n    }\n    expect(readBack!.error).toBeUndefined();\n    expect(readBack!.logs.stdout).toHaveLength(1);\n    expect(readBack!.logs.stdout[0]?.text).toBe(\"subpath-write-test\");\n  } finally {\n    try {\n      await subpathSandbox.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 3 * 60_000);\n\ntest(\"01g sandbox manager: list + get\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const manager = SandboxManager.create({ connectionConfig: sandbox.connectionConfig });\n\n  const list = await manager.listSandboxInfos({\n    states: [\"Running\"],\n    metadata: { tag: \"e2e-test\" },\n    pageSize: 50,\n  });\n  expect(Array.isArray(list.items)).toBe(true);\n  expect(list.items.some((s) => s.id === sandbox!.id)).toBe(true);\n\n  const info = await manager.getSandboxInfo(sandbox.id);\n  expect(info.id).toBe(sandbox.id);\n  expect(info.metadata?.tag).toBe(\"e2e-test\");\n});\n\ntest(\"02 command execution: success, cwd, background, failure\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const stdoutMessages: OutputMessage[] = [];\n  const stderrMessages: OutputMessage[] = [];\n  const results: ExecutionResult[] = [];\n  const initEvents: ExecutionInit[] = [];\n  const completedEvents: ExecutionComplete[] = [];\n  const errors: ExecutionError[] = [];\n\n  const handlers: ExecutionHandlers = {\n    onStdout: (m) => {\n      stdoutMessages.push(m);\n    },\n    onStderr: (m) => {\n      stderrMessages.push(m);\n    },\n    onResult: (r) => {\n      results.push(r);\n    },\n    onInit: (i) => {\n      initEvents.push(i);\n    },\n    onExecutionComplete: (c) => {\n      completedEvents.push(c);\n    },\n    onError: (e) => {\n      errors.push(e);\n    },\n  };\n\n  const ok = await sandbox.commands.run(\n    \"echo 'Hello OpenSandbox E2E'\",\n    undefined,\n    handlers\n  );\n  expect(ok.id).toBeTruthy();\n  expect(ok.error).toBeUndefined();\n  expect(ok.logs.stdout).toHaveLength(1);\n  expect(ok.logs.stdout[0]?.text).toBe(\"Hello OpenSandbox E2E\");\n  assertRecentTimestampMs(ok.logs.stdout[0]!.timestamp);\n\n  expect(initEvents).toHaveLength(1);\n  expect(completedEvents).toHaveLength(1);\n  expect(errors).toHaveLength(0);\n\n  const pwd = await sandbox.commands.run(\"pwd\", { workingDirectory: \"/tmp\" });\n  expect(pwd.error).toBeUndefined();\n  expect(pwd.logs.stdout[0]?.text).toBe(\"/tmp\");\n\n  const start = Date.now();\n  await sandbox.commands.run(\"sleep 30\", { background: true });\n  expect(Date.now() - start).toBeLessThan(10_000);\n\n  // failure contract: error exists; completion should be absent\n  stdoutMessages.length = 0;\n  stderrMessages.length = 0;\n  results.length = 0;\n  initEvents.length = 0;\n  completedEvents.length = 0;\n  errors.length = 0;\n\n  const fail = await sandbox.commands.run(\n    \"nonexistent-command-that-does-not-exist\",\n    undefined,\n    handlers\n  );\n  expect(fail.id).toBeTruthy();\n  expect(fail.error).toBeTruthy();\n  expect(fail.error?.name).toBe(\"CommandExecError\");\n  expect(fail.logs.stderr.length).toBeGreaterThan(0);\n  expect(\n    fail.logs.stderr.some((m) =>\n      m.text.includes(\"nonexistent-command-that-does-not-exist\")\n    )\n  ).toBe(true);\n  expect(completedEvents.length).toBe(0);\n});\n\ntest(\"02a command status + background logs\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const exec = await sandbox.commands.run(\n    \"sh -c 'echo log-line-1; echo log-line-2; sleep 2'\",\n    { background: true }\n  );\n  expect(exec.id).toBeTruthy();\n\n  const commandId = exec.id!;\n  const status = await sandbox.commands.getCommandStatus(commandId);\n  expect(status.id).toBe(commandId);\n  expect(typeof status.running).toBe(\"boolean\");\n\n  let logsText = \"\";\n  let cursor: number | undefined = undefined;\n  for (let i = 0; i < 20; i++) {\n    const logs = await sandbox.commands.getBackgroundCommandLogs(\n      commandId,\n      cursor\n    );\n    logsText += logs.content;\n    cursor = logs.cursor ?? cursor;\n    if (logsText.includes(\"log-line-2\")) break;\n    await new Promise<void>((resolve) => setTimeout(resolve, 1000));\n  }\n\n  expect(logsText.includes(\"log-line-1\")).toBe(true);\n  expect(logsText.includes(\"log-line-2\")).toBe(true);\n});\n\ntest(\"02b command env injection\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const envKey = \"OPEN_SANDBOX_E2E_CMD_ENV\";\n  const envValue = `env-ok-${Date.now()}`;\n  const probeCommand = `sh -c 'if [ -z \"\\${${envKey}:-}\" ]; then echo \"__EMPTY__\"; else echo \"\\${${envKey}}\"; fi'`;\n\n  const baseline = await sandbox.commands.run(probeCommand);\n  expect(baseline.error).toBeUndefined();\n  const baselineOutput = baseline.logs.stdout.map((m) => m.text).join(\"\\n\").trim();\n  expect(baselineOutput).toBe(\"__EMPTY__\");\n\n  const injected = await sandbox.commands.run(probeCommand, {\n    envs: {\n      [envKey]: envValue,\n      OPEN_SANDBOX_E2E_SECOND_ENV: \"second-ok\",\n    },\n  });\n  expect(injected.error).toBeUndefined();\n  const injectedOutput = injected.logs.stdout.map((m) => m.text).join(\"\\n\").trim();\n  expect(injectedOutput).toBe(envValue);\n});\n\ntest(\"03 filesystem operations: CRUD + replace/move/delete + range + stream\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const ts = Date.now();\n  const dir1 = `/tmp/fs_test1_${ts}`;\n  const dir2 = `/tmp/fs_test2_${ts}`;\n\n  await sandbox.files.createDirectories([\n    { path: dir1, mode: 755 },\n    { path: dir2, mode: 644 },\n  ]);\n\n  const infoMap = await sandbox.files.getFileInfo([dir1, dir2]);\n  expect(infoMap[dir1]?.path).toBe(dir1);\n  expect(infoMap[dir2]?.path).toBe(dir2);\n  expect(infoMap[dir1]?.mode).toBe(755);\n  expect(infoMap[dir2]?.mode).toBe(644);\n\n  const ls = await sandbox.commands.run(\"ls -la | grep fs_test\", {\n    workingDirectory: \"/tmp\",\n  });\n  expect(ls.error).toBeUndefined();\n  expect(ls.logs.stdout).toHaveLength(2);\n\n  const file1 = `${dir1}/test_file1.txt`;\n  const file2 = `${dir1}/test_file2.txt`;\n  const file3 = `${dir1}/test_file3.txt`;\n  const content = \"Hello Filesystem!\\nLine 2 with special chars: åäö\\nLine 3\";\n  const bytes = new TextEncoder().encode(content);\n\n  // Align with Python/Kotlin semantics but keep E2E portable across different base images:\n  // prefer \"nogroup\"/\"nobody\" if present, otherwise fall back to \"root\".\n  const ownerPick = await sandbox.commands.run(\n    `id -u nobody >/dev/null 2>&1 && echo nobody || echo root`,\n    { workingDirectory: \"/tmp\" }\n  );\n  expect(ownerPick.error).toBeUndefined();\n  const ownerName = (ownerPick.logs.stdout[0]?.text || \"root\").trim();\n\n  const groupPick = await sandbox.commands.run(\n    `getent group nogroup >/dev/null 2>&1 && echo nogroup || echo root`,\n    { workingDirectory: \"/tmp\" }\n  );\n  expect(groupPick.error).toBeUndefined();\n  const groupName = (groupPick.logs.stdout[0]?.text || \"root\").trim();\n\n  await sandbox.files.writeFiles([\n    { path: file1, data: content, mode: 644 },\n    { path: file2, data: bytes, mode: 755 },\n    { path: file3, data: bytes, mode: 755, owner: ownerName, group: groupName },\n  ]);\n\n  const searched = await sandbox.files.search({ path: dir1, pattern: \"*\" });\n  const searchedPaths = new Set(searched.map((f) => f.path));\n  expect(searchedPaths.has(file1)).toBe(true);\n  expect(searchedPaths.has(file2)).toBe(true);\n  expect(searchedPaths.has(file3)).toBe(true);\n\n  const read1 = await sandbox.files.readFile(file1, { encoding: \"utf-8\" });\n  const read1Partial = await sandbox.files.readFile(file1, {\n    encoding: \"utf-8\",\n    range: \"bytes=0-9\",\n  });\n  const read2 = await sandbox.files.readBytes(file2);\n  let read3 = new Uint8Array();\n  for await (const chunk of sandbox.files.readBytesStream(file3)) {\n    const merged = new Uint8Array(read3.length + chunk.length);\n    merged.set(read3, 0);\n    merged.set(chunk, read3.length);\n    read3 = merged;\n  }\n\n  expect(read1).toBe(content);\n  expect(new TextDecoder(\"utf-8\").decode(read2)).toBe(content);\n  expect(new TextDecoder(\"utf-8\").decode(read3)).toBe(content);\n  expect(read1Partial).toBe(content.slice(0, 10));\n\n  await sandbox.files.setPermissions([\n    { path: file1, mode: 755, owner: ownerName, group: groupName },\n    { path: file2, mode: 600, owner: ownerName, group: groupName },\n  ]);\n  const perms = await sandbox.files.getFileInfo([file1, file2]);\n  expect(perms[file1]?.mode).toBe(755);\n  expect(perms[file1]?.owner).toBe(ownerName);\n  expect(perms[file1]?.group).toBe(groupName);\n  expect(perms[file2]?.mode).toBe(600);\n\n  const updated1 = `${content}\\nAppended line to file1`;\n  const updated2 = `${content}\\nAppended line to file2`;\n  await new Promise((r) => setTimeout(r, 50));\n  await sandbox.files.writeFiles([\n    { path: file1, data: updated1, mode: 644 },\n    { path: file2, data: updated2, mode: 755 },\n  ]);\n  expect(await sandbox.files.readFile(file1)).toBe(updated1);\n  expect(await sandbox.files.readFile(file2)).toBe(updated2);\n\n  await new Promise((r) => setTimeout(r, 50));\n  await sandbox.files.replaceContents([\n    {\n      path: file1,\n      oldContent: \"Appended line to file1\",\n      newContent: \"Replaced line in file1\",\n    },\n  ]);\n  const replaced = await sandbox.files.readFile(file1);\n  expect(replaced.includes(\"Replaced line in file1\")).toBe(true);\n  expect(replaced.includes(\"Appended line to file1\")).toBe(false);\n\n  const movedPath = `${dir2}/moved_file3.txt`;\n  await sandbox.files.moveFiles([{ src: file3, dest: movedPath }]);\n  expect(await sandbox.files.readFile(movedPath)).toBe(content);\n\n  await sandbox.files.deleteFiles([file2]);\n  await expect(sandbox.files.readFile(file2)).rejects.toBeTruthy();\n\n  await sandbox.files.deleteDirectories([dir1, dir2]);\n  let verify = await sandbox.commands.run(\n    `test ! -d ${dir1} && test ! -d ${dir2} && echo OK`,\n    { workingDirectory: \"/tmp\" }\n  );\n  for (let attempt = 0; attempt < 3; attempt++) {\n    if (!verify.error && verify.logs.stdout[0]?.text === \"OK\") break;\n    await new Promise((r) => setTimeout(r, 1000));\n    verify = await sandbox.commands.run(\n      `test ! -d ${dir1} && test ! -d ${dir2} && echo OK`,\n      { workingDirectory: \"/tmp\" }\n    );\n  }\n  expect(verify.error).toBeUndefined();\n  expect(verify.logs.stdout[0]?.text).toBe(\"OK\");\n});\n\ntest(\"04 interrupt command\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  const initEvents: ExecutionInit[] = [];\n  const completed: ExecutionComplete[] = [];\n  const errors: ExecutionError[] = [];\n  let initResolve: ((v: ExecutionInit) => void) | null = null;\n  const initPromise = new Promise<ExecutionInit>((r) => (initResolve = r));\n\n  const handlers: ExecutionHandlers = {\n    onInit: (i) => {\n      initEvents.push(i);\n      initResolve?.(i);\n    },\n    onExecutionComplete: (c) => {\n      completed.push(c);\n    },\n    onError: (e) => {\n      errors.push(e);\n    },\n  };\n\n  const task = sandbox.commands.run(\"sleep 30\", undefined, handlers);\n  const init = await initPromise;\n  expect(init.id).toBeTruthy();\n  assertRecentTimestampMs(init.timestamp);\n\n  await sandbox.commands.interrupt(init.id);\n  let exec = null;\n  try {\n    exec = await Promise.race([\n      task,\n      new Promise<never>((_, reject) =>\n        setTimeout(() => reject(new Error(\"interrupt wait timeout\")), 60_000),\n      ),\n    ]);\n  } catch {\n    exec = null;\n  }\n\n  if (exec) {\n    expect(exec.id).toBe(init.id);\n  }\n\n  let followUp = null;\n  try {\n    followUp = await sandbox.commands.run(\"echo interrupt-ok\");\n  } catch {\n    followUp = null;\n  }\n\n  expect(\n    completed.length > 0 ||\n      errors.length > 0 ||\n      (followUp?.error === undefined &&\n        followUp?.logs.stdout[0]?.text === \"interrupt-ok\"),\n  ).toBe(true);\n});\n\ntest(\"05 sandbox pause + resume\", async () => {\n  if (!sandbox) throw new Error(\"sandbox not created\");\n\n  await new Promise((r) => setTimeout(r, 20_000));\n  await sandbox.pause();\n\n  let state = \"Pausing\";\n  for (let i = 0; i < 300; i++) {\n    await new Promise((r) => setTimeout(r, 1000));\n    const info = await sandbox.getInfo();\n    state = info.status.state;\n    if (state !== \"Pausing\") break;\n  }\n  expect(state).toBe(\"Paused\");\n\n  // pause => unhealthy\n  let healthy = true;\n  for (let i = 0; i < 10; i++) {\n    healthy = await sandbox.isHealthy();\n    if (!healthy) break;\n    await new Promise((r) => setTimeout(r, 500));\n  }\n  expect(healthy).toBe(false);\n\n  sandbox = await sandbox.resume({\n    readyTimeoutSeconds: 60,\n    healthCheckPollingInterval: 200,\n  });\n\n  let ok = false;\n  for (let i = 0; i < 60; i++) {\n    await new Promise((r) => setTimeout(r, 1000));\n    ok = await sandbox.isHealthy();\n    if (ok) break;\n  }\n  expect(ok).toBe(true);\n\n  const echo = await sandbox.commands.run(\"echo resume-ok\");\n  expect(echo.error).toBeUndefined();\n  expect(echo.logs.stdout[0]?.text).toBe(\"resume-ok\");\n});\n\ntest(\"06 x-request-id passthrough on server error\", async () => {\n  const requestId = `e2e-js-server-${Date.now()}`;\n  const missingSandboxId = `missing-${requestId}`;\n  const connectionConfig = new ConnectionConfig({\n    domain: TEST_DOMAIN,\n    protocol: TEST_PROTOCOL === \"https\" ? \"https\" : \"http\",\n    apiKey: TEST_API_KEY,\n    requestTimeoutSeconds: 180,\n    headers: { \"X-Request-ID\": requestId },\n  });\n\n  try {\n    const connected = await Sandbox.connect({\n      sandboxId: missingSandboxId,\n      connectionConfig,\n    });\n    await connected.getInfo();\n    throw new Error(\"expected server call to fail\");\n  } catch (err) {\n    expect(err).toBeInstanceOf(SandboxApiException);\n    expect((err as SandboxApiException).requestId).toBe(requestId);\n  }\n});\n"
  },
  {
    "path": "tests/javascript/tests/test_sandbox_manager_e2e.test.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { afterAll, beforeAll, expect, test } from \"vitest\";\n\nimport {\n  Sandbox,\n  SandboxManager,\n} from \"@alibaba-group/opensandbox\";\n\nimport {\n  createConnectionConfig,\n  getSandboxImage,\n} from \"./base_e2e.ts\";\n\nlet manager: SandboxManager | null = null;\nlet tag: string | null = null;\nlet s1: Sandbox | null = null;\nlet s2: Sandbox | null = null;\nlet s3: Sandbox | null = null;\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((r) => setTimeout(r, ms));\n}\n\nasync function waitForState(\n  sandboxId: string,\n  expectedState: string,\n  timeoutMs = 180_000\n): Promise<void> {\n  if (!manager) throw new Error(\"sandbox manager not initialized\");\n  const deadline = Date.now() + timeoutMs;\n  let lastState = \"unknown\";\n\n  while (Date.now() < deadline) {\n    const info = await manager.getSandboxInfo(sandboxId);\n    lastState = info.status.state;\n    if (lastState === expectedState) return;\n    await sleep(1000);\n  }\n\n  throw new Error(\n    `Timed out waiting for state=${expectedState}, lastState=${lastState}`\n  );\n}\n\nbeforeAll(async () => {\n  const connectionConfig = createConnectionConfig();\n  manager = SandboxManager.create({ connectionConfig });\n  tag = `e2e-sandbox-manager-${Math.random().toString(16).slice(2, 10)}`;\n\n  const common = {\n    connectionConfig,\n    image: getSandboxImage(),\n    timeoutSeconds: 5 * 60,\n    readyTimeoutSeconds: 60,\n    healthCheckPollingInterval: 500,\n    resource: { cpu: \"1\", memory: \"2Gi\" },\n  };\n\n  s1 = await Sandbox.create({\n    ...common,\n    metadata: { tag, team: \"t1\", env: \"prod\" },\n    env: { E2E_TEST: \"true\", CASE: \"mgr-s1\" },\n  });\n  s2 = await Sandbox.create({\n    ...common,\n    metadata: { tag, team: \"t1\", env: \"dev\" },\n    env: { E2E_TEST: \"true\", CASE: \"mgr-s2\" },\n  });\n  s3 = await Sandbox.create({\n    ...common,\n    metadata: { tag, env: \"prod\" },\n    env: { E2E_TEST: \"true\", CASE: \"mgr-s3\" },\n  });\n\n  expect(await s1.isHealthy()).toBe(true);\n  expect(await s2.isHealthy()).toBe(true);\n  expect(await s3.isHealthy()).toBe(true);\n\n  await manager.pauseSandbox(s3.id);\n  await waitForState(s3.id, \"Paused\");\n}, 10 * 60_000);\n\nafterAll(async () => {\n  for (const sbx of [s1, s2, s3]) {\n    if (!sbx) continue;\n    try {\n      await sbx.kill();\n    } catch {\n      // ignore\n    }\n  }\n}, 5 * 60_000);\n\ntest(\"01 states filter uses OR semantics\", async () => {\n  if (!manager || !tag || !s1 || !s2 || !s3) {\n    throw new Error(\"sandbox manager not initialized\");\n  }\n\n  const allStates = await manager.listSandboxInfos({\n    states: [\"Running\", \"Paused\"],\n    metadata: { tag },\n    pageSize: 50,\n  });\n  const allIds = new Set(allStates.items.map((info) => info.id));\n  expect(allIds.has(s1.id)).toBe(true);\n  expect(allIds.has(s2.id)).toBe(true);\n  expect(allIds.has(s3.id)).toBe(true);\n\n  const pausedOnly = await manager.listSandboxInfos({\n    states: [\"Paused\"],\n    metadata: { tag },\n    pageSize: 50,\n  });\n  const pausedIds = new Set(pausedOnly.items.map((info) => info.id));\n  expect(pausedIds.has(s3.id)).toBe(true);\n  expect(pausedIds.has(s1.id)).toBe(false);\n  expect(pausedIds.has(s2.id)).toBe(false);\n\n  const runningOnly = await manager.listSandboxInfos({\n    states: [\"Running\"],\n    metadata: { tag },\n    pageSize: 50,\n  });\n  const runningIds = new Set(runningOnly.items.map((info) => info.id));\n  expect(runningIds.has(s1.id)).toBe(true);\n  expect(runningIds.has(s2.id)).toBe(true);\n  expect(runningIds.has(s3.id)).toBe(false);\n}, 2 * 60_000);\n\ntest(\"02 metadata filter uses AND semantics\", async () => {\n  if (!manager || !tag || !s1 || !s2 || !s3) {\n    throw new Error(\"sandbox manager not initialized\");\n  }\n\n  const tagAndTeam = await manager.listSandboxInfos({\n    metadata: { tag, team: \"t1\" },\n    pageSize: 50,\n  });\n  const tagAndTeamIds = new Set(tagAndTeam.items.map((info) => info.id));\n  expect(tagAndTeamIds.has(s1.id)).toBe(true);\n  expect(tagAndTeamIds.has(s2.id)).toBe(true);\n  expect(tagAndTeamIds.has(s3.id)).toBe(false);\n\n  const tagTeamEnv = await manager.listSandboxInfos({\n    metadata: { tag, team: \"t1\", env: \"prod\" },\n    pageSize: 50,\n  });\n  const tagTeamEnvIds = new Set(tagTeamEnv.items.map((info) => info.id));\n  expect(tagTeamEnvIds.has(s1.id)).toBe(true);\n  expect(tagTeamEnvIds.has(s2.id)).toBe(false);\n  expect(tagTeamEnvIds.has(s3.id)).toBe(false);\n\n  const tagEnv = await manager.listSandboxInfos({\n    metadata: { tag, env: \"prod\" },\n    pageSize: 50,\n  });\n  const tagEnvIds = new Set(tagEnv.items.map((info) => info.id));\n  expect(tagEnvIds.has(s1.id)).toBe(true);\n  expect(tagEnvIds.has(s3.id)).toBe(true);\n  expect(tagEnvIds.has(s2.id)).toBe(false);\n\n  const noneMatch = await manager.listSandboxInfos({\n    metadata: { tag, team: \"t2\" },\n    pageSize: 50,\n  });\n  const noneMatchIds = new Set(noneMatch.items.map((info) => info.id));\n  expect(noneMatchIds.has(s1.id)).toBe(false);\n  expect(noneMatchIds.has(s2.id)).toBe(false);\n  expect(noneMatchIds.has(s3.id)).toBe(false);\n}, 2 * 60_000);\n\ntest(\"03 invalid operations reject\", async () => {\n  if (!manager) throw new Error(\"sandbox manager not initialized\");\n  const nonExistentId = `non-existent-${Date.now()}`;\n\n  await expect(manager.getSandboxInfo(nonExistentId)).rejects.toBeTruthy();\n  await expect(manager.pauseSandbox(nonExistentId)).rejects.toBeTruthy();\n  await expect(manager.resumeSandbox(nonExistentId)).rejects.toBeTruthy();\n  await expect(manager.killSandbox(nonExistentId)).rejects.toBeTruthy();\n  await expect(manager.renewSandbox(nonExistentId, 5 * 60)).rejects.toBeTruthy();\n}, 60_000);\n"
  },
  {
    "path": "tests/javascript/tests/test_wait_until_ready_diagnostics.test.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { expect, test } from \"vitest\";\n\nimport { Sandbox, SandboxReadyTimeoutException } from \"@alibaba-group/opensandbox\";\n\ntest(\"waitUntilReady timeout includes last health-check error and connection context\", async () => {\n  const fakeSandbox = {\n    connectionConfig: {\n      domain: \"localhost:8080\",\n      useServerProxy: false,\n    },\n    health: {\n      ping: async () => {\n        throw new Error(\"connect ECONNREFUSED 127.0.0.1:8080\");\n      },\n    },\n  } as unknown as Sandbox;\n\n  let thrown: unknown;\n  try {\n    await Sandbox.prototype.waitUntilReady.call(fakeSandbox, {\n      readyTimeoutSeconds: 0.01,\n      pollingIntervalMillis: 1,\n    });\n  } catch (err) {\n    thrown = err;\n  }\n\n  expect(thrown).toBeInstanceOf(SandboxReadyTimeoutException);\n  const message = (thrown as Error).message;\n  expect(message).toContain(\"Sandbox health check timed out\");\n  expect(message).toContain(\"Last health check error\");\n  expect(message).toContain(\"domain=localhost:8080\");\n  expect(message).toContain(\"useServerProxy=false\");\n  expect(message).toContain(\"useServerProxy=true\");\n});\n\ntest(\"waitUntilReady timeout includes false-continuously hint when ping returns false\", async () => {\n  let pingCalls = 0;\n  const fakeSandbox = {\n    connectionConfig: {\n      domain: \"localhost:8080\",\n      useServerProxy: true,\n    },\n    health: {\n      ping: async () => {\n        pingCalls++;\n        return false;\n      },\n    },\n  } as unknown as Sandbox;\n\n  let thrown: unknown;\n  try {\n    await Sandbox.prototype.waitUntilReady.call(fakeSandbox, {\n      readyTimeoutSeconds: 0.01,\n      pollingIntervalMillis: 1,\n    });\n  } catch (err) {\n    thrown = err;\n  }\n\n  expect(thrown).toBeInstanceOf(SandboxReadyTimeoutException);\n  expect((thrown as Error).message).toContain(\"Health check returned false continuously.\");\n  expect(pingCalls).toBeGreaterThan(0);\n});\n"
  },
  {
    "path": "tests/javascript/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ES2022\",\n    \"moduleResolution\": \"Bundler\",\n    \"lib\": [\"ES2022\", \"DOM\"],\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"allowImportingTsExtensions\": true\n  },\n  \"include\": [\"tests\"]\n}\n\n\n"
  },
  {
    "path": "tests/javascript/vitest.config.ts",
    "content": "// Copyright 2026 Alibaba Group Holding Ltd.\n// \n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n// \n//     http://www.apache.org/licenses/LICENSE-2.0\n// \n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n  test: {\n    environment: \"node\",\n    // These E2E tests can be slow depending on the provider.\n    testTimeout: 15 * 60_000,\n    hookTimeout: 15 * 60_000,\n    // Keep ordering deterministic (mirrors ordered Python/Java E2E suites).\n    sequence: {\n      concurrent: false,\n    },\n  },\n});"
  },
  {
    "path": "tests/python/Makefile",
    "content": ".PHONY: sync sync-dev test test-sandbox test-manager test-code lint fmt\n\nsync:\n\tuv sync\n\nsync-dev:\n\tuv sync --group dev\n\ntest:\n\tuv run pytest\n\ntest-sandbox:\n\tuv run pytest tests/test_sandbox_e2e.py\n\ntest-manager:\n\tuv run pytest tests/test_sandbox_manager_e2e.py\n\ntest-code:\n\tuv run pytest tests/test_code_interpreter_e2e.py\n\nlint:\n\tuv run ruff check tests\n\nfmt:\n\tuv run ruff format tests\n"
  },
  {
    "path": "tests/python/README.md",
    "content": "## OpenSandbox Python SDK – E2E Tests (uv)\n\nThis folder is a standalone e2e test project managed by **uv**.\n\n### Setup\n\n```bash\ncd tests/e2e/python\nuv sync\n```\n\n### Run tests\n\n```bash\nuv run pytest\n```\n\nRun a specific suite:\n\n```bash\nuv run pytest tests/test_sandbox_e2e.py\n```\n\n### Notes about asyncio + shared Sandbox\n\nThese tests may reuse a single Sandbox instance across multiple test cases for speed.\nTo avoid `RuntimeError: Event loop is closed`, pytest-asyncio is configured to use a\n**session-scoped event loop** in `pyproject.toml`.\n\n### Handy shortcuts\n\n```bash\nmake sync\nmake test\nmake test-sandbox\nmake lint\nmake fmt\n```\n"
  },
  {
    "path": "tests/python/pyproject.toml",
    "content": "# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n[project]\nname = \"opensandbox-e2e-tests\"\nversion = \"0.1.0\"\ndescription = \"E2E tests for OpenSandbox Python SDK\"\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nlicense = { text = \"MIT\" }\nauthors = [\n    { name = \"OpenSandbox Team\", email = \"ninan.nn@alibaba-inc.com\" }\n]\ndependencies = [\n    \"pytest>=7.0.0\",\n    \"pytest-asyncio>=0.21.0\",\n    \"pytest-timeout>=2.1.0\",\n    \"pytest-order>=1.2.0\",\n    \"pydantic>=2.0.0\",\n    \"opensandbox\",\n    \"opensandbox-code-interpreter\",\n]\n\n[dependency-groups]\ndev = [\n    \"ruff>=0.14.8\",\n    \"pyright>=1.1.407\",\n]\n\n[tool.uv]\n# This is a test runner project (no importable package); don't try to build/install it.\npackage = false\n\n[tool.uv.sources]\nopensandbox = { path = \"../../sdks/sandbox/python\", editable = true }\nopensandbox-code-interpreter = { path = \"../../sdks/code-interpreter/python\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\naddopts = [\n    \"-v\",\n    \"-s\",\n    \"-x\",\n    \"--tb=short\",\n    \"--strict-markers\",\n    \"--asyncio-mode=auto\",\n    \"--order-scope=class\",\n]\nmarkers = [\n    \"e2e: marks tests as end-to-end tests\",\n    \"slow: marks tests as slow running\",\n    \"order: run tests in specific order\",\n]\nasyncio_mode = \"auto\"\nasyncio_default_fixture_loop_scope = \"session\"\nasyncio_default_test_loop_scope = \"session\"\ntimeout = 300\nlog_cli = true\nlog_cli_level = \"INFO\"\nlog_cli_format = \"%(asctime)s [%(levelname)s] %(name)s - %(message)s\"\nlog_cli_date_format = \"%Y-%m-%d %H:%M:%S\"\n\n[tool.ruff.lint]\nselect = [\n    \"E\",  # pycodestyle errors\n    \"W\",  # pycodestyle warnings\n    \"F\",  # pyflakes\n    \"I\",  # isort\n    \"B\",  # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"UP\", # pyupgrade\n]\nignore = [\n    \"E501\", # line too long, handled by formatter\n    \"B008\", # do not perform function calls in argument defaults\n    \"C901\", # too complex\n    \"B017\", # pytest.raises(Exception) is too broad\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"__init__.py\" = [\"F401\"]\n"
  },
  {
    "path": "tests/python/tests/__init__.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n"
  },
  {
    "path": "tests/python/tests/base_e2e_test.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nBase class for E2E tests providing common setup and configuration.\n\"\"\"\n\nimport os\nfrom datetime import timedelta\n\nimport httpx\nfrom opensandbox.config import ConnectionConfig, ConnectionConfigSync\n\nDEFAULT_DOMAIN = \"localhost:8080\"\nDEFAULT_PROTOCOL = \"http\"\nDEFAULT_API_KEY = \"e2e-test\"\nDEFAULT_IMAGE = \"sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:latest\"\n\nTEST_DOMAIN = os.getenv(\"OPENSANDBOX_TEST_DOMAIN\", DEFAULT_DOMAIN)\nTEST_PROTOCOL = os.getenv(\"OPENSANDBOX_TEST_PROTOCOL\", DEFAULT_PROTOCOL)\nTEST_API_KEY = os.getenv(\"OPENSANDBOX_TEST_API_KEY\", DEFAULT_API_KEY)\nTEST_IMAGE = os.getenv(\"OPENSANDBOX_SANDBOX_DEFAULT_IMAGE\", DEFAULT_IMAGE)\n\n\ndef get_sandbox_image() -> str:\n    \"\"\"Get the default sandbox image for E2E tests.\"\"\"\n    return TEST_IMAGE\n\n\ndef create_connection_config() -> ConnectionConfig:\n    \"\"\"Create async ConnectionConfig for E2E tests.\"\"\"\n    return ConnectionConfig(\n        domain=TEST_DOMAIN,\n        api_key=TEST_API_KEY,\n        request_timeout=timedelta(minutes=3),\n        protocol=TEST_PROTOCOL,\n    )\n\n\ndef create_connection_config_server_proxy() -> ConnectionConfig:\n    \"\"\"Create async ConnectionConfig for E2E tests using server-proxied endpoints.\"\"\"\n    return ConnectionConfig(\n        domain=TEST_DOMAIN,\n        api_key=TEST_API_KEY,\n        request_timeout=timedelta(minutes=3),\n        protocol=TEST_PROTOCOL,\n        use_server_proxy=True,\n    )\n\n\ndef create_connection_config_sync() -> ConnectionConfigSync:\n    \"\"\"Create sync ConnectionConfig for E2E tests.\"\"\"\n    return ConnectionConfigSync(\n        domain=TEST_DOMAIN,\n        api_key=TEST_API_KEY,\n        request_timeout=timedelta(minutes=3),\n        transport=httpx.HTTPTransport(\n            limits=httpx.Limits(\n                max_connections=100,\n                max_keepalive_connections=20,\n                keepalive_expiry=15,\n            )\n        ),\n        protocol=TEST_PROTOCOL,\n    )\n\n\ndef create_connection_config_sync_server_proxy() -> ConnectionConfigSync:\n    \"\"\"Create sync ConnectionConfig for E2E tests using server-proxied endpoints.\"\"\"\n    return ConnectionConfigSync(\n        domain=TEST_DOMAIN,\n        api_key=TEST_API_KEY,\n        request_timeout=timedelta(minutes=3),\n        transport=httpx.HTTPTransport(\n            limits=httpx.Limits(\n                max_connections=100,\n                max_keepalive_connections=20,\n                keepalive_expiry=15,\n            )\n        ),\n        protocol=TEST_PROTOCOL,\n        use_server_proxy=True,\n    )\n"
  },
  {
    "path": "tests/python/tests/test_code_interpreter_e2e.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive E2E tests for CodeInterpreter functionality.\n\nTests code execution capabilities including:\n- Multi-language code execution (Java, Python, Go, TypeScript)\n- Session state management and variable persistence\n- Context isolation between different execution contexts\n- Error handling and recovery mechanisms\n- Event handling patterns identical to runCommand\n\nThis file is intentionally split into ordered test methods (rather than one giant test)\nto make failures easier to locate and debug.\n\"\"\"\n\nimport asyncio\nimport logging\nimport time\nfrom collections.abc import Awaitable, Callable\nfrom contextlib import AsyncExitStack, asynccontextmanager\nfrom datetime import timedelta\n\nimport pytest\nfrom code_interpreter import CodeInterpreter\nfrom code_interpreter.models.code import SupportedLanguage\nfrom opensandbox import Sandbox\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.constants import DEFAULT_EXECD_PORT\nfrom opensandbox.models.execd import (\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionHandlers,\n    ExecutionInit,\n    ExecutionResult,\n    OutputMessage,\n)\nfrom opensandbox.models.sandboxes import Host, SandboxImageSpec, Volume\n\nfrom tests.base_e2e_test import create_connection_config, get_sandbox_image\n\nlogger = logging.getLogger(__name__)\n\n\ndef _now_ms() -> int:\n    return int(time.time() * 1000)\n\n\ndef _assert_recent_timestamp_ms(ts: int, *, tolerance_ms: int = 180_000) -> None:\n    assert isinstance(ts, int)\n    assert ts > 0\n    delta = abs(_now_ms() - ts)\n    assert delta <= tolerance_ms, f\"timestamp too far from now: delta={delta}ms (ts={ts})\"\n\n\ndef _assert_endpoint_has_port(endpoint: str, expected_port: int) -> None:\n    assert endpoint\n    assert \"://\" not in endpoint, f\"unexpected scheme in endpoint: {endpoint}\"\n    if \"/\" in endpoint:\n        assert endpoint.endswith(f\"/{expected_port}\"), (\n            f\"endpoint route must end with /{expected_port}: {endpoint}\"\n        )\n        assert endpoint.split(\"/\", 1)[0], f\"missing domain in endpoint: {endpoint}\"\n        return\n    host, port = endpoint.rsplit(\":\", 1)\n    assert host\n    assert port.isdigit()\n    assert int(port) == expected_port\n\n\ndef _assert_terminal_event_contract(\n        *,\n        init_events: list[ExecutionInit],\n        completed_events: list[ExecutionComplete],\n        errors: list[ExecutionError],\n        execution_id: str | None,\n) -> None:\n    # Contract: init must exist, and exactly one of (error, complete) exists.\n    assert len(init_events) == 1\n    assert init_events[0].id is not None and init_events[0].id.strip()\n    if execution_id is not None:\n        assert init_events[0].id == execution_id\n    _assert_recent_timestamp_ms(init_events[0].timestamp)\n    assert (len(completed_events) > 0) or (len(errors) > 0), (\n        f\"expected exactly one of complete/error, got complete={len(completed_events)} \"\n        f\"error={len(errors)}\"\n    )\n    if len(completed_events) > 0:\n        assert len(completed_events) == 1\n        _assert_recent_timestamp_ms(completed_events[0].timestamp)\n        assert completed_events[0].execution_time_in_millis >= 0\n    if len(errors) > 0:\n        assert errors[0].name\n        assert errors[0].value is not None\n        _assert_recent_timestamp_ms(errors[0].timestamp)\n\n\ndef _buffer_attempt_handlers(\n        handlers: ExecutionHandlers,\n) -> tuple[ExecutionHandlers, Callable[[], Awaitable[None]]]:\n    buffered_events: list[tuple[str, object]] = []\n\n    async def on_stdout(msg: OutputMessage) -> None:\n        buffered_events.append((\"stdout\", msg))\n\n    async def on_stderr(msg: OutputMessage) -> None:\n        buffered_events.append((\"stderr\", msg))\n\n    async def on_result(result: ExecutionResult) -> None:\n        buffered_events.append((\"result\", result))\n\n    async def on_complete(complete: ExecutionComplete) -> None:\n        buffered_events.append((\"complete\", complete))\n\n    async def on_error(error: ExecutionError) -> None:\n        buffered_events.append((\"error\", error))\n\n    async def on_init(init: ExecutionInit) -> None:\n        buffered_events.append((\"init\", init))\n\n    async def flush() -> None:\n        for event_type, payload in buffered_events:\n            if event_type == \"stdout\" and handlers.on_stdout is not None:\n                await handlers.on_stdout(payload)\n            elif event_type == \"stderr\" and handlers.on_stderr is not None:\n                await handlers.on_stderr(payload)\n            elif event_type == \"result\" and handlers.on_result is not None:\n                await handlers.on_result(payload)\n            elif (\n                event_type == \"complete\"\n                and handlers.on_execution_complete is not None\n            ):\n                await handlers.on_execution_complete(payload)\n            elif event_type == \"error\" and handlers.on_error is not None:\n                await handlers.on_error(payload)\n            elif event_type == \"init\" and handlers.on_init is not None:\n                await handlers.on_init(payload)\n\n    return (\n        ExecutionHandlers(\n            on_stdout=on_stdout if handlers.on_stdout is not None else None,\n            on_stderr=on_stderr if handlers.on_stderr is not None else None,\n            on_result=on_result if handlers.on_result is not None else None,\n            on_execution_complete=(\n                on_complete if handlers.on_execution_complete is not None else None\n            ),\n            on_error=on_error if handlers.on_error is not None else None,\n            on_init=on_init if handlers.on_init is not None else None,\n        ),\n        flush,\n    )\n\n\nasync def run_with_retry(\n    code_interpreter: CodeInterpreter,\n    code: str,\n    *,\n    context=None,\n    language=None,\n    handlers=None,\n    max_retries: int = 3,\n    retry_delay: float = 2.0,\n    per_call_timeout: float = 120.0,\n):\n    \"\"\"\n    Run code with retry logic for flaky kernel initialization and network errors.\n\n    Returns the execution result, retrying on:\n    - Empty/None id responses (kernel not ready)\n    - Network errors (connection reset, server disconnected)\n    - Per-call timeout (SSE stream hangs due to peer disconnect)\n    \"\"\"\n    last_result = None\n    last_exception = None\n\n    for attempt in range(max_retries):\n        try:\n            attempt_handlers = handlers\n            flush_attempt_events: Callable[[], Awaitable[None]] | None = None\n            if handlers is not None:\n                attempt_handlers, flush_attempt_events = _buffer_attempt_handlers(\n                    handlers\n                )\n\n            result = await asyncio.wait_for(\n                code_interpreter.codes.run(\n                    code,\n                    context=context,\n                    language=language,\n                    handlers=attempt_handlers,\n                ),\n                timeout=per_call_timeout,\n            )\n            last_result = result\n            if result is not None and result.id is not None:\n                if flush_attempt_events is not None:\n                    await flush_attempt_events()\n                return result\n            # Empty result - retry\n            if attempt < max_retries - 1:\n                logger.warning(\n                    \"Execution returned empty result (attempt %d/%d), retrying in %.1fs...\",\n                    attempt + 1, max_retries, retry_delay\n                )\n                await asyncio.sleep(retry_delay)\n                retry_delay *= 1.5  # exponential backoff\n        except asyncio.TimeoutError:\n            last_exception = TimeoutError(\n                f\"codes.run() did not complete within {per_call_timeout}s\"\n            )\n            if attempt < max_retries - 1:\n                logger.warning(\n                    \"Execution timed out after %.0fs (attempt %d/%d), retrying in %.1fs...\",\n                    per_call_timeout, attempt + 1, max_retries, retry_delay,\n                )\n                await asyncio.sleep(retry_delay)\n                retry_delay *= 1.5\n            else:\n                logger.error(\n                    \"Execution timed out after %.0fs on final attempt %d/%d\",\n                    per_call_timeout, attempt + 1, max_retries,\n                )\n        except Exception as e:\n            last_exception = e\n            error_name = type(e).__name__\n            # Check if it's a retryable network error\n            error_str = str(e).lower()\n            is_retryable = any(keyword in error_str for keyword in [\n                \"disconnected\", \"connection\", \"reset\", \"closed\", \"timeout\",\n                \"remoteerror\", \"protocol\", \"peer closed\", \"session is busy\",\n            ])\n            if is_retryable and attempt < max_retries - 1:\n                logger.warning(\n                    \"Execution failed with %s (attempt %d/%d), retrying in %.1fs: %s\",\n                    error_name, attempt + 1, max_retries, retry_delay, str(e)[:100]\n                )\n                await asyncio.sleep(retry_delay)\n                retry_delay *= 1.5\n            else:\n                # Non-retryable error or last attempt\n                raise\n\n    # If we have a result (even empty), return it; otherwise raise last exception\n    if last_result is not None:\n        return last_result\n    if last_exception is not None:\n        raise last_exception\n    return None\n\n\nasync def create_context_with_retry(\n    code_interpreter: CodeInterpreter,\n    language: str,\n    max_retries: int = 3,\n    retry_delay: float = 2.0,\n):\n    \"\"\"Create a code context with retry logic for network errors.\"\"\"\n    last_exception = None\n    for attempt in range(max_retries):\n        try:\n            ctx = await code_interpreter.codes.create_context(language)\n            # Small delay to allow kernel initialization\n            await asyncio.sleep(0.5)\n            return ctx\n        except Exception as e:\n            last_exception = e\n            error_str = str(e).lower()\n            is_retryable = any(keyword in error_str for keyword in [\n                \"disconnected\", \"connection\", \"reset\", \"closed\", \"timeout\",\n                \"remoteerror\", \"protocol\", \"peer closed\"\n            ])\n            if is_retryable and attempt < max_retries - 1:\n                logger.warning(\n                    \"Context creation failed (attempt %d/%d), retrying in %.1fs: %s\",\n                    attempt + 1, max_retries, retry_delay, str(e)[:100]\n                )\n                await asyncio.sleep(retry_delay)\n                retry_delay *= 1.5\n            else:\n                raise\n    raise last_exception  # type: ignore\n\n\n@asynccontextmanager\nasync def managed_ctx(code_interpreter: CodeInterpreter, language: str):\n    ctx = await create_context_with_retry(code_interpreter, language)\n    try:\n        yield ctx\n    finally:\n        # Best-effort cleanup with retry and a hard timeout so that an\n        # unreachable sandbox (dead container / network gone) cannot block\n        # the test suite indefinitely.\n        for cleanup_attempt in range(2):\n            try:\n                if ctx.id:\n                    await asyncio.wait_for(\n                        code_interpreter.codes.delete_context(ctx.id),\n                        timeout=10.0,\n                    )\n                break\n            except Exception:\n                if cleanup_attempt == 0:\n                    await asyncio.sleep(0.5)\n                else:\n                    logger.warning(\n                        \"Cleanup: failed to delete context %s (%s)\", ctx.id, language, exc_info=True\n                    )\n\n\n@asynccontextmanager\nasync def managed_ctx_stack(code_interpreter: CodeInterpreter, languages: list[str]):\n    async with AsyncExitStack() as stack:\n        contexts = []\n        for lang in languages:\n            contexts.append(await stack.enter_async_context(managed_ctx(code_interpreter, lang)))\n        yield contexts\n\n\n@pytest.mark.asyncio\nclass TestCodeInterpreterE2E:\n    \"\"\"Comprehensive E2E tests for CodeInterpreter runCode functionality (ordered).\"\"\"\n\n    sandbox: Sandbox | None = None\n    code_interpreter: CodeInterpreter | None = None\n    connection_config: ConnectionConfig | None = None\n    _setup_done = False\n\n    @pytest.fixture(scope=\"class\", autouse=True)\n    async def _ci_lifecycle(self, request):\n        \"\"\"Create sandbox + code interpreter once and ALWAYS cleanup.\"\"\"\n        await request.cls._ensure_code_interpreter_created()\n        try:\n            yield\n        finally:\n            sandbox = request.cls.sandbox\n            if sandbox is not None:\n                try:\n                    await sandbox.kill()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.kill() failed: %s\", e, exc_info=True)\n                try:\n                    await sandbox.close()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.close() failed: %s\", e, exc_info=True)\n\n    @classmethod\n    async def _ensure_code_interpreter_created(cls) -> None:\n        \"\"\"Create CodeInterpreter once and reuse it across ordered tests.\"\"\"\n        if cls._setup_done:\n            return\n\n        logger.info(\"=\" * 100)\n        logger.info(\"SETUP: Creating sandbox and creating CodeInterpreter\")\n        logger.info(\"=\" * 100)\n\n        cls.connection_config = create_connection_config()\n\n        cls.sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n            connection_config=cls.connection_config,\n            timeout=timedelta(minutes=15),\n            ready_timeout=timedelta(seconds=60),\n            metadata={\"tag\": \"e2e-code-interpreter\"},\n            env={\n                \"E2E_TEST\": \"true\",\n                \"GO_VERSION\": \"1.25\",\n                \"JAVA_VERSION\": \"21\",\n                \"NODE_VERSION\": \"22\",\n                \"PYTHON_VERSION\": \"3.12\",\n                \"EXECD_LOG_FILE\": \"/tmp/opensandbox-e2e/logs/execd.log\",\n            },\n            health_check_polling_interval=timedelta(milliseconds=500),\n            volumes=[\n                Volume(\n                    name=\"execd-log\",\n                    host=Host(path=\"/tmp/opensandbox-e2e/logs\"),\n                    mountPath=\"/tmp/opensandbox-e2e/logs\",\n                    readOnly=False,\n                ),\n            ],\n        )\n\n        cls.code_interpreter = await CodeInterpreter.create(sandbox=cls.sandbox)\n\n        assert cls.code_interpreter is not None\n        assert isinstance(cls.code_interpreter.id, str)\n        logger.info(\"✓ CodeInterpreter created: %s\", cls.code_interpreter.id)\n        logger.info(\"=\" * 100)\n\n        cls._setup_done = True\n\n    @pytest.mark.timeout(600)\n    @pytest.mark.order(1)\n    async def test_01_creation_and_basic_functionality(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1: CodeInterpreter creation and basic functionality\")\n        logger.info(\"=\" * 80)\n\n        assert code_interpreter.codes is not None\n        assert code_interpreter.files is not None\n        assert code_interpreter.commands is not None\n        assert code_interpreter.metrics is not None\n        logger.info(\"✓ All service components are accessible\")\n\n        assert await code_interpreter.sandbox.is_healthy() is True\n        logger.info(\"✓ CodeInterpreter is healthy\")\n\n        info = await code_interpreter.sandbox.get_info()\n        assert str(code_interpreter.id) == str(info.id)\n        assert info.status.state == \"Running\"\n        logger.info(\n            \"✓ CodeInterpreter info: state=%s, created=%s\",\n            info.status.state,\n            info.created_at,\n        )\n\n        endpoint = await code_interpreter.sandbox.get_endpoint(DEFAULT_EXECD_PORT)\n        assert endpoint is not None\n        assert endpoint.endpoint is not None\n        _assert_endpoint_has_port(endpoint.endpoint, DEFAULT_EXECD_PORT)\n        logger.info(\"✓ CodeInterpreter endpoint: %s\", endpoint.endpoint)\n\n        metrics = await code_interpreter.sandbox.get_metrics()\n        assert metrics is not None\n        assert metrics.cpu_count > 0\n        assert 0.0 <= metrics.cpu_used_percentage <= 100.0\n        assert metrics.memory_total_in_mib > 0\n        assert 0.0 <= metrics.memory_used_in_mib <= metrics.memory_total_in_mib\n        _assert_recent_timestamp_ms(metrics.timestamp)\n        logger.info(\n            \"✓ CPU: %s cores, %.2f%% used\",\n            metrics.cpu_count,\n            metrics.cpu_used_percentage,\n        )\n        logger.info(\n            \"✓ Memory: %s/%s MiB\",\n            int(metrics.memory_used_in_mib),\n            int(metrics.memory_total_in_mib),\n        )\n\n        # Renewal through CodeInterpreter (extend expiration time)\n        renew_response = await code_interpreter.sandbox.renew(timedelta(minutes=20))\n        assert renew_response is not None\n        logger.info(\"✓ CodeInterpreter expiration renewed to %s\", renew_response.expires_at)\n\n        renewed_info = await code_interpreter.sandbox.get_info()\n        assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10\n        now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)\n        remaining = renewed_info.expires_at - now\n        assert remaining > timedelta(minutes=18)\n        assert remaining < timedelta(minutes=22)\n        logger.info(\"✓ Expiration updated to %s\", renewed_info.expires_at)\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(2)\n    async def test_02_java_code_execution(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 2: Java code execution\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx(code_interpreter, SupportedLanguage.JAVA) as java_context:\n            assert java_context.id is not None and java_context.id.strip()\n            assert java_context.language == \"java\"\n            logger.info(\"✓ Java context created\")\n\n            stdout_messages: list[OutputMessage] = []\n            stderr_messages: list[OutputMessage] = []\n            results: list[ExecutionResult] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            async def on_stdout(msg: OutputMessage):\n                stdout_messages.append(msg)\n                logger.info(\"Java stdout: %s\", msg.text)\n\n            async def on_stderr(msg: OutputMessage):\n                stderr_messages.append(msg)\n                logger.warning(\"Java stderr: %s\", msg.text)\n\n            async def on_result(result: ExecutionResult):\n                results.append(result)\n                logger.info(\"Java result: %s\", result.text)\n\n            async def on_complete(complete: ExecutionComplete):\n                completed_events.append(complete)\n                logger.info(\n                    \"Java execution completed in %s ms\", complete.execution_time_in_millis\n                )\n\n            async def on_error(error: ExecutionError):\n                errors.append(error)\n                logger.error(\"Java error: %s - %s\", error.name, error.value)\n\n            async def on_init(init: ExecutionInit):\n                init_events.append(init)\n                logger.info(\"Java execution initialized with ID: %s\", init.id)\n\n            handlers = ExecutionHandlers(\n                on_stdout=on_stdout,\n                on_stderr=on_stderr,\n                on_result=on_result,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            # Use retry for first execution in context (Java kernel init can be slow)\n            simple_result = await run_with_retry(\n                code_interpreter,\n                \"System.out.println(\\\"Hello from Java!\\\");\\n\"\n                + \"int result = 2 + 2;\\n\"\n                + \"System.out.println(\\\"2 + 2 = \\\" + result);\\n\"\n                + \"result\",\n                context=java_context,\n                handlers=handlers,\n                )\n            assert simple_result is not None\n            assert simple_result.id is not None and simple_result.id.strip()\n            assert len(simple_result.result) > 0\n            assert simple_result.result[0].text == \"4\"\n\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert len(stdout_messages) > 0\n            assert any(\"Hello from Java!\" in m.text for m in stdout_messages)\n            # Depending on kernel formatting, spaces may vary; normalize spaces for matching.\n            assert any(\n                \"2+2=4\" in m.text.replace(\" \", \"\") for m in stdout_messages\n            )\n            assert all(m.is_error is False for m in stdout_messages)\n            for m in stdout_messages[:3]:\n                _assert_recent_timestamp_ms(m.timestamp)\n            logger.info(\"✓ Simple Java execution successful\")\n\n            var_result = await code_interpreter.codes.run(\n                \"import java.util.*;\\n\"\n                + \"List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);\\n\"\n                + \"int sum = numbers.stream().mapToInt(Integer::intValue).sum();\\n\"\n                + \"System.out.println(\\\"Numbers: \\\" + numbers);\\n\"\n                + \"System.out.println(\\\"Sum: \\\" + sum);\\n\"\n                + \"result\",\n                context=java_context,\n                )\n            assert var_result is not None\n            assert var_result.id is not None\n            assert len(var_result.result) > 0\n            assert var_result.result[0].text == \"4\"\n            logger.info(\"✓ Java variables and state persistence work correctly\")\n\n            # Error handling test\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result = await code_interpreter.codes.run(\n                \"int x = 10 / 0; // This will cause ArithmeticException\",\n                context=java_context,\n                handlers=handlers,\n            )\n            assert error_result is not None\n            assert error_result.id is not None and error_result.id.strip()\n            assert error_result.error is not None\n            assert error_result.error.name == \"EvalException\"\n\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result.id,\n            )\n            assert len(errors) > 0\n            assert errors[0].name == \"EvalException\"\n            logger.info(\"✓ Java error handling works correctly\")\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(3)\n    async def test_03_python_code_execution(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 3: Python code execution\")\n        logger.info(\"=\" * 80)\n\n        # New usage: directly pass a language string (ephemeral context).\n        # This validates the `codes.run(..., language=...)` convenience interface.\n        # Use retry helper for the first call — kernel initialization can be flaky.\n        direct_lang_result = await run_with_retry(\n            code_interpreter,\n            \"result = 2 + 2\\nresult\",\n            language=SupportedLanguage.PYTHON,\n        )\n        assert direct_lang_result is not None\n        assert direct_lang_result.id is not None and direct_lang_result.id.strip()\n        assert direct_lang_result.error is None\n        assert len(direct_lang_result.result) > 0\n        assert direct_lang_result.result[0].text == \"4\"\n\n        stdout_messages: list[OutputMessage] = []\n        stderr_messages: list[OutputMessage] = []\n        errors: list[ExecutionError] = []\n        completed_events: list[ExecutionComplete] = []\n        init_events: list[ExecutionInit] = []\n\n        async def on_stdout(msg: OutputMessage):\n            stdout_messages.append(msg)\n            logger.info(\"Python stdout: %s\", msg.text)\n\n        async def on_stderr(msg: OutputMessage):\n            stderr_messages.append(msg)\n            logger.warning(\"Python stderr: %s\", msg.text)\n\n        async def on_complete(complete: ExecutionComplete):\n            completed_events.append(complete)\n            logger.info(\n                \"Python execution completed in %s ms\", complete.execution_time_in_millis\n            )\n\n        async def on_error(error: ExecutionError):\n            errors.append(error)\n            logger.error(\"Python error: %s - %s\", error.name, error.value)\n\n        async def on_init(init: ExecutionInit):\n            init_events.append(init)\n            logger.info(\"Python execution initialized with ID: %s\", init.id)\n\n        handlers_py = ExecutionHandlers(\n            on_stdout=on_stdout,\n            on_stderr=on_stderr,\n            on_execution_complete=on_complete,\n            on_error=on_error,\n            on_init=on_init,\n        )\n\n        async with managed_ctx(code_interpreter, SupportedLanguage.PYTHON) as python_context:\n            assert python_context.id is not None and python_context.id.strip()\n            logger.info(\"✓ Python context created\")\n\n            # Use retry for first execution in context (kernel init can be flaky)\n            simple_result_py = await run_with_retry(\n                code_interpreter,\n                \"print('Hello from Python!')\\n\"\n                + \"result = 2 + 2\\n\"\n                + \"print(f'2 + 2 = {result}')\",\n                context=python_context,\n                handlers=handlers_py,\n                )\n            assert simple_result_py is not None\n            assert simple_result_py.id is not None and simple_result_py.id.strip()\n\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_py.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert any(\"Hello from Python!\" in m.text for m in stdout_messages)\n            assert any(\"2 + 2 = 4\" in m.text for m in stdout_messages)\n            logger.info(\"✓ Simple Python execution successful\")\n\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            var_result_py = await code_interpreter.codes.run(\n                \"x = 42\\n\"\n                + \"y = 'persistent variable'\\n\"\n                + \"my_list = [1, 2, 3, 4, 5]\\n\"\n                + \"print(f'x={x}, y=\\\"{y}\\\", list={my_list}')\\n\"\n                + \"result\",\n                context=python_context,\n                handlers=handlers_py,\n                )\n            assert var_result_py is not None\n            assert var_result_py.id is not None and var_result_py.id.strip()\n            assert len(var_result_py.result) > 0\n            assert var_result_py.result[0].text == \"4\"\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=var_result_py.id,\n            )\n            logger.info(\"✓ Python variables and state persistence work correctly\")\n\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n            persist_result = await code_interpreter.codes.run(\n                \"print(f'Previously set variables: x={x}, y={y}')\\n\"\n                + \"z = sum(my_list)\\n\"\n                + \"print(f'Sum of list: {z}')\",\n                context=python_context,\n                handlers=handlers_py,\n                )\n            assert persist_result is not None\n            assert persist_result.id is not None and persist_result.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=persist_result.id,\n            )\n            assert any(\"Previously set variables: x=42\" in m.text for m in stdout_messages)\n            assert any(\"Sum of list: 15\" in m.text for m in stdout_messages)\n            logger.info(\"✓ Python variable persistence across executions works\")\n\n            # Error handling\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result_py = await code_interpreter.codes.run(\n                \"print(undefined_variable)  # This will cause NameError\",\n                context=python_context,\n                handlers=handlers_py,\n            )\n            assert error_result_py is not None\n            assert error_result_py.id is not None and error_result_py.id.strip()\n            assert error_result_py.error is not None or len(error_result_py.logs.stderr) > 0\n\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_py.id,\n            )\n            assert len(errors) > 0\n            if error_result_py.error:\n                assert (\n                        \"NameError\" in error_result_py.error.name\n                        or \"NameError\" in error_result_py.error.value\n                )\n            assert \"NameError\" in errors[0].name or \"NameError\" in errors[0].value\n            logger.info(\"✓ Python error handling works correctly\")\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(4)\n    async def test_04_go_code_execution(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 4: Go code execution\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx(code_interpreter, SupportedLanguage.GO) as go_context:\n            assert go_context.id is not None and go_context.id.strip()\n            assert go_context.language == \"go\"\n            logger.info(\"✓ Go context created\")\n\n            stdout_messages: list[OutputMessage] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            async def on_stdout(msg: OutputMessage):\n                stdout_messages.append(msg)\n                logger.info(\"Go stdout: %s\", msg.text)\n\n            async def on_complete(complete: ExecutionComplete):\n                completed_events.append(complete)\n                logger.info(\"Go execution completed in %s ms\", complete.execution_time_in_millis)\n\n            async def on_error(error: ExecutionError):\n                errors.append(error)\n                logger.error(\"Go error: %s - %s\", error.name, error.value)\n\n            async def on_init(init: ExecutionInit):\n                init_events.append(init)\n                logger.info(\"Go execution initialized with ID: %s\", init.id)\n\n            handlers_go = ExecutionHandlers(\n                on_stdout=on_stdout,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            # Use retry for first execution in context (Go compile can be slow)\n            simple_result_go = await run_with_retry(\n                code_interpreter,\n                \"package main\\n\"\n                + \"import \\\"fmt\\\"\\n\"\n                + \"func main() {\\n\"\n                + \"    fmt.Print(\\\"Hello from Go!\\\")\\n\"\n                + \"    result := 2 + 2\\n\"\n                + \"    fmt.Print(\\\"2 + 2 =\\\", result)\\n\"\n                + \"}\",\n                context=go_context,\n                handlers=handlers_go,\n                )\n            assert simple_result_go is not None\n            assert simple_result_go.id is not None and simple_result_go.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_go.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert len(stdout_messages) > 0\n            logger.info(\"✓ Simple Go execution successful\")\n\n            data_result_go = await code_interpreter.codes.run(\n                \"package main\\n\"\n                + \"import \\\"fmt\\\"\\n\"\n                + \"func calculate(numbers []int) int {\\n\"\n                + \"    sum := 0\\n\"\n                + \"    for _, num := range numbers {\\n\"\n                + \"        sum += num\\n\"\n                + \"    }\\n\"\n                + \"    return sum\\n\"\n                + \"}\\n\"\n                + \"func main() {\\n\"\n                + \"    numbers := []int{1, 2, 3, 4, 5}\\n\"\n                + \"    sum := calculate(numbers)\\n\"\n                + \"    fmt.Print(\\\"Numbers:\\\", numbers)\\n\"\n                + \"    fmt.Print(\\\"Sum:\\\", sum)\\n\"\n                + \"}\",\n                context=go_context,\n                )\n            assert data_result_go is not None\n            assert data_result_go.id is not None\n            logger.info(\"✓ Go data structures and functions work correctly\")\n\n            # Compilation error\n            stdout_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result_go = await code_interpreter.codes.run(\n                \"package main\\n\"\n                + \"func main() {\\n\"\n                + \"    undeclaredVariable++  // This will cause compilation error\\n\"\n                + \"}\",\n                context=go_context,\n                handlers=handlers_go,\n                )\n            assert error_result_go is not None\n            assert error_result_go.id is not None and error_result_go.id.strip()\n            assert error_result_go.error is not None or len(error_result_go.logs.stderr) > 0\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_go.id,\n            )\n            logger.info(\"✓ Go error handling works correctly\")\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(5)\n    async def test_05_typescript_code_execution(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 5: TypeScript code execution\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx(code_interpreter, SupportedLanguage.TYPESCRIPT) as ts_context:\n            assert ts_context.id is not None and ts_context.id.strip()\n            assert ts_context.language == \"typescript\"\n            logger.info(\"✓ TypeScript context created\")\n\n            stdout_messages: list[OutputMessage] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            async def on_stdout(msg: OutputMessage):\n                stdout_messages.append(msg)\n                logger.info(\"TypeScript stdout: %s\", msg.text)\n\n            async def on_complete(complete: ExecutionComplete):\n                completed_events.append(complete)\n                logger.info(\n                    \"TypeScript execution completed in %s ms\", complete.execution_time_in_millis\n                )\n\n            async def on_error(error: ExecutionError):\n                errors.append(error)\n                logger.error(\"TypeScript error: %s - %s\", error.name, error.value)\n\n            async def on_init(init: ExecutionInit):\n                init_events.append(init)\n                logger.info(\"TypeScript execution initialized with ID: %s\", init.id)\n\n            handlers_ts = ExecutionHandlers(\n                on_stdout=on_stdout,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            # Use retry for first execution in context (TS init can be slow)\n            simple_result_ts = await run_with_retry(\n                code_interpreter,\n                \"console.log('Hello from TypeScript!');\\n\"\n                + \"const result: number = 2 + 2;\\n\"\n                + \"console.log(`2 + 2 = ${result}`);\",\n                context=ts_context,\n                handlers=handlers_ts,\n                )\n            assert simple_result_ts is not None\n            assert simple_result_ts.id is not None and simple_result_ts.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_ts.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert any(\"Hello from TypeScript!\" in m.text for m in stdout_messages)\n            logger.info(\"✓ Simple TypeScript execution successful\")\n\n            types_result_ts = await code_interpreter.codes.run(\n                \"interface Person {\\n\"\n                + \"  name: string;\\n\"\n                + \"  age: number;\\n\"\n                + \"}\\n\"\n                + \"const person: Person = { name: 'John', age: 30 };\\n\"\n                + \"const numbers: number[] = [1, 2, 3, 4, 5];\\n\"\n                + \"const sum: number = numbers.reduce((a, b) => a + b, 0);\\n\"\n                + \"console.log(`Person: ${person.name}, Age: ${person.age}`);\\n\"\n                + \"console.log(`Numbers: ${numbers}`);\\n\"\n                + \"console.log(`Sum: ${sum}`);\",\n                context=ts_context,\n                )\n            assert types_result_ts is not None\n            assert types_result_ts.id is not None\n            logger.info(\"✓ TypeScript types and interfaces work correctly\")\n\n            # Type error\n            stdout_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            # Use a deterministic runtime error (TypeScript compile/type-checking may be configured permissively).\n            error_result_ts = await code_interpreter.codes.run(\n                \"throw new Error('ts-runtime-error');\",\n                context=ts_context,\n                handlers=handlers_ts,\n            )\n            assert error_result_ts is not None\n            assert error_result_ts.id is not None and error_result_ts.id.strip()\n            assert error_result_ts.error is not None or len(error_result_ts.logs.stderr) > 0\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_ts.id,\n            )\n            logger.info(\"✓ TypeScript error handling works correctly\")\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(6)\n    async def test_06_multi_language_support_and_context_isolation(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 6: Multi-language support and context isolation\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx_stack(\n            code_interpreter,\n            [\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.JAVA,\n                SupportedLanguage.GO,\n            ],\n        ) as (python1, python2, java1, go1):\n            logger.info(\"✓ Created multiple contexts for different languages\")\n\n            # Use retry helper for flaky kernel initialization\n            result1 = await run_with_retry(\n                code_interpreter,\n                \"secret_value1 = 'python1_secret'\\nprint(f'Python1 secret: {secret_value1}')\",\n                context=python1,\n            )\n            result2 = await run_with_retry(\n                code_interpreter,\n                \"secret_value2 = 'python2_secret'\\nprint(f'Python2 secret: {secret_value2}')\",\n                context=python2,\n            )\n            assert result1 is not None and result1.id is not None\n            assert result2 is not None and result2.id is not None\n            logger.info(\"✓ Variables set in different Python contexts\")\n\n            check1 = await code_interpreter.codes.run(\n                \"print(f'Python1 still has: {secret_value1}')\",\n                context=python1,\n            )\n            check2 = await code_interpreter.codes.run(\n                \"print(f'Python2 has no: {secret_value1}')\",\n                context=python2,\n            )\n            assert check1 is not None\n            assert check2 is not None\n            assert check2.error is not None\n            assert check2.error.name == \"NameError\"\n            logger.info(\"✓ Context isolation verified - contexts are properly isolated\")\n\n            java_result = await run_with_retry(\n                code_interpreter,\n                \"String javaSecret = \\\"java_secret\\\";\\n\"\n                + \"System.out.println(\\\"Java secret: \\\" + javaSecret);\",\n                context=java1,\n                )\n            go_result = await run_with_retry(\n                code_interpreter,\n                \"package main\\n\"\n                + \"import \\\"fmt\\\"\\n\"\n                + \"func main() {\\n\"\n                + \"    goSecret := \\\"go_secret\\\"\\n\"\n                + \"    fmt.Print(\\\"Go secret:\\\", goSecret)\\n\"\n                + \"}\",\n                context=go1,\n                )\n            assert java_result is not None and java_result.id is not None\n            assert go_result is not None and go_result.id is not None\n            logger.info(\"✓ Cross-language execution works correctly\")\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(7)\n    async def test_07_concurrent_code_execution(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 7: Concurrent code execution\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx_stack(\n            code_interpreter,\n            [\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.JAVA,\n                SupportedLanguage.GO,\n            ],\n        ) as (python_c1, java_c1, go_c1):\n            logger.info(\"✓ Created contexts for concurrent execution\")\n\n            async def run_python1():\n                return await code_interpreter.codes.run(\n                    \"import time\\n\"\n                    + \"for i in range(3):\\n\"\n                    + \"    print(f'Python1 iteration {i}')\\n\"\n                    + \"    time.sleep(0.1)\\n\"\n                    + \"print('Python1 completed')\",\n                    context=python_c1,\n                    )\n\n            async def run_java_concurrent():\n                return await code_interpreter.codes.run(\n                    \"for (int i = 0; i < 3; i++) {\\n\"\n                    + \"    System.out.println(\\\"Java iteration \\\" + i);\\n\"\n                    + \"    try { Thread.sleep(100); } catch (Exception e) {}\\n\"\n                    + \"}\\n\"\n                    + \"System.out.println(\\\"Java completed\\\");\",\n                    context=java_c1,\n                    )\n\n            async def run_go_concurrent():\n                return await code_interpreter.codes.run(\n                    \"package main\\n\"\n                    + \"import \\\"fmt\\\"\\n\"\n                    + \"func main() {\\n\"\n                    + \"    for i := 0; i < 3; i++ {\\n\"\n                    + \"        fmt.Print(\\\"Go iteration\\\", i)\\n\"\n                    + \"    }\\n\"\n                    + \"    fmt.Print(\\\"Go completed\\\")\\n\"\n                    + \"}\",\n                    context=go_c1,\n                    )\n\n            results = await asyncio.gather(\n                run_python1(), run_java_concurrent(), run_go_concurrent()\n            )\n            for result in results:\n                assert result is not None\n                assert result.id is not None\n                logger.info(\"✓ Concurrent execution completed: %s\", result.id)\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(8)\n    async def test_08_code_execution_interrupt(self):\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 8: Code execution interrupt\")\n        logger.info(\"=\" * 80)\n\n        async with managed_ctx(code_interpreter, SupportedLanguage.PYTHON) as python_int_context:\n            assert python_int_context.id is not None and python_int_context.id.strip()\n\n            init_events_int: list[ExecutionInit] = []\n            completed_events: list[ExecutionComplete] = []\n            errors: list[ExecutionError] = []\n            init_received = asyncio.Event()\n\n            async def on_init(init: ExecutionInit):\n                init_events_int.append(init)\n                init_received.set()\n\n            async def on_complete(complete: ExecutionComplete):\n                completed_events.append(complete)\n\n            async def on_error(error: ExecutionError):\n                errors.append(error)\n\n            handlers_int = ExecutionHandlers(\n                on_init=on_init,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n            )\n\n            execution_task = asyncio.create_task(\n                code_interpreter.codes.run(\n                    \"import time\\n\"\n                    + \"print('Starting long-running Python execution')\\n\"\n                    + \"for i in range(100):\\n\"\n                    + \"    print(f'Python iteration {i}')\\n\"\n                    + \"    time.sleep(0.2)\\n\",\n                    context=python_int_context,\n                    handlers=handlers_int,\n                    )\n            )\n\n            await asyncio.wait_for(init_received.wait(), timeout=15)\n            assert len(init_events_int) == 1, \"Execution should have been initialized exactly once\"\n            execution_id = init_events_int[-1].id\n            assert execution_id is not None\n            logger.info(\"✓ Execution initialized with ID: %s\", execution_id)\n\n            await asyncio.wait_for(\n                code_interpreter.codes.interrupt(execution_id),\n                timeout=15.0,\n            )\n\n            # After interrupt the SSE stream should close promptly.  Add a\n            # hard timeout so that a slow/stuck server cannot block the test\n            # for the full 900 s pytest-timeout.\n            try:\n                result_int = await asyncio.wait_for(execution_task, timeout=60.0)\n            except (asyncio.TimeoutError, Exception) as exc:\n                execution_task.cancel()\n                logger.warning(\n                    \"Execution task did not return cleanly after interrupt: %s\", exc\n                )\n                result_int = None\n\n            if result_int is not None:\n                assert result_int.id is not None\n                assert result_int.id == execution_id\n\n            quick_result = None\n            try:\n                quick_result = await asyncio.wait_for(\n                    code_interpreter.codes.run(\n                        \"print('Quick Python execution')\\n\"\n                        + \"result = 2 + 2\\n\"\n                        + \"print(f'Result: {result}')\",\n                        context=python_int_context,\n                        handlers=handlers_int,\n                    ),\n                    timeout=60.0,\n                )\n                assert quick_result is not None\n                assert quick_result.id is not None\n            except (asyncio.TimeoutError, Exception) as exc:\n                logger.warning(\"Quick execution after interrupt failed: %s\", exc)\n\n            # Different backends may close the interrupted SSE stream without\n            # emitting an explicit terminal event. Accept either a terminal\n            # event or proof that the context became usable again.\n            assert (\n                len(completed_events) > 0\n                or len(errors) > 0\n                or quick_result is not None\n            ), \"expected terminal event or successful follow-up execution after interrupt\"\n            logger.info(\"✓ Python execution was interrupted successfully\")\n\n            # Interrupting a completed execution may or may not throw depending on backend behavior.\n            try:\n                if quick_result is not None:\n                    await asyncio.wait_for(\n                        code_interpreter.codes.interrupt(quick_result.id),\n                        timeout=10.0,\n                    )\n            except Exception:\n                pass\n\n    @pytest.mark.timeout(600)\n    @pytest.mark.order(9)\n    async def test_09_context_management_endpoints(self):\n        \"\"\"Validate list/get/delete context APIs map to execd /code/contexts endpoints.\"\"\"\n        await self._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2E.code_interpreter\n        assert code_interpreter is not None\n\n        language = SupportedLanguage.BASH\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 9: Context management endpoints (%s)\", language)\n        logger.info(\"=\" * 80)\n\n        # Ensure clean slate for bash contexts to avoid interference with other tests.\n        await code_interpreter.codes.delete_contexts(language)\n\n        ctx1 = await code_interpreter.codes.create_context(language)\n        ctx2 = await code_interpreter.codes.create_context(language)\n        assert ctx1.id is not None and ctx1.id.strip()\n        assert ctx2.id is not None and ctx2.id.strip()\n        assert ctx1.language == language\n        assert ctx2.language == language\n        logger.info(\"✓ Created two bash contexts: %s, %s\", ctx1.id, ctx2.id)\n\n        listed = await code_interpreter.codes.list_contexts(language)\n        bash_context_ids = {c.id for c in listed if c.id}\n        assert ctx1.id in bash_context_ids\n        assert ctx2.id in bash_context_ids\n        assert all(c.language == language for c in listed)\n        logger.info(\"✓ list_contexts returned expected bash contexts\")\n\n        fetched = await code_interpreter.codes.get_context(ctx1.id)\n        assert fetched.id == ctx1.id\n        assert fetched.language == language\n        logger.info(\"✓ get_context returned expected context %s\", fetched.id)\n\n        await code_interpreter.codes.delete_context(ctx1.id)\n        remaining = await code_interpreter.codes.list_contexts(language)\n        remaining_ids = {c.id for c in remaining if c.id}\n        assert ctx1.id not in remaining_ids\n        assert ctx2.id in remaining_ids\n        logger.info(\"✓ delete_context removed %s\", ctx1.id)\n\n        await code_interpreter.codes.delete_contexts(language)\n        final_contexts = [\n            c for c in await code_interpreter.codes.list_contexts(language) if c.id\n        ]\n        assert len(final_contexts) == 0\n        logger.info(\"✓ delete_contexts removed all bash contexts\")\n"
  },
  {
    "path": "tests/python/tests/test_code_interpreter_e2e_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive Sync E2E tests for CodeInterpreterSync functionality.\n\nThis mirrors `test_code_interpreter_e2e.py` but uses the synchronous SDK.\n\"\"\"\n\nimport logging\nimport time\nfrom collections.abc import Callable\nfrom concurrent.futures import ThreadPoolExecutor\nfrom contextlib import ExitStack, contextmanager\nfrom datetime import timedelta\n\nimport pytest\nfrom code_interpreter import CodeInterpreterSync\nfrom code_interpreter.models.code import SupportedLanguage\nfrom opensandbox import SandboxSync\nfrom opensandbox.config import ConnectionConfigSync\nfrom opensandbox.constants import DEFAULT_EXECD_PORT\nfrom opensandbox.models.execd import (\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionInit,\n    ExecutionResult,\n    OutputMessage,\n)\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\nfrom opensandbox.models.sandboxes import Host, SandboxImageSpec, Volume\n\nfrom tests.base_e2e_test import create_connection_config_sync, get_sandbox_image\n\nlogger = logging.getLogger(__name__)\n\n\ndef _now_ms() -> int:\n    return int(time.time() * 1000)\n\n\ndef _assert_recent_timestamp_ms(ts: int, *, tolerance_ms: int = 180_000) -> None:\n    assert isinstance(ts, int)\n    assert ts > 0\n    delta = abs(_now_ms() - ts)\n    assert delta <= tolerance_ms, f\"timestamp too far from now: delta={delta}ms (ts={ts})\"\n\n\ndef _assert_endpoint_has_port(endpoint: str, expected_port: int) -> None:\n    assert endpoint\n    assert \"://\" not in endpoint, f\"unexpected scheme in endpoint: {endpoint}\"\n    if \"/\" in endpoint:\n        assert endpoint.endswith(f\"/{expected_port}\"), (\n            f\"endpoint route must end with /{expected_port}: {endpoint}\"\n        )\n        assert endpoint.split(\"/\", 1)[0], f\"missing domain in endpoint: {endpoint}\"\n        return\n    host, port = endpoint.rsplit(\":\", 1)\n    assert host\n    assert port.isdigit()\n    assert int(port) == expected_port\n\n\ndef _assert_terminal_event_contract(\n    *,\n    init_events: list[ExecutionInit],\n    completed_events: list[ExecutionComplete],\n    errors: list[ExecutionError],\n    execution_id: str | None,\n) -> None:\n    # Contract: init must exist, and exactly one of (error, complete) exists.\n    assert len(init_events) == 1\n    assert init_events[0].id is not None and init_events[0].id.strip()\n    if execution_id is not None:\n        assert init_events[0].id == execution_id\n    _assert_recent_timestamp_ms(init_events[0].timestamp)\n    assert (len(completed_events) > 0) or (len(errors) > 0), (\n        f\"expected exactly one of complete/error, got complete={len(completed_events)} \"\n        f\"error={len(errors)}\"\n    )\n    if len(completed_events) > 0:\n        assert len(completed_events) == 1\n        _assert_recent_timestamp_ms(completed_events[0].timestamp)\n        assert completed_events[0].execution_time_in_millis >= 0\n    if len(errors) > 0:\n        assert errors[0].name\n        assert errors[0].value is not None\n        _assert_recent_timestamp_ms(errors[0].timestamp)\n\n\ndef _buffer_attempt_handlers_sync(\n    handlers: ExecutionHandlersSync,\n) -> tuple[ExecutionHandlersSync, Callable[[], None]]:\n    buffered_events: list[tuple[str, object]] = []\n\n    def on_stdout(msg) -> None:\n        buffered_events.append((\"stdout\", msg))\n\n    def on_stderr(msg) -> None:\n        buffered_events.append((\"stderr\", msg))\n\n    def on_result(result) -> None:\n        buffered_events.append((\"result\", result))\n\n    def on_complete(complete) -> None:\n        buffered_events.append((\"complete\", complete))\n\n    def on_error(error) -> None:\n        buffered_events.append((\"error\", error))\n\n    def on_init(init) -> None:\n        buffered_events.append((\"init\", init))\n\n    def flush() -> None:\n        for event_type, payload in buffered_events:\n            if event_type == \"stdout\" and handlers.on_stdout is not None:\n                handlers.on_stdout(payload)\n            elif event_type == \"stderr\" and handlers.on_stderr is not None:\n                handlers.on_stderr(payload)\n            elif event_type == \"result\" and handlers.on_result is not None:\n                handlers.on_result(payload)\n            elif (\n                event_type == \"complete\"\n                and handlers.on_execution_complete is not None\n            ):\n                handlers.on_execution_complete(payload)\n            elif event_type == \"error\" and handlers.on_error is not None:\n                handlers.on_error(payload)\n            elif event_type == \"init\" and handlers.on_init is not None:\n                handlers.on_init(payload)\n\n    return (\n        ExecutionHandlersSync(\n            on_stdout=on_stdout if handlers.on_stdout is not None else None,\n            on_stderr=on_stderr if handlers.on_stderr is not None else None,\n            on_result=on_result if handlers.on_result is not None else None,\n            on_execution_complete=(\n                on_complete if handlers.on_execution_complete is not None else None\n            ),\n            on_error=on_error if handlers.on_error is not None else None,\n            on_init=on_init if handlers.on_init is not None else None,\n        ),\n        flush,\n    )\n\n\ndef run_with_retry_sync(\n    code_interpreter: CodeInterpreterSync,\n    code: str,\n    *,\n    context=None,\n    language=None,\n    handlers=None,\n    max_retries: int = 3,\n    retry_delay: float = 2.0,\n):\n    \"\"\"\n    Synchronous retry wrapper for code_interpreter.codes.run().\n\n    Retries on:\n    - Empty/None id responses (kernel not ready / session busy)\n    - Retryable network errors (connection reset, server disconnected)\n    \"\"\"\n    last_result = None\n    last_exception = None\n\n    for attempt in range(max_retries):\n        try:\n            attempt_handlers = handlers\n            flush_attempt_events: Callable[[], None] | None = None\n            if handlers is not None:\n                attempt_handlers, flush_attempt_events = _buffer_attempt_handlers_sync(\n                    handlers\n                )\n            result = code_interpreter.codes.run(\n                code,\n                context=context,\n                language=language,\n                handlers=attempt_handlers,\n            )\n            last_result = result\n            if result is not None and result.id is not None:\n                if flush_attempt_events is not None:\n                    flush_attempt_events()\n                return result\n            # Empty result — retry\n            if attempt < max_retries - 1:\n                logger.warning(\n                    \"Execution returned empty result (attempt %d/%d), retrying in %.1fs...\",\n                    attempt + 1, max_retries, retry_delay,\n                )\n                time.sleep(retry_delay)\n                retry_delay *= 1.5\n        except Exception as e:\n            last_exception = e\n            error_str = str(e).lower()\n            is_retryable = any(keyword in error_str for keyword in [\n                \"disconnected\", \"connection\", \"reset\", \"closed\", \"timeout\",\n                \"remoteerror\", \"protocol\", \"peer closed\", \"session is busy\",\n            ])\n            if is_retryable and attempt < max_retries - 1:\n                logger.warning(\n                    \"Execution failed with %s (attempt %d/%d), retrying in %.1fs: %s\",\n                    type(e).__name__, attempt + 1, max_retries, retry_delay, str(e)[:100],\n                )\n                time.sleep(retry_delay)\n                retry_delay *= 1.5\n            else:\n                raise\n\n    if last_result is not None:\n        return last_result\n    if last_exception is not None:\n        raise last_exception\n    return None\n\n\n@contextmanager\ndef managed_ctx_sync(code_interpreter: CodeInterpreterSync, language: str):\n    ctx = code_interpreter.codes.create_context(language)\n    try:\n        yield ctx\n    finally:\n        try:\n            if ctx.id:\n                code_interpreter.codes.delete_context(ctx.id)\n        except Exception:\n            logger.warning(\n                \"Cleanup: failed to delete context %s (%s)\", ctx.id, language, exc_info=True\n            )\n\n\n@contextmanager\ndef managed_ctx_stack_sync(code_interpreter: CodeInterpreterSync, languages: list[str]):\n    with ExitStack() as stack:\n        contexts = []\n        for lang in languages:\n            contexts.append(stack.enter_context(managed_ctx_sync(code_interpreter, lang)))\n        yield contexts\n\n\nclass TestCodeInterpreterE2ESync:\n    sandbox: SandboxSync | None = None\n    code_interpreter: CodeInterpreterSync | None = None\n    connection_config: ConnectionConfigSync | None = None\n    _setup_done = False\n\n    @pytest.fixture(scope=\"class\", autouse=True)\n    def _ci_lifecycle(self, request):\n        \"\"\"Create sandbox + code interpreter once and ALWAYS cleanup.\"\"\"\n        request.cls._ensure_code_interpreter_created()\n        try:\n            yield\n        finally:\n            sandbox = request.cls.sandbox\n            if sandbox is not None:\n                try:\n                    sandbox.kill()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.kill() failed: %s\", e, exc_info=True)\n                try:\n                    sandbox.close()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.close() failed: %s\", e, exc_info=True)\n\n            cfg = request.cls.connection_config\n            if cfg is not None:\n                try:\n                    cfg.transport.close()\n                except Exception:\n                    pass\n\n    @classmethod\n    def _ensure_code_interpreter_created(cls) -> None:\n        if cls._setup_done:\n            return\n\n        cls.connection_config = create_connection_config_sync()\n\n        cls.sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            entrypoint=[\"/opt/opensandbox/code-interpreter.sh\"],\n            connection_config=cls.connection_config,\n            timeout=timedelta(minutes=15),\n            ready_timeout=timedelta(seconds=60),\n            metadata={\"tag\": \"e2e-code-interpreter\"},\n            env={\n                \"E2E_TEST\": \"true\",\n                \"GO_VERSION\": \"1.25\",\n                \"JAVA_VERSION\": \"21\",\n                \"NODE_VERSION\": \"22\",\n                \"PYTHON_VERSION\": \"3.12\",\n                \"EXECD_LOG_FILE\": \"/tmp/opensandbox-e2e/logs/execd.log\",\n            },\n            health_check_polling_interval=timedelta(milliseconds=500),\n            volumes=[\n                Volume(\n                    name=\"execd-log\",\n                    host=Host(path=\"/tmp/opensandbox-e2e/logs\"),\n                    mountPath=\"/tmp/opensandbox-e2e/logs\",\n                    readOnly=False,\n                ),\n            ],\n        )\n\n        cls.code_interpreter = CodeInterpreterSync.create(sandbox=cls.sandbox)\n        assert cls.code_interpreter is not None\n        assert isinstance(cls.code_interpreter.id, str)\n        cls._setup_done = True\n\n    @pytest.mark.timeout(600)\n    @pytest.mark.order(1)\n    def test_01_creation_and_basic_functionality(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        assert code_interpreter.codes is not None\n        assert code_interpreter.files is not None\n        assert code_interpreter.commands is not None\n        assert code_interpreter.metrics is not None\n\n        assert code_interpreter.sandbox.is_healthy() is True\n\n        info = code_interpreter.sandbox.get_info()\n        assert str(code_interpreter.id) == str(info.id)\n        assert info.status.state == \"Running\"\n\n        endpoint = code_interpreter.sandbox.get_endpoint(DEFAULT_EXECD_PORT)\n        assert endpoint is not None\n        assert endpoint.endpoint is not None\n        _assert_endpoint_has_port(endpoint.endpoint, DEFAULT_EXECD_PORT)\n\n        metrics = code_interpreter.sandbox.get_metrics()\n        assert metrics is not None\n        assert metrics.cpu_count > 0\n        assert 0.0 <= metrics.cpu_used_percentage <= 100.0\n        assert metrics.memory_total_in_mib > 0\n        assert 0.0 <= metrics.memory_used_in_mib <= metrics.memory_total_in_mib\n        _assert_recent_timestamp_ms(metrics.timestamp)\n\n        renew_response = code_interpreter.sandbox.renew(timedelta(minutes=20))\n        assert renew_response is not None\n        renewed_info = code_interpreter.sandbox.get_info()\n        assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10\n        now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)\n        remaining = renewed_info.expires_at - now\n        assert remaining > timedelta(minutes=18)\n        assert remaining < timedelta(minutes=22)\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(2)\n    def test_02_java_code_execution(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_sync(code_interpreter, SupportedLanguage.JAVA) as java_context:\n            assert java_context.id is not None and str(java_context.id).strip()\n            assert java_context.language == \"java\"\n\n            stdout_messages: list[OutputMessage] = []\n            stderr_messages: list[OutputMessage] = []\n            results: list[ExecutionResult] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            def on_stdout(msg):\n                stdout_messages.append(msg)\n\n            def on_stderr(msg):\n                stderr_messages.append(msg)\n\n            def on_result(result):\n                results.append(result)\n\n            def on_complete(complete):\n                completed_events.append(complete)\n\n            def on_error(error):\n                errors.append(error)\n\n            def on_init(init):\n                init_events.append(init)\n\n            handlers = ExecutionHandlersSync(\n                on_stdout=on_stdout,\n                on_stderr=on_stderr,\n                on_result=on_result,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            simple_result = code_interpreter.codes.run(\n                \"System.out.println(\\\"Hello from Java!\\\");\\n\"\n                + \"int result = 2 + 2;\\n\"\n                + \"System.out.println(\\\"2 + 2 = \\\" + result);\\n\"\n                + \"result\",\n                context=java_context,\n                handlers=handlers,\n                )\n            assert simple_result is not None\n            assert simple_result.id is not None and simple_result.id.strip()\n            assert simple_result.error is None\n            assert len(simple_result.result) > 0\n            assert simple_result.result[0].text == \"4\"\n\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert len(stdout_messages) > 0\n            assert any(\"Hello from Java!\" in m.text for m in stdout_messages)\n            assert any(\"2+2=4\" in m.text.replace(\" \", \"\") for m in stdout_messages)\n            assert all(m.is_error is False for m in stdout_messages)\n            for m in stdout_messages[:3]:\n                _assert_recent_timestamp_ms(m.timestamp)\n\n            var_result = code_interpreter.codes.run(\n                \"import java.util.*;\\n\"\n                + \"List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);\\n\"\n                + \"int sum = numbers.stream().mapToInt(Integer::intValue).sum();\\n\"\n                + \"System.out.println(\\\"Numbers: \\\" + numbers);\\n\"\n                + \"System.out.println(\\\"Sum: \\\" + sum);\\n\"\n                + \"result\",\n                context=java_context,\n                )\n            assert var_result is not None\n            assert var_result.id is not None\n            assert len(var_result.result) > 0\n            assert var_result.result[0].text == \"4\"\n\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result = code_interpreter.codes.run(\n                \"int x = 10 / 0; // This will cause ArithmeticException\",\n                context=java_context,\n                handlers=handlers,\n            )\n            assert error_result is not None\n            assert error_result.id is not None and error_result.id.strip()\n            assert error_result.error is not None\n            assert error_result.error.name == \"EvalException\"\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result.id,\n            )\n            assert len(errors) > 0\n            assert errors[0].name == \"EvalException\"\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(3)\n    def test_03_python_code_execution(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        # New usage: directly pass a language string (ephemeral context).\n        # This validates the `codes.run(..., language=...)` convenience interface.\n        direct_lang_result = run_with_retry_sync(\n            code_interpreter,\n            \"result = 2 + 2\\nresult\",\n            language=SupportedLanguage.PYTHON,\n        )\n        assert direct_lang_result is not None\n        assert direct_lang_result.id is not None and direct_lang_result.id.strip()\n        assert direct_lang_result.error is None\n        assert len(direct_lang_result.result) > 0\n        assert direct_lang_result.result[0].text == \"4\"\n\n        stdout_messages: list[OutputMessage] = []\n        stderr_messages: list[OutputMessage] = []\n        errors: list[ExecutionError] = []\n        completed_events: list[ExecutionComplete] = []\n        init_events: list[ExecutionInit] = []\n\n        def on_stdout(msg):\n            stdout_messages.append(msg)\n\n        def on_stderr(msg):\n            stderr_messages.append(msg)\n\n        def on_complete(complete):\n            completed_events.append(complete)\n\n        def on_error(error):\n            errors.append(error)\n\n        def on_init(init):\n            init_events.append(init)\n\n        handlers_py = ExecutionHandlersSync(\n            on_stdout=on_stdout,\n            on_stderr=on_stderr,\n            on_execution_complete=on_complete,\n            on_error=on_error,\n            on_init=on_init,\n        )\n\n        with managed_ctx_sync(code_interpreter, SupportedLanguage.PYTHON) as python_context:\n            assert python_context.id is not None and str(python_context.id).strip()\n\n            simple_result_py = run_with_retry_sync(\n                code_interpreter,\n                \"print('Hello from Python!')\\n\"\n                + \"result = 2 + 2\\n\"\n                + \"print(f'2 + 2 = {result}')\",\n                context=python_context,\n                handlers=handlers_py,\n            )\n            assert simple_result_py is not None\n            assert simple_result_py.id is not None and simple_result_py.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_py.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert any(\"Hello from Python!\" in m.text for m in stdout_messages)\n            assert any(\"2 + 2 = 4\" in m.text for m in stdout_messages)\n\n            var_result_py = code_interpreter.codes.run(\n                \"x = 42\\n\"\n                + \"y = 'persistent variable'\\n\"\n                + \"my_list = [1, 2, 3, 4, 5]\\n\"\n                + \"print(f'x={x}, y=\\\"{y}\\\", list={my_list}')\\n\"\n                + \"result\",\n                context=python_context,\n                )\n            assert var_result_py is not None\n            assert var_result_py.id is not None\n            assert len(var_result_py.result) > 0\n            assert var_result_py.result[0].text == \"4\"\n\n            persist_result = code_interpreter.codes.run(\n                \"print(f'Previously set variables: x={x}, y={y}')\\n\"\n                + \"z = sum(my_list)\\n\"\n                + \"print(f'Sum of list: {z}')\",\n                context=python_context,\n                )\n            assert persist_result is not None\n            assert persist_result.id is not None\n\n            stdout_messages.clear()\n            stderr_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result_py = code_interpreter.codes.run(\n                \"print(undefined_variable)  # This will cause NameError\",\n                context=python_context,\n                handlers=handlers_py,\n            )\n            assert error_result_py is not None\n            assert error_result_py.id is not None and error_result_py.id.strip()\n            assert error_result_py.error is not None or len(error_result_py.logs.stderr) > 0\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_py.id,\n            )\n            assert len(errors) > 0\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(4)\n    def test_04_go_code_execution(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_sync(code_interpreter, SupportedLanguage.GO) as go_context:\n            assert go_context.id is not None and str(go_context.id).strip()\n            assert go_context.language == \"go\"\n\n            stdout_messages: list[OutputMessage] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            def on_stdout(msg):\n                stdout_messages.append(msg)\n\n            def on_complete(complete):\n                completed_events.append(complete)\n\n            def on_error(error):\n                errors.append(error)\n\n            def on_init(init):\n                init_events.append(init)\n\n            handlers_go = ExecutionHandlersSync(\n                on_stdout=on_stdout,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            simple_result_go = code_interpreter.codes.run(\n                \"package main\\n\"\n                + \"import \\\"fmt\\\"\\n\"\n                + \"func main() {\\n\"\n                + \"    fmt.Print(\\\"Hello from Go!\\\")\\n\"\n                + \"    result := 2 + 2\\n\"\n                + \"    fmt.Print(\\\"2 + 2 =\\\", result)\\n\"\n                + \"}\",\n                context=go_context,\n                handlers=handlers_go,\n                )\n            assert simple_result_go is not None\n            assert simple_result_go.id is not None and simple_result_go.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_go.id,\n            )\n            assert len(errors) == 0\n            assert len(stdout_messages) > 0\n\n            data_result_go = code_interpreter.codes.run(\n                \"package main\\n\"\n                + \"import \\\"fmt\\\"\\n\"\n                + \"func calculate(numbers []int) int {\\n\"\n                + \"    sum := 0\\n\"\n                + \"    for _, num := range numbers {\\n\"\n                + \"        sum += num\\n\"\n                + \"    }\\n\"\n                + \"    return sum\\n\"\n                + \"}\\n\"\n                + \"func main() {\\n\"\n                + \"    numbers := []int{1, 2, 3, 4, 5}\\n\"\n                + \"    sum := calculate(numbers)\\n\"\n                + \"    fmt.Print(\\\"Numbers:\\\", numbers)\\n\"\n                + \"    fmt.Print(\\\"Sum:\\\", sum)\\n\"\n                + \"}\",\n                context=go_context,\n                )\n            assert data_result_go is not None\n            assert data_result_go.id is not None\n\n            stdout_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            error_result_go = code_interpreter.codes.run(\n                \"package main\\n\"\n                + \"func main() {\\n\"\n                + \"    undeclaredVariable++  // This will cause compilation error\\n\"\n                + \"}\",\n                context=go_context,\n                handlers=handlers_go,\n                )\n            assert error_result_go is not None\n            assert error_result_go.id is not None and error_result_go.id.strip()\n            assert error_result_go.error is not None or len(error_result_go.logs.stderr) > 0\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_go.id,\n            )\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(5)\n    def test_05_typescript_code_execution(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_sync(code_interpreter, SupportedLanguage.TYPESCRIPT) as ts_context:\n            assert ts_context.id is not None and str(ts_context.id).strip()\n            assert ts_context.language == \"typescript\"\n\n            stdout_messages: list[OutputMessage] = []\n            errors: list[ExecutionError] = []\n            completed_events: list[ExecutionComplete] = []\n            init_events: list[ExecutionInit] = []\n\n            def on_stdout(msg):\n                stdout_messages.append(msg)\n\n            def on_complete(complete):\n                completed_events.append(complete)\n\n            def on_error(error):\n                errors.append(error)\n\n            def on_init(init):\n                init_events.append(init)\n\n            handlers_ts = ExecutionHandlersSync(\n                on_stdout=on_stdout,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n                on_init=on_init,\n            )\n\n            simple_result_ts = code_interpreter.codes.run(\n                \"console.log('Hello from TypeScript!');\\n\"\n                + \"const result: number = 2 + 2;\\n\"\n                + \"console.log(`2 + 2 = ${result}`);\",\n                context=ts_context,\n                handlers=handlers_ts,\n                )\n            assert simple_result_ts is not None\n            assert simple_result_ts.id is not None and simple_result_ts.id.strip()\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=simple_result_ts.id,\n            )\n            assert len(errors) == 0\n            assert len(completed_events) == 1\n            assert any(\"Hello from TypeScript!\" in m.text for m in stdout_messages)\n\n            types_result_ts = code_interpreter.codes.run(\n                \"interface Person {\\n\"\n                + \"  name: string;\\n\"\n                + \"  age: number;\\n\"\n                + \"}\\n\"\n                + \"const person: Person = { name: 'John', age: 30 };\\n\"\n                + \"const numbers: number[] = [1, 2, 3, 4, 5];\\n\"\n                + \"const sum: number = numbers.reduce((a, b) => a + b, 0);\\n\"\n                + \"console.log(`Person: ${person.name}, Age: ${person.age}`);\\n\"\n                + \"console.log(`Numbers: ${numbers}`);\\n\"\n                + \"console.log(`Sum: ${sum}`);\",\n                context=ts_context,\n                )\n            assert types_result_ts is not None\n            assert types_result_ts.id is not None\n\n            stdout_messages.clear()\n            errors.clear()\n            completed_events.clear()\n            init_events.clear()\n\n            # Use a deterministic runtime error (TypeScript compile/type-checking may be configured permissively).\n            error_result_ts = code_interpreter.codes.run(\n                \"throw new Error('ts-runtime-error');\",\n                context=ts_context,\n                handlers=handlers_ts,\n            )\n            assert error_result_ts is not None\n            assert error_result_ts.id is not None and error_result_ts.id.strip()\n            assert error_result_ts.error is not None or len(error_result_ts.logs.stderr) > 0\n            _assert_terminal_event_contract(\n                init_events=init_events,\n                completed_events=completed_events,\n                errors=errors,\n                execution_id=error_result_ts.id,\n            )\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(6)\n    def test_06_multi_language_support_and_context_isolation(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_stack_sync(\n            code_interpreter,\n            [\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.JAVA,\n                SupportedLanguage.GO,\n            ],\n        ) as (python1, python2, java1, go1):\n            assert python1.id is not None and str(python1.id).strip()\n            assert python2.id is not None and str(python2.id).strip()\n            assert java1.id is not None and str(java1.id).strip()\n            assert go1.id is not None and str(go1.id).strip()\n\n            # Use retry helper for flaky kernel initialization\n            result1 = run_with_retry_sync(\n                code_interpreter,\n                \"secret_value1 = 'python1_secret'\\nprint(f'Python1 secret: {secret_value1}')\",\n                context=python1,\n            )\n            result2 = run_with_retry_sync(\n                code_interpreter,\n                \"secret_value2 = 'python2_secret'\\nprint(f'Python2 secret: {secret_value2}')\",\n                context=python2,\n            )\n            assert result1 is not None and result1.id is not None\n            assert result2 is not None and result2.id is not None\n\n            # Small delay to avoid \"session is busy\" between runs\n            time.sleep(1)\n\n            check1 = run_with_retry_sync(\n                code_interpreter,\n                \"print(f'Python1 still has: {secret_value1}')\",\n                context=python1,\n            )\n            time.sleep(0.5)\n            check2 = run_with_retry_sync(\n                code_interpreter,\n                \"print(f'Python2 has no: {secret_value1}')\",\n                context=python2,\n            )\n            assert check1 is not None\n            assert check2 is not None\n            # check2 should fail with NameError (context isolation):\n            # secret_value1 is defined in python1 but not in python2.\n            # If check2.error is None, the SDK may have swallowed a \"session\n            # is busy\" error and returned an empty Execution; retry once more.\n            if check2.error is None and not check2.result and not check2.logs.stdout:\n                logger.warning(\n                    \"check2 returned empty Execution (possible session-busy); retrying...\"\n                )\n                time.sleep(2)\n                check2 = run_with_retry_sync(\n                    code_interpreter,\n                    \"print(f'Python2 has no: {secret_value1}')\",\n                    context=python2,\n                )\n            assert check2.error is not None, (\n                f\"Expected NameError for context isolation but got: {check2}\"\n            )\n            assert check2.error.name == \"NameError\"\n\n            java_result = run_with_retry_sync(\n                code_interpreter,\n                \"String javaSecret = \\\"java_secret\\\";\\n\"\n                    + \"System.out.println(\\\"Java secret: \\\" + javaSecret);\",\n                context=java1,\n            )\n            go_result = run_with_retry_sync(\n                code_interpreter,\n                \"package main\\n\"\n                    + \"import \\\"fmt\\\"\\n\"\n                    + \"func main() {\\n\"\n                    + \"    goSecret := \\\"go_secret\\\"\\n\"\n                    + \"    fmt.Print(\\\"Go secret:\\\", goSecret)\\n\"\n                    + \"}\",\n                context=go1,\n            )\n            assert java_result is not None and java_result.id is not None\n            assert go_result is not None and go_result.id is not None\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(7)\n    def test_07_concurrent_code_execution(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_stack_sync(\n            code_interpreter,\n            [\n                SupportedLanguage.PYTHON,\n                SupportedLanguage.JAVA,\n                SupportedLanguage.GO,\n            ],\n        ) as (python_c1, java_c1, go_c1):\n            from concurrent.futures import ThreadPoolExecutor\n            from concurrent.futures import TimeoutError as FutureTimeout\n\n            labels = [\"Python\", \"Java\", \"Go\"]\n\n            def run_python1():\n                return code_interpreter.codes.run(\n                    \"import time\\n\"\n                    + \"for i in range(3):\\n\"\n                    + \"    print(f'Python1 iteration {i}')\\n\"\n                    + \"    time.sleep(0.1)\\n\"\n                    + \"print('Python1 completed')\",\n                    context=python_c1,\n                    )\n\n            def run_java_concurrent():\n                return code_interpreter.codes.run(\n                    \"for (int i = 0; i < 3; i++) {\\n\"\n                    + \"    System.out.println(\\\"Java iteration \\\" + i);\\n\"\n                    + \"    try { Thread.sleep(100); } catch (Exception e) {}\\n\"\n                    + \"}\\n\"\n                    + \"System.out.println(\\\"Java completed\\\");\",\n                    context=java_c1,\n                    )\n\n            def run_go_concurrent():\n                return code_interpreter.codes.run(\n                    \"package main\\n\"\n                    + \"import \\\"fmt\\\"\\n\"\n                    + \"func main() {\\n\"\n                    + \"    for i := 0; i < 3; i++ {\\n\"\n                    + \"        fmt.Print(\\\"Go iteration\\\", i)\\n\"\n                    + \"    }\\n\"\n                    + \"    fmt.Print(\\\"Go completed\\\")\\n\"\n                    + \"}\",\n                    context=go_c1,\n                    )\n\n            with ThreadPoolExecutor(max_workers=4) as ex:\n                futures = [\n                    ex.submit(run_python1),\n                    ex.submit(run_java_concurrent),\n                    ex.submit(run_go_concurrent),\n                ]\n\n                succeeded = 0\n                for i, future in enumerate(futures):\n                    label = labels[i]\n                    try:\n                        result = future.result(timeout=120)\n                        if result is not None and result.id is not None:\n                            succeeded += 1\n                            logger.info(\"Concurrent %s: OK (id=%s)\", label, result.id)\n                        else:\n                            logger.warning(\n                                \"Concurrent %s: returned empty result: %s\", label, result\n                            )\n                    except FutureTimeout:\n                        logger.warning(\"Concurrent %s: timed out\", label)\n                    except Exception as e:\n                        logger.warning(\"Concurrent %s: failed: %s\", label, e)\n\n            # In resource-constrained CI, \"session is busy\" may cause some\n            # concurrent executions to return empty results.  Require at\n            # least 2 of 3 to succeed.\n            assert succeeded >= 2, (\n                f\"Only {succeeded}/3 concurrent executions succeeded; \"\n                f\"expected at least 2\"\n            )\n\n    @pytest.mark.timeout(900)\n    @pytest.mark.order(8)\n    def test_08_code_execution_interrupt(self):\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        with managed_ctx_sync(code_interpreter, SupportedLanguage.PYTHON) as python_int_context:\n            assert python_int_context is not None and python_int_context.id is not None and str(python_int_context.id).strip()\n\n            init_events_int: list[ExecutionInit] = []\n            completed_events: list[ExecutionComplete] = []\n            errors: list[ExecutionError] = []\n\n            def on_init(init: ExecutionInit):\n                init_events_int.append(init)\n\n            def on_complete(complete: ExecutionComplete):\n                completed_events.append(complete)\n\n            def on_error(error: ExecutionError):\n                errors.append(error)\n\n            handlers_int = ExecutionHandlersSync(\n                on_init=on_init,\n                on_execution_complete=on_complete,\n                on_error=on_error,\n            )\n\n            with ThreadPoolExecutor(max_workers=1) as ex:\n                start = time.time()\n                future = ex.submit(\n                    code_interpreter.codes.run,\n                    \"import time\\n\"\n                    + \"print('Starting long-running Python execution')\\n\"\n                    + \"for i in range(50):\\n\"\n                    + \"    print(f'Python iteration {i}')\\n\"\n                    + \"    time.sleep(0.2)\\n\",\n                    context=python_int_context,\n                    handlers=handlers_int,\n                    )\n\n                deadline = time.time() + 15\n                while len(init_events_int) == 0 and time.time() < deadline:\n                    time.sleep(0.1)\n\n                assert len(init_events_int) == 1, \"Execution should have been initialized exactly once\"\n                execution_id = init_events_int[-1].id\n                assert execution_id is not None and execution_id.strip()\n                _assert_recent_timestamp_ms(init_events_int[-1].timestamp)\n\n                code_interpreter.codes.interrupt(execution_id)\n\n                result_int = future.result()\n                assert result_int is not None\n                assert result_int.id is not None\n                assert result_int.id == execution_id\n                assert (len(completed_events) > 0) or (len(errors) > 0)\n                elapsed = time.time() - start\n                assert elapsed < 30\n\n            # Small delay after interrupt to let the kernel recover\n            time.sleep(1)\n            quick_result = run_with_retry_sync(\n                code_interpreter,\n                \"print('Quick Python execution')\\n\"\n                + \"result = 2 + 2\\n\"\n                + \"print(f'Result: {result}')\",\n                context=python_int_context,\n                handlers=handlers_int,\n            )\n            assert quick_result is not None\n            assert quick_result.id is not None\n\n            try:\n                code_interpreter.codes.interrupt(quick_result.id)\n            except Exception:\n                pass\n\n    @pytest.mark.timeout(600)\n    @pytest.mark.order(9)\n    def test_09_context_management_endpoints(self):\n        \"\"\"Validate list/get/delete context APIs map to execd /code/contexts endpoints (sync).\"\"\"\n        TestCodeInterpreterE2ESync._ensure_code_interpreter_created()\n        code_interpreter = TestCodeInterpreterE2ESync.code_interpreter\n        assert code_interpreter is not None\n\n        language = SupportedLanguage.PYTHON\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 9: Context management endpoints (%s)\", language)\n        logger.info(\"=\" * 80)\n\n        # Ensure clean slate for bash contexts to avoid interference with other tests.\n        code_interpreter.codes.delete_contexts(language)\n\n        ctx1 = code_interpreter.codes.create_context(language)\n        ctx2 = code_interpreter.codes.create_context(language)\n        assert ctx1.id is not None and str(ctx1.id).strip()\n        assert ctx2.id is not None and str(ctx2.id).strip()\n        assert ctx1.language == language\n        assert ctx2.language == language\n        logger.info(\"✓ Created two bash contexts: %s, %s\", ctx1.id, ctx2.id)\n\n        listed = code_interpreter.codes.list_contexts(language)\n        bash_context_ids = {c.id for c in listed if c.id}\n        assert ctx1.id in bash_context_ids\n        assert ctx2.id in bash_context_ids\n        assert all(c.language == language for c in listed)\n        logger.info(\"✓ list_contexts returned expected bash contexts\")\n\n        fetched = code_interpreter.codes.get_context(ctx1.id)\n        assert fetched.id == ctx1.id\n        assert fetched.language == language\n        logger.info(\"✓ get_context returned expected context %s\", fetched.id)\n\n        code_interpreter.codes.delete_context(ctx1.id)\n        remaining = code_interpreter.codes.list_contexts(language)\n        remaining_ids = {c.id for c in remaining if c.id}\n        assert ctx1.id not in remaining_ids\n        assert ctx2.id in remaining_ids\n        logger.info(\"✓ delete_context removed %s\", ctx1.id)\n\n        code_interpreter.codes.delete_contexts(language)\n        final_contexts = [\n            c for c in code_interpreter.codes.list_contexts(language) if c.id\n        ]\n        assert len(final_contexts) == 0\n        logger.info(\"✓ delete_contexts removed all bash contexts\")\n"
  },
  {
    "path": "tests/python/tests/test_sandbox_e2e.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive E2E tests for Sandbox functionality.\n\"\"\"\n\nimport asyncio\nimport logging\nimport time\nfrom datetime import timedelta\nfrom io import BytesIO\n\nimport pytest\nfrom opensandbox import Sandbox\nfrom opensandbox.constants import DEFAULT_EGRESS_PORT\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.exceptions import SandboxApiException\nfrom opensandbox.models.execd import (\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionHandlers,\n    ExecutionInit,\n    ExecutionResult,\n    OutputMessage,\n    RunCommandOpts,\n)\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import (\n    PVC,\n    Host,\n    NetworkPolicy,\n    NetworkRule,\n    SandboxImageSpec,\n    Volume,\n)\n\nfrom tests.base_e2e_test import (\n    TEST_API_KEY,\n    TEST_DOMAIN,\n    TEST_PROTOCOL,\n    create_connection_config,\n    create_connection_config_server_proxy,\n    get_sandbox_image,\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef _now_ms() -> int:\n    return int(time.time() * 1000)\n\n\ndef _assert_recent_timestamp_ms(ts: int, *, tolerance_ms: int = 60_000) -> None:\n    assert isinstance(ts, int)\n    assert ts > 0\n    delta = abs(_now_ms() - ts)\n    assert delta <= tolerance_ms, f\"timestamp too far from now: delta={delta}ms (ts={ts})\"\n\n\ndef _assert_endpoint_has_port(endpoint: str, expected_port: int) -> None:\n    assert endpoint\n    # In some deployments lifecycle returns direct \"host:port\".\n    # In others it returns a reverse-proxy route like \"domain/route/{id}/{port}\".\n    # In both cases, we expect NO scheme, and the port to be present deterministically.\n    assert \"://\" not in endpoint, f\"unexpected scheme in endpoint: {endpoint}\"\n\n    if \"/\" in endpoint:\n        assert endpoint.endswith(f\"/{expected_port}\"), (\n            f\"endpoint route must end with /{expected_port}: {endpoint}\"\n        )\n        # Keep this strict: the route must contain a non-empty domain prefix.\n        assert endpoint.split(\"/\", 1)[0], f\"missing domain in endpoint: {endpoint}\"\n        return\n\n    host, port = endpoint.rsplit(\":\", 1)\n    assert host, f\"missing host in endpoint: {endpoint}\"\n    assert port.isdigit(), f\"non-numeric port in endpoint: {endpoint}\"\n    assert int(port) == expected_port, f\"endpoint port mismatch: {endpoint} != :{expected_port}\"\n\n\ndef _assert_times_close(created_at, modified_at, *, tolerance_seconds: float = 2.0) -> None:\n    \"\"\"\n    Some filesystems / implementations may report created/modified with slight reordering.\n    We only assert they're close, and rely on explicit update operations to validate mtime.\n    \"\"\"\n    delta = abs((modified_at - created_at).total_seconds())\n    assert delta <= tolerance_seconds, f\"created/modified skew too large: {delta}s\"\n\n\ndef _assert_modified_updated(before, after, *, min_delta_ms: int = 0, allow_skew_ms: int = 1000) -> None:\n    \"\"\"\n    Validate modified_at moved forward after a mutating operation, allowing small clock jitter.\n    \"\"\"\n    delta_ms = int((after - before).total_seconds() * 1000)\n    assert delta_ms >= min_delta_ms - allow_skew_ms, (\n        f\"modified_at did not update as expected: delta_ms={delta_ms} \"\n        f\"(min_delta_ms={min_delta_ms}, allow_skew_ms={allow_skew_ms})\"\n    )\n\n\n@pytest.mark.asyncio\nclass TestSandboxE2E:\n    \"\"\"Comprehensive E2E tests for Sandbox functionality.\"\"\"\n\n    sandbox = None\n    connection_config = None\n    _setup_done = False\n\n    @pytest.fixture(scope=\"class\", autouse=True)\n    async def _sandbox_lifecycle(self, request):\n        \"\"\"Create sandbox once and ALWAYS cleanup to avoid resource leaks.\"\"\"\n        await request.cls._ensure_sandbox_created()\n        try:\n            yield\n        finally:\n            sandbox = request.cls.sandbox\n            if sandbox is not None:\n                try:\n                    await sandbox.kill()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.kill() failed: %s\", e, exc_info=True)\n                try:\n                    await sandbox.close()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.close() failed: %s\", e, exc_info=True)\n\n    @classmethod\n    async def _ensure_sandbox_created(cls):\n        \"\"\"Ensure sandbox is created before running tests.\"\"\"\n        if cls._setup_done:\n            return\n\n        logger.info(\"=\" * 100)\n        logger.info(\"SETUP: Creating sandbox\")\n        logger.info(\"=\" * 100)\n\n        cls.connection_config = create_connection_config()\n\n        cls.sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cls.connection_config,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            metadata={\"tag\": \"e2e-test\"},\n            env={\n                \"E2E_TEST\": \"true\",\n                \"GO_VERSION\": \"1.25\",\n                \"JAVA_VERSION\": \"21\",\n                \"NODE_VERSION\": \"22\",\n                \"PYTHON_VERSION\": \"3.12\"\n            },\n            health_check_polling_interval=timedelta(milliseconds=500),\n        )\n\n        logger.info(f\"✓ Sandbox created: {cls.sandbox.id}\")\n        logger.info(\"=\" * 100)\n\n        cls._setup_done = True\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01_sandbox_lifecycle_and_health(self):\n        \"\"\"Test sandbox lifecycle and health monitoring.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1: Testing sandbox lifecycle and health monitoring\")\n        logger.info(\"=\" * 80)\n\n        logger.info(\"Step 1: Verify basic sandbox properties\")\n        assert sandbox is not None\n        assert isinstance(sandbox.id, str)\n        assert await sandbox.is_healthy() is True\n        logger.info(f\"✓ Sandbox ID: {sandbox.id}\")\n        logger.info(\"✓ Sandbox is healthy\")\n\n        logger.info(\"Step 2: Get sandbox information\")\n        info = await sandbox.get_info()\n        assert info.id == sandbox.id\n        assert info.status.state == \"Running\"\n        assert info.created_at is not None\n        assert info.expires_at is not None\n        assert info.expires_at > info.created_at\n        assert info.entrypoint == [\"tail\", \"-f\", \"/dev/null\"]\n\n        duration = info.expires_at - info.created_at\n        min_duration = timedelta(minutes=1)\n        max_duration = timedelta(minutes=3)\n        assert min_duration <= duration <= max_duration, \\\n            f\"Duration {duration} should be between 1 and 3 minutes\"\n\n        assert info.metadata is not None\n        assert info.metadata.get(\"tag\") == \"e2e-test\"\n        logger.info(\n            \"✓ Sandbox info: state=%s, created=%s, expires=%s\",\n            info.status.state,\n            info.created_at,\n            info.expires_at,\n        )\n\n        logger.info(\"Step 3: Get sandbox endpoint for default execd port\")\n        endpoint = await sandbox.get_endpoint(44772)\n        assert endpoint is not None\n        assert endpoint.endpoint is not None\n        _assert_endpoint_has_port(endpoint.endpoint, 44772)\n        logger.info(f\"✓ Sandbox endpoint: {endpoint.endpoint}\")\n\n        logger.info(\"Step 4: Get and verify metrics\")\n        metrics = await sandbox.get_metrics()\n        assert metrics is not None\n        assert metrics.cpu_count > 0\n        assert 0.0 <= metrics.cpu_used_percentage <= 100.0\n        assert metrics.memory_total_in_mib > 0\n        assert 0.0 <= metrics.memory_used_in_mib <= metrics.memory_total_in_mib\n        _assert_recent_timestamp_ms(metrics.timestamp, tolerance_ms=120_000)\n        logger.info(\n            \"✓ CPU: %s cores, %.2f%% used\",\n            metrics.cpu_count,\n            metrics.cpu_used_percentage,\n        )\n        logger.info(\n            \"✓ Memory: %s/%s MiB\",\n            int(metrics.memory_used_in_mib),\n            int(metrics.memory_total_in_mib),\n        )\n\n        logger.info(\"Step 5: Test sandbox renewal (extend expiration time)\")\n        renew_response = await sandbox.renew(timedelta(minutes=20))\n        assert renew_response is not None\n        assert renew_response.expires_at > info.expires_at\n        logger.info(\"✓ Sandbox expiration renewed to %s\", renew_response.expires_at)\n\n        renewed_info = await sandbox.get_info()\n        assert renewed_info.expires_at > info.expires_at\n        assert renewed_info.id == sandbox.id\n        assert renewed_info.status.state == \"Running\"\n\n        # The renew API should return the new expiration time. Allow small backend-side skew.\n        assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10\n\n        # Renewal is \"now + timeout\" (SDK behavior). Validate remaining TTL is close to 5 minutes.\n        now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)\n        remaining = renewed_info.expires_at - now\n        assert remaining > timedelta(minutes=18), f\"Remaining TTL too small: {remaining}\"\n        assert remaining < timedelta(minutes=22), f\"Remaining TTL too large: {remaining}\"\n\n        logger.info(\n            \"✓ Sandbox expiration updated from %s to %s\",\n            info.expires_at,\n            renewed_info.expires_at,\n        )\n\n        logger.info(\"Step 6: Test access to service components\")\n\n        assert sandbox.files is not None\n        assert sandbox.commands is not None\n        assert sandbox.metrics is not None\n        assert sandbox.connection_config is not None\n        logger.info(\"✓ All sandbox service components are accessible\")\n\n        logger.info(\"Step 7: Connect to existing sandbox by ID\")\n        sandbox2 = await Sandbox.connect(\n            sandbox_id=sandbox.id,\n            connection_config=TestSandboxE2E.connection_config,\n        )\n        try:\n            assert sandbox2.id == sandbox.id\n            assert await sandbox2.is_healthy() is True\n            connect_result = await sandbox2.commands.run(\"echo connect-ok\")\n            assert connect_result.error is None\n            assert len(connect_result.logs.stdout) == 1\n            assert connect_result.logs.stdout[0].text == \"connect-ok\"\n        finally:\n            await sandbox2.close()\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01b_manual_cleanup(self):\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=TestSandboxE2E.connection_config,\n            timeout=None,\n            ready_timeout=timedelta(seconds=30),\n            metadata={\"tag\": \"manual-e2e-test\"},\n        )\n        try:\n            info = await sandbox.get_info()\n            assert info.expires_at is None\n            assert info.metadata is not None\n            assert info.metadata.get(\"tag\") == \"manual-e2e-test\"\n        finally:\n            await sandbox.kill()\n            await sandbox.close()\n\n        logger.info(\"TEST 1 PASSED: Sandbox lifecycle and health test completed successfully\")\n\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01a_network_policy_create(self):\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1a: Creating sandbox with networkPolicy (async)\")\n        logger.info(\"=\" * 80)\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            network_policy=NetworkPolicy(\n                defaultAction=\"deny\",\n                egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n            ),\n        )\n        try:\n            await asyncio.sleep(5)\n            result = await sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert result.error is not None\n            result = await sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert result.error is None\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n    @pytest.mark.timeout(180)\n    @pytest.mark.order(1)\n    async def test_01aa_network_policy_get_and_patch(self):\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1aa: networkPolicy get/patch (async)\")\n        logger.info(\"=\" * 80)\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            network_policy=NetworkPolicy(\n                defaultAction=\"deny\",\n                egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n            ),\n        )\n        try:\n            await asyncio.sleep(5)\n\n            # Verify get egress policy right after create.\n            policy = await sandbox.get_egress_policy()\n            assert policy.default_action == \"deny\"\n            assert policy.egress is not None\n            assert any(rule.target == \"pypi.org\" and rule.action == \"allow\" for rule in policy.egress)\n\n            # Baseline behavior: github blocked, pypi allowed.\n            blocked = await sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert blocked.error is not None\n            allowed = await sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert allowed.error is None\n\n            # Patch policy: allow github, deny pypi.\n            await sandbox.patch_egress_rules(\n                [\n                    NetworkRule(action=\"allow\", target=\"www.github.com\"),\n                    NetworkRule(action=\"deny\", target=\"pypi.org\"),\n                ],\n            )\n            await asyncio.sleep(2)\n\n            patched_policy = await sandbox.get_egress_policy()\n            assert patched_policy.egress is not None\n            assert any(\n                rule.target == \"www.github.com\" and rule.action == \"allow\"\n                for rule in patched_policy.egress\n            )\n            assert any(\n                rule.target == \"pypi.org\" and rule.action == \"deny\"\n                for rule in patched_policy.egress\n            )\n\n            # Behavior after patch should be flipped.\n            github_allowed = await sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert github_allowed.error is None\n            pypi_denied = await sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert pypi_denied.error is not None\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n    @pytest.mark.timeout(180)\n    @pytest.mark.order(1)\n    async def test_01ab_network_policy_get_and_patch_with_server_proxy(self):\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1ab: networkPolicy get/patch with server proxy (async)\")\n        logger.info(\"=\" * 80)\n\n        cfg = create_connection_config_server_proxy()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            network_policy=NetworkPolicy(\n                defaultAction=\"deny\",\n                egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n            ),\n        )\n        try:\n            await asyncio.sleep(5)\n\n            egress_endpoint = await sandbox.get_endpoint(DEFAULT_EGRESS_PORT)\n            assert f\"/sandboxes/{sandbox.id}/proxy/{DEFAULT_EGRESS_PORT}\" in egress_endpoint.endpoint\n\n            policy = await sandbox.get_egress_policy()\n            assert policy.default_action == \"deny\"\n            assert policy.egress is not None\n            assert any(rule.target == \"pypi.org\" and rule.action == \"allow\" for rule in policy.egress)\n\n            blocked = await sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert blocked.error is not None\n            allowed = await sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert allowed.error is None\n\n            await sandbox.patch_egress_rules(\n                [\n                    NetworkRule(action=\"allow\", target=\"www.github.com\"),\n                    NetworkRule(action=\"deny\", target=\"pypi.org\"),\n                ],\n            )\n            await asyncio.sleep(2)\n\n            patched_policy = await sandbox.get_egress_policy()\n            assert patched_policy.egress is not None\n            assert any(\n                rule.target == \"www.github.com\" and rule.action == \"allow\"\n                for rule in patched_policy.egress\n            )\n            assert any(\n                rule.target == \"pypi.org\" and rule.action == \"deny\"\n                for rule in patched_policy.egress\n            )\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01b_host_volume_mount(self):\n        \"\"\"Test creating a sandbox with a host volume mount.\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1b: Creating sandbox with host volume mount (async)\")\n        logger.info(\"=\" * 80)\n\n        host_dir = \"/tmp/opensandbox-e2e/host-volume-test\"\n        container_mount_path = \"/mnt/host-data\"\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-host-vol\",\n                    host=Host(path=host_dir),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                ),\n            ],\n        )\n        try:\n            logger.info(f\"✓ Sandbox with volume created: {sandbox.id}\")\n\n            # Step 1: Verify the host marker file is visible inside the sandbox\n            logger.info(\"Step 1: Verify host marker file is readable inside the sandbox\")\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"opensandbox-e2e-marker\"\n            logger.info(\"✓ Host marker file read successfully inside sandbox\")\n\n            # Step 2: Write a file from inside the sandbox to the mounted path (read-write)\n            logger.info(\"Step 2: Write a file from inside the sandbox to the mount path\")\n            result = await sandbox.commands.run(\n                f\"echo 'written-from-sandbox' > {container_mount_path}/sandbox-output.txt\"\n            )\n            assert result.error is None, f\"Failed to write file: {result.error}\"\n\n            # Step 3: Verify the written file is readable\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/sandbox-output.txt\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"written-from-sandbox\"\n            logger.info(\"✓ File written and verified inside sandbox\")\n\n            # Step 4: Verify the mount path is a proper directory\n            logger.info(\"Step 3: Verify mount path is a directory\")\n            result = await sandbox.commands.run(f\"test -d {container_mount_path} && echo OK\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"OK\"\n            logger.info(\"✓ Mount path is a valid directory\")\n\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n        logger.info(\"TEST 1b PASSED: Host volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01c_host_volume_mount_readonly(self):\n        \"\"\"Test creating a sandbox with a read-only host volume mount.\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1c: Creating sandbox with read-only host volume mount (async)\")\n        logger.info(\"=\" * 80)\n\n        host_dir = \"/tmp/opensandbox-e2e/host-volume-test\"\n        container_mount_path = \"/mnt/host-data-ro\"\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-host-vol-ro\",\n                    host=Host(path=host_dir),\n                    mountPath=container_mount_path,\n                    readOnly=True,\n                ),\n            ],\n        )\n        try:\n            logger.info(f\"✓ Sandbox with read-only volume created: {sandbox.id}\")\n\n            # Step 1: Verify the host marker file is readable\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"opensandbox-e2e-marker\"\n            logger.info(\"✓ Host marker file read successfully in read-only mount\")\n\n            # Step 2: Verify writing is denied on read-only mount\n            result = await sandbox.commands.run(\n                f\"touch {container_mount_path}/should-fail.txt\"\n            )\n            assert result.error is not None, \"Write should fail on read-only mount\"\n            logger.info(\"✓ Write correctly denied on read-only mount\")\n\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n        logger.info(\"TEST 1c PASSED: Read-only host volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01d_pvc_named_volume_mount(self):\n        \"\"\"Test creating a sandbox with a PVC (Docker named volume) mount.\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1d: Creating sandbox with PVC named volume mount (async)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/pvc-data\"\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-vol\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                ),\n            ],\n        )\n        try:\n            logger.info(f\"✓ Sandbox with PVC volume created: {sandbox.id}\")\n\n            # Step 1: Verify the marker file seeded into the named volume is readable\n            logger.info(\"Step 1: Verify PVC marker file is readable inside the sandbox\")\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-marker-data\"\n            logger.info(\"✓ PVC marker file read successfully inside sandbox\")\n\n            # Step 2: Write a file from inside the sandbox to the named volume\n            logger.info(\"Step 2: Write a file from inside the sandbox to the PVC mount\")\n            result = await sandbox.commands.run(\n                f\"echo 'written-to-pvc' > {container_mount_path}/pvc-output.txt\"\n            )\n            assert result.error is None, f\"Failed to write file: {result.error}\"\n\n            # Step 3: Verify the written file is readable\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/pvc-output.txt\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"written-to-pvc\"\n            logger.info(\"✓ File written and verified inside sandbox via PVC mount\")\n\n            # Step 4: Verify the mount path is a proper directory\n            result = await sandbox.commands.run(f\"test -d {container_mount_path} && echo OK\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"OK\"\n            logger.info(\"✓ PVC mount path is a valid directory\")\n\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n        logger.info(\"TEST 1d PASSED: PVC named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01e_pvc_named_volume_mount_readonly(self):\n        \"\"\"Test creating a sandbox with a read-only PVC (Docker named volume) mount.\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1e: Creating sandbox with read-only PVC named volume mount (async)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/pvc-data-ro\"\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-vol-ro\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=True,\n                ),\n            ],\n        )\n        try:\n            logger.info(f\"✓ Sandbox with read-only PVC volume created: {sandbox.id}\")\n\n            # Step 1: Verify the marker file is readable on read-only mount\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-marker-data\"\n            logger.info(\"✓ PVC marker file read successfully in read-only mount\")\n\n            # Step 2: Verify writing is denied on read-only mount\n            result = await sandbox.commands.run(\n                f\"touch {container_mount_path}/should-fail.txt\"\n            )\n            assert result.error is not None, \"Write should fail on read-only PVC mount\"\n            logger.info(\"✓ Write correctly denied on read-only PVC mount\")\n\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n        logger.info(\"TEST 1e PASSED: Read-only PVC named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    async def test_01f_pvc_named_volume_subpath_mount(self):\n        \"\"\"Test creating a sandbox with a PVC named volume mount using subPath.\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1f: Creating sandbox with PVC named volume subPath mount (async)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/train\"\n\n        cfg = create_connection_config()\n        sandbox = await Sandbox.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-subpath\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                    subPath=\"datasets/train\",\n                ),\n            ],\n        )\n        try:\n            logger.info(f\"✓ Sandbox with PVC subPath volume created: {sandbox.id}\")\n\n            # Step 1: Verify the subpath marker file is readable\n            logger.info(\"Step 1: Verify subPath marker file is readable\")\n            result = await sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read subpath marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-subpath-marker\"\n            logger.info(\"✓ SubPath marker file read successfully\")\n\n            # Step 2: Verify we only see the subpath contents (not the full volume)\n            logger.info(\"Step 2: Verify only subPath contents are visible\")\n            result = await sandbox.commands.run(f\"ls {container_mount_path}/\")\n            assert result.error is None\n            # Should contain marker.txt but NOT 'datasets' directory (we are inside it)\n            stdout_text = \"\\n\".join(msg.text for msg in result.logs.stdout)\n            assert \"marker.txt\" in stdout_text\n            assert \"datasets\" not in stdout_text\n            logger.info(\"✓ Only subPath contents are visible inside the sandbox\")\n\n            # Step 3: Write a file and verify (retry read-back for transient SSE drops)\n            logger.info(\"Step 3: Write and verify a file inside subPath mount\")\n            result = await sandbox.commands.run(\n                f\"echo 'subpath-write-test' > {container_mount_path}/output.txt\"\n            )\n            assert result.error is None\n            for _attempt in range(3):\n                result = await sandbox.commands.run(f\"cat {container_mount_path}/output.txt\")\n                if result.logs.stdout:\n                    break\n                await asyncio.sleep(1)\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"subpath-write-test\"\n            logger.info(\"✓ File written and verified inside subPath mount\")\n\n        finally:\n            try:\n                await sandbox.kill()\n            except Exception:\n                pass\n            await sandbox.close()\n\n        logger.info(\"TEST 1f PASSED: PVC subPath named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(2)\n    async def test_02_basic_command_execution(self):\n        \"\"\"Test basic command execution.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 2: Testing basic command execution\")\n        logger.info(\"=\" * 80)\n\n        logger.info(\"Step 1: Simple echo command with handlers to capture events\")\n        stdout_messages = []\n        stderr_messages = []\n        results = []\n        completed_events = []\n        errors = []\n        init_events = []\n\n        async def on_stdout(msg: OutputMessage):\n            stdout_messages.append(msg)\n            logger.info(f\"Stdout: {msg.text}\")\n\n        async def on_stderr(msg: OutputMessage):\n            stderr_messages.append(msg)\n            logger.warning(f\"Stderr: {msg.text}\")\n\n        async def on_result(result: ExecutionResult):\n            results.append(result)\n            logger.info(f\"Result: {result.text}\")\n\n        async def on_execution_complete(complete: ExecutionComplete):\n            completed_events.append(complete)\n            logger.info(f\"Execution completed in {complete.execution_time_in_millis} ms\")\n\n        async def on_error(error: ExecutionError):\n            errors.append(error)\n            logger.error(f\"Error: {error.name} - {error.value}\")\n\n        async def on_init(init: ExecutionInit):\n            init_events.append(init)\n            logger.info(f\"Execution initialized with ID: {init.id}\")\n\n        handlers = ExecutionHandlers(\n            on_stdout=on_stdout,\n            on_stderr=on_stderr,\n            on_result=on_result,\n            on_execution_complete=on_execution_complete,\n            on_error=on_error,\n            on_init=on_init\n        )\n\n        echo_result = await sandbox.commands.run(\n            \"echo 'Hello OpenSandbox E2E'\",\n            handlers=handlers,\n        )\n\n        # Verify result\n        assert echo_result is not None\n        assert echo_result.id is not None and echo_result.id.strip()\n        assert echo_result.error is None\n        assert len(echo_result.logs.stdout) == 1\n        assert echo_result.logs.stdout[0].text == \"Hello OpenSandbox E2E\"\n        assert echo_result.logs.stdout[0].is_error is False\n        _assert_recent_timestamp_ms(echo_result.logs.stdout[0].timestamp)\n        assert len(echo_result.logs.stderr) == 0\n\n        # Verify handlers captured events\n        assert len(init_events) == 1, \"Execution should have exactly one init event\"\n        assert len(completed_events) == 1, \"Execution should have exactly one completion event\"\n        assert init_events[0].id == echo_result.id\n        _assert_recent_timestamp_ms(init_events[0].timestamp)\n        _assert_recent_timestamp_ms(completed_events[0].timestamp)\n        assert completed_events[0].execution_time_in_millis >= 0\n\n        assert len(stdout_messages) == 1, \"Should have captured exactly one stdout message\"\n        assert stdout_messages[0].text == \"Hello OpenSandbox E2E\"\n        assert stdout_messages[0].is_error is False\n        _assert_recent_timestamp_ms(stdout_messages[0].timestamp)\n\n        assert len(errors) == 0, \"Should have no errors for successful command\"\n\n        logger.info(\n            \"✓ Captured %s stdout, %s stderr, %s results, %s errors, %s completions, %s inits\",\n            len(stdout_messages),\n            len(stderr_messages),\n            len(results),\n            len(errors),\n            len(completed_events),\n            len(init_events),\n        )\n\n        logger.info(\"Step 2: Command with working directory\")\n        pwd_result = await sandbox.commands.run(\n            \"pwd\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert pwd_result is not None\n        assert pwd_result.id is not None and pwd_result.id.strip()\n        assert pwd_result.error is None\n        assert len(pwd_result.logs.stdout) == 1\n        assert pwd_result.logs.stdout[0].text == \"/tmp\"\n        assert pwd_result.logs.stdout[0].is_error is False\n        _assert_recent_timestamp_ms(pwd_result.logs.stdout[0].timestamp)\n        logger.info(f\"✓ PWD command executed: {pwd_result}\")\n\n        logger.info(\"Step 3: Background command\")\n        start_time = time.time()\n        await sandbox.commands.run(\n            \"sleep 30\",\n            opts=RunCommandOpts(background=True),\n        )\n        end_time = time.time()\n\n        execution_time = (end_time - start_time) * 1000\n        assert execution_time < 10000, \\\n            f\"Background command should return quickly, but took {execution_time} ms\"\n        logger.info(f\"✓ Background command returned in {execution_time:.2f} ms\")\n\n        logger.info(\"Step 4: Test failing command\")\n        # Clear event lists for fail test\n        stdout_messages.clear()\n        stderr_messages.clear()\n        errors.clear()\n        completed_events.clear()\n        init_events.clear()\n\n        fail_result = await sandbox.commands.run(\n            \"nonexistent-command-that-does-not-exist\",\n            handlers=handlers,\n        )\n\n        # Verify error result\n        assert fail_result is not None\n        assert fail_result.id is not None and fail_result.id.strip()\n        assert fail_result.error is not None\n        assert fail_result.error.name == \"CommandExecError\"\n        assert len(fail_result.logs.stderr) > 0\n        assert any(\n            \"nonexistent-command-that-does-not-exist\" in m.text for m in fail_result.logs.stderr\n        )\n        assert all(m.is_error is True for m in fail_result.logs.stderr)\n        _assert_recent_timestamp_ms(fail_result.logs.stderr[0].timestamp)\n\n        # Verify handlers captured error events\n        assert len(init_events) == 1, \"Execution should have exactly one init event\"\n        assert init_events[0].id == fail_result.id\n        _assert_recent_timestamp_ms(init_events[0].timestamp)\n        # Contract: error and complete are mutually exclusive; failing command should emit error only.\n        assert len(errors) >= 1, \"Should have captured error events\"\n        assert len(completed_events) == 0, \"Failing command should not emit completion event\"\n\n        assert errors[0].name == \"CommandExecError\", \"Error name should match\"\n        assert len(stderr_messages) > 0, \"Should have captured stderr messages\"\n        assert \"nonexistent-command-that-does-not-exist\" in stderr_messages[0].text, (\n            \"Stderr should contain command name\"\n        )\n\n        logger.info(f\"✓ Failed command result: {fail_result}\")\n\n        logger.info(\"TEST 2 PASSED: Basic command execution test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(3)\n    async def test_02a_command_status_and_logs(self):\n        \"\"\"Test command status + background logs.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        exec_result = await sandbox.commands.run(\n            \"sh -c 'echo log-line-1; echo log-line-2; sleep 2'\",\n            opts=RunCommandOpts(background=True),\n        )\n        assert exec_result.id is not None\n        command_id = exec_result.id\n\n        status = await sandbox.commands.get_command_status(command_id)\n        assert status.id == command_id\n        assert isinstance(status.running, bool)\n\n        logs_text = \"\"\n        cursor = None\n        for _ in range(20):\n            logs = await sandbox.commands.get_background_command_logs(command_id, cursor=cursor)\n            logs_text += logs.content\n            cursor = logs.cursor if logs.cursor is not None else cursor\n            if \"log-line-2\" in logs_text:\n                break\n            await asyncio.sleep(1.0)\n\n        assert \"log-line-1\" in logs_text\n        assert \"log-line-2\" in logs_text\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(3)\n    async def test_02b_run_command_with_envs(self):\n        \"\"\"Test run_command env injection via RunCommandOpts.envs.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        env_key = \"OPEN_SANDBOX_E2E_CMD_ENV\"\n        env_value = f\"env-ok-{int(time.time())}\"\n        probe_command = (\n            f\"sh -c 'if [ -z \\\"${{{env_key}:-}}\\\" ]; then echo \\\"__EMPTY__\\\"; \"\n            f\"else echo \\\"${{{env_key}}}\\\"; fi'\"\n        )\n\n        # Baseline: variable should be empty when not injected.\n        baseline = await sandbox.commands.run(probe_command)\n        assert baseline.error is None\n        baseline_output = \"\\n\".join(msg.text for msg in baseline.logs.stdout).strip()\n        assert baseline_output == \"__EMPTY__\"\n\n        # Inject environment variables for this command only.\n        injected = await sandbox.commands.run(\n            probe_command,\n            opts=RunCommandOpts(\n                envs={\n                    env_key: env_value,\n                    \"OPEN_SANDBOX_E2E_SECOND_ENV\": \"second-ok\",\n                }\n            ),\n        )\n        assert injected.error is None\n        injected_output = \"\\n\".join(msg.text for msg in injected.logs.stdout).strip()\n        assert injected_output == env_value\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(4)\n    async def test_03_basic_filesystem_operations(self):\n        \"\"\"Test basic filesystem operations.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 3: Testing basic filesystem operations\")\n        logger.info(\"=\" * 80)\n\n        test_dir1 = f\"/tmp/fs_test1_{int(time.time() * 1000)}\"\n        test_dir2 = f\"/tmp/fs_test2_{int(time.time() * 1000)}\"\n\n        logger.info(\"Step 1: Create directories\")\n        dir_entry1 = WriteEntry(path=test_dir1, mode=755)\n        dir_entry2 = WriteEntry(path=test_dir2, mode=644)\n        await sandbox.files.create_directories([dir_entry1, dir_entry2])\n        logger.info(f\"✓ Created directories: {test_dir1}, {test_dir2}\")\n\n        dir_info_map = await sandbox.files.get_file_info([test_dir1, test_dir2])\n        assert test_dir1 in dir_info_map\n        assert test_dir2 in dir_info_map\n        assert dir_info_map[test_dir1].path == test_dir1\n        assert dir_info_map[test_dir2].path == test_dir2\n        assert dir_info_map[test_dir1].mode == 755\n        assert dir_info_map[test_dir2].mode == 644\n        assert dir_info_map[test_dir1].owner\n        assert dir_info_map[test_dir1].group\n        _assert_times_close(dir_info_map[test_dir1].created_at, dir_info_map[test_dir1].modified_at)\n\n        ls_result = await sandbox.commands.run(\n            \"ls -la | grep fs_test\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert len(ls_result.logs.stdout) == 2, \"Should find exactly 2 directories\"\n        logger.info(f\"✓ Directory verification: {ls_result}\")\n\n        logger.info(\"Step 2: Create and write files\")\n        test_file1 = f\"{test_dir1}/test_file1.txt\"\n        test_file2 = f\"{test_dir1}/test_file2.txt\"\n        test_file3 = f\"{test_dir1}/test_file3.txt\"\n        test_content = \"Hello Filesystem!\\\\nLine 2 with special chars: åäö\\\\nLine 3\"\n\n        write_entry1 = WriteEntry(path=test_file1, data=test_content, mode=644)\n        write_entry2 = WriteEntry(path=test_file2, data=test_content.encode('utf-8'), mode=755)\n        write_entry3 = WriteEntry(\n            path=test_file3,\n            data=BytesIO(test_content.encode('utf-8')),\n            group=\"nogroup\",\n            owner=\"nobody\",\n            mode=755\n        )\n        await sandbox.files.write_files([write_entry1, write_entry2, write_entry3])\n        logger.info(\"✓ Created 3 test files\")\n\n        logger.info(\"Step 3: Read and verify file content using different methods\")\n        read_content1 = await sandbox.files.read_file(test_file1, encoding='utf-8')\n        read_content1_partial = await sandbox.files.read_file(\n            test_file1, encoding='utf-8', range_header=\"bytes=0-9\"\n        )\n\n        read_bytes2 = await sandbox.files.read_bytes(test_file2)\n        read_content2 = read_bytes2.decode('utf-8')\n\n        stream3 = await sandbox.files.read_bytes_stream(test_file3)\n        read_content3_bytes = b\"\"\n        async for chunk in stream3:\n            read_content3_bytes += chunk\n        read_content3 = read_content3_bytes.decode(\"utf-8\")\n\n        expected_size = len(test_content.encode(\"utf-8\"))\n        assert read_content1 == test_content\n        assert read_content2 == test_content\n        assert read_content3 == test_content\n        assert read_content1_partial == test_content[:10]\n        logger.info(\"✓ All file reads successful and content verified\")\n\n        logger.info(\"Step 4: Get and verify file info\")\n        all_test_files = [test_file1, test_file2, test_file3]\n        file_info_map = await sandbox.files.get_file_info(all_test_files)\n\n        file_info1 = file_info_map[test_file1]\n        assert file_info1 is not None\n        assert file_info1.path == test_file1\n        assert file_info1.size == expected_size\n        assert file_info1.mode == 644\n        assert file_info1.owner is not None\n        assert file_info1.group is not None\n        _assert_times_close(file_info1.created_at, file_info1.modified_at)\n\n        file_info2 = file_info_map[test_file2]\n        assert file_info2 is not None\n        assert file_info2.path == test_file2\n        assert file_info2.size == expected_size\n        assert file_info2.mode == 755\n        assert file_info2.owner is not None\n        assert file_info2.group is not None\n        _assert_times_close(file_info2.created_at, file_info2.modified_at)\n\n        file_info3 = file_info_map[test_file3]\n        assert file_info3 is not None\n        assert file_info3.path == test_file3\n        assert file_info3.size == expected_size\n        assert file_info3.mode == 755\n        assert file_info3.owner == \"nobody\"\n        assert file_info3.group == \"nogroup\"\n        _assert_times_close(file_info3.created_at, file_info3.modified_at)\n        logger.info(f\"✓ File info verified: size={file_info1.size}, mode={oct(file_info1.mode)}\")\n\n        logger.info(\"Step 5: Test search functionality\")\n        search_all_entry = SearchEntry(path=test_dir1, pattern=\"*\")\n        all_files_list = await sandbox.files.search(search_all_entry)\n        all_files = {entry.path: entry for entry in all_files_list}\n\n        assert len(all_files) == 3\n        assert test_file1 in all_files\n        assert test_file2 in all_files\n        assert test_file3 in all_files\n        assert all_files[test_file1].size == expected_size\n        _assert_times_close(all_files[test_file1].created_at, all_files[test_file1].modified_at)\n        logger.info(\"✓ Search found all 3 files\")\n\n        logger.info(\"Step 6: Test permission changes\")\n        perm_entry1 = SetPermissionEntry(\n            path=test_file1,\n            mode=755,\n            owner=\"nobody\",\n            group=\"nogroup\"\n        )\n        perm_entry2 = SetPermissionEntry(\n            path=test_file2,\n            mode=600,\n            owner=\"nobody\",\n            group=\"nogroup\"\n        )\n        await sandbox.files.set_permissions([perm_entry1, perm_entry2])\n\n        updated_info_map = await sandbox.files.get_file_info([test_file1, test_file2])\n        updated_info1 = updated_info_map[test_file1]\n        updated_info2 = updated_info_map[test_file2]\n\n        assert updated_info1.mode == 755\n        assert updated_info1.owner == \"nobody\"\n        assert updated_info1.group == \"nogroup\"\n\n        assert updated_info2.mode == 600\n        assert updated_info2.owner == \"nobody\"\n        assert updated_info2.group == \"nogroup\"\n        logger.info(\"✓ Permissions updated successfully\")\n\n        logger.info(\"Step 7: Update file content\")\n        before_update_info = (await sandbox.files.get_file_info([test_file1]))[test_file1]\n        updated_content1 = test_content + \"\\\\nAppended line to file1\"\n        updated_content2 = test_content + \"\\\\nAppended line to file2\"\n\n        # Ensure server-visible mtime delta is measurable.\n        await asyncio.sleep(0.05)\n\n        update_entry1 = WriteEntry(path=test_file1, data=updated_content1, mode=644)\n        update_entry2 = WriteEntry(path=test_file2, data=updated_content2, mode=755)\n        await sandbox.files.write_files([update_entry1, update_entry2])\n\n        new_content1 = await sandbox.files.read_file(test_file1, encoding=\"utf-8\")\n        new_content2 = await sandbox.files.read_file(test_file2, encoding=\"utf-8\")\n\n        assert new_content1 == updated_content1\n        assert new_content2 == updated_content2\n        logger.info(\"✓ File content updated successfully\")\n\n        after_update_info = (await sandbox.files.get_file_info([test_file1]))[test_file1]\n        assert after_update_info.size == len(updated_content1.encode(\"utf-8\"))\n        _assert_modified_updated(before_update_info.modified_at, after_update_info.modified_at, min_delta_ms=1)\n\n        logger.info(\"Step 8: Replace file contents via API (replace_contents)\")\n        before_replace_info = after_update_info\n        await asyncio.sleep(0.05)\n        replace_entry = ContentReplaceEntry(\n            path=test_file1,\n            old_content=\"Appended line to file1\",\n            new_content=\"Replaced line in file1\",\n        )\n        await sandbox.files.replace_contents([replace_entry])\n        replaced_content1 = await sandbox.files.read_file(test_file1, encoding=\"utf-8\")\n        assert \"Replaced line in file1\" in replaced_content1\n        assert \"Appended line to file1\" not in replaced_content1\n\n        after_replace_info = (await sandbox.files.get_file_info([test_file1]))[test_file1]\n        _assert_modified_updated(before_replace_info.modified_at, after_replace_info.modified_at, min_delta_ms=1)\n\n        logger.info(\"Step 9: Move/rename a file via API (move_files)\")\n        moved_path = f\"{test_dir2}/moved_file3.txt\"\n        await sandbox.files.move_files([MoveEntry(src=test_file3, dest=moved_path)])\n        moved_bytes = await sandbox.files.read_bytes(moved_path)\n        assert moved_bytes.decode(\"utf-8\") == test_content\n        with pytest.raises(Exception):\n            await sandbox.files.read_bytes(test_file3)\n\n        logger.info(\"Step 10: Delete file via API (delete_files)\")\n        await sandbox.files.delete_files([test_file2])\n        with pytest.raises(Exception):\n            await sandbox.files.read_file(test_file2, encoding=\"utf-8\")\n\n        # After move+delete, search should reflect the updated view.\n        files_after = await sandbox.files.search(SearchEntry(path=test_dir1, pattern=\"*\"))\n        assert {e.path for e in files_after} == {test_file1}\n\n        logger.info(\"Step 11: Delete directories recursively (delete_directories)\")\n        await sandbox.files.delete_directories([test_dir1, test_dir2])\n        verify_dirs_deleted = await sandbox.commands.run(\n            f\"test ! -d {test_dir1} && test ! -d {test_dir2} && echo OK\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert verify_dirs_deleted.error is None\n        assert len(verify_dirs_deleted.logs.stdout) == 1\n        assert verify_dirs_deleted.logs.stdout[0].text == \"OK\"\n\n        logger.info(\"TEST 3 PASSED: Basic filesystem operations test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(5)\n    async def test_04_interrupt_command(self):\n        \"\"\"Test interrupting a long-running command.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 4: Testing command interrupt\")\n        logger.info(\"=\" * 80)\n\n        init_events: list[ExecutionInit] = []\n        completed_events: list[ExecutionComplete] = []\n        errors: list[ExecutionError] = []\n        init_received = asyncio.Event()\n\n        async def on_init(init: ExecutionInit):\n            init_events.append(init)\n            init_received.set()\n\n        async def on_execution_complete(complete: ExecutionComplete):\n            completed_events.append(complete)\n\n        async def on_error(error: ExecutionError):\n            errors.append(error)\n\n        handlers = ExecutionHandlers(\n            on_init=on_init,\n            on_execution_complete=on_execution_complete,\n            on_error=on_error,\n        )\n\n        start = time.time()\n        task = asyncio.create_task(\n            sandbox.commands.run(\n                \"sleep 30\",\n                handlers=handlers,\n            )\n        )\n\n        await asyncio.wait_for(init_received.wait(), timeout=15)\n        assert len(init_events) == 1\n        assert init_events[0].id is not None and init_events[0].id.strip()\n        _assert_recent_timestamp_ms(init_events[0].timestamp)\n\n        await sandbox.commands.interrupt(init_events[0].id)\n\n        execution = await asyncio.wait_for(task, timeout=30)\n        elapsed = time.time() - start\n\n        assert execution is not None\n        assert execution.id == init_events[0].id\n        assert elapsed < 20, f\"Interrupted command took too long: {elapsed:.2f}s\"\n        # Contract: error and complete are mutually exclusive.\n        assert (len(completed_events) > 0) or (len(errors) > 0), (\n            f\"expected exactly one of complete/error, got complete={len(completed_events)} \"\n            f\"error={len(errors)}\"\n        )\n        if len(completed_events) > 0:\n            assert len(completed_events) == 1\n            _assert_recent_timestamp_ms(completed_events[0].timestamp, tolerance_ms=180_000)\n\n        # Interrupt should stop the process early; most implementations surface an error and/or stderr.\n        assert execution.error is not None or len(execution.logs.stderr) > 0\n        if execution.error is not None:\n            assert execution.error.name\n            assert execution.error.value\n            _assert_recent_timestamp_ms(execution.error.timestamp, tolerance_ms=180_000)\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(6)\n    async def test_05_sandbox_pause(self):\n        \"\"\"Test sandbox pause operation.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 5: Testing sandbox pause operation\")\n        logger.info(\"=\" * 80)\n\n        # Sandbox has been exercised through tests 01-04; a brief settle is sufficient.\n        await asyncio.sleep(2)\n        assert await sandbox.is_healthy(), \"Sandbox should be healthy before pause\"\n\n        logger.info(\"Requesting sandbox pause...\")\n        await sandbox.pause()\n\n        start_time = time.time()\n        poll_count = 0\n        final_status = None\n\n        logger.info(\"Polling for status change (timeout: 30s)...\")\n        while poll_count < 30:\n            await asyncio.sleep(1)\n            poll_count += 1\n\n            info = await sandbox.get_info()\n            current_status = info.status\n            logger.info(f\"Poll {poll_count}: Status = {current_status.state}\")\n\n            if current_status.state == \"Pausing\":\n                continue\n            else:\n                final_status = current_status\n                break\n\n        assert final_status is not None, \"Failed to get final status after pause operation\"\n        assert final_status.state == \"Paused\", \"Sandbox should be in Paused state\"\n\n        # Verify pause semantics: execd should be unreachable.\n        # The global HTTP request_timeout is 3 min, so we wrap the single\n        # is_healthy() call in a short asyncio timeout.  A paused container's\n        # frozen process will never reply, causing either a timeout (good) or\n        # an immediate connection refusal (also good).\n        try:\n            healthy = await asyncio.wait_for(sandbox.is_healthy(), timeout=15)\n        except asyncio.TimeoutError:\n            healthy = False\n        assert healthy is False, \"Sandbox should be unhealthy after pause\"\n\n        elapsed_time = (time.time() - start_time) * 1000\n        logger.info(f\"✓ Sandbox pause confirmed in {elapsed_time:.2f} ms\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(7)\n    async def test_06_sandbox_resume(self):\n        \"\"\"Test sandbox resume operation.\"\"\"\n        await self._ensure_sandbox_created()\n        sandbox = TestSandboxE2E.sandbox\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 6: Testing sandbox resume operation\")\n        logger.info(\"=\" * 80)\n\n        logger.info(\"Requesting sandbox resume...\")\n        resumed = await Sandbox.resume(\n            sandbox_id=sandbox.id,\n            connection_config=TestSandboxE2E.connection_config,\n        )\n        # Replace the class-held instance so subsequent operations/teardown use the resumed instance.\n        TestSandboxE2E.sandbox = resumed\n        sandbox = resumed\n\n        start_time = time.time()\n        poll_count = 0\n        final_status = None\n\n        logger.info(\"Polling for status change (timeout: 1 minute)...\")\n        while poll_count < 60:\n            await asyncio.sleep(1)\n            poll_count += 1\n\n            info = await sandbox.get_info()\n            current_status = info.status\n            logger.info(f\"Poll {poll_count}: Status = {current_status.state}\")\n\n            if current_status.state == \"Running\":\n                final_status = current_status\n                break\n\n        assert final_status is not None, \"Failed to get final status after resume operation\"\n        assert final_status.state == \"Running\", \"Sandbox should be in Running state after resume\"\n\n        logger.info(\"Verifying sandbox health after resume...\")\n        healthy = False\n        for _ in range(30):\n            healthy = await sandbox.is_healthy()\n            if healthy:\n                break\n            await asyncio.sleep(1)\n        assert healthy is True, \"Sandbox should be healthy after resume\"\n\n        # Minimal smoke check: after resume, the existing Sandbox instance should still be usable.\n        # This helps validate that SDK re-bound its execd adapters (endpoint may change across resume).\n        echo = await sandbox.commands.run(\"echo resume-ok\")\n        assert echo.error is None\n        assert len(echo.logs.stdout) == 1\n        assert echo.logs.stdout[0].text == \"resume-ok\"\n\n        elapsed_time = (time.time() - start_time) * 1000\n        logger.info(f\"✓ Sandbox resume completed in {elapsed_time:.2f} ms\")\n        logger.info(\"TEST 5 PASSED: Sandbox resume operation test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(8)\n    async def test_07_x_request_id_passthrough_on_server_error(self):\n        request_id = f\"e2e-py-server-{int(time.time() * 1000)}\"\n        missing_sandbox_id = f\"missing-{request_id}\"\n        cfg = ConnectionConfig(\n            domain=TEST_DOMAIN,\n            api_key=TEST_API_KEY,\n            request_timeout=timedelta(minutes=3),\n            protocol=TEST_PROTOCOL,\n            headers={\"X-Request-ID\": request_id},\n        )\n\n        with pytest.raises(SandboxApiException) as ei:\n            connected = await Sandbox.connect(sandbox_id=missing_sandbox_id, connection_config=cfg)\n            await connected.get_info()\n        assert ei.value.request_id == request_id\n"
  },
  {
    "path": "tests/python/tests/test_sandbox_e2e_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive Sync E2E tests for SandboxSync functionality.\n\nThis mirrors `test_sandbox_e2e.py` but uses the synchronous SDK.\n\"\"\"\n\nimport logging\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom datetime import timedelta\nfrom io import BytesIO\n\nimport httpx\nimport pytest\nfrom opensandbox import SandboxSync\nfrom opensandbox.config.connection_sync import ConnectionConfigSync\nfrom opensandbox.exceptions import SandboxApiException\nfrom opensandbox.models.execd import (\n    ExecutionComplete,\n    ExecutionError,\n    ExecutionInit,\n    OutputMessage,\n    RunCommandOpts,\n)\nfrom opensandbox.models.execd_sync import ExecutionHandlersSync\nfrom opensandbox.models.filesystem import (\n    ContentReplaceEntry,\n    MoveEntry,\n    SearchEntry,\n    SetPermissionEntry,\n    WriteEntry,\n)\nfrom opensandbox.models.sandboxes import (\n    PVC,\n    Host,\n    NetworkPolicy,\n    NetworkRule,\n    SandboxImageSpec,\n    Volume,\n)\n\nfrom tests.base_e2e_test import (\n    TEST_API_KEY,\n    TEST_DOMAIN,\n    TEST_PROTOCOL,\n    create_connection_config_sync,\n    get_sandbox_image,\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef _now_ms() -> int:\n    return int(time.time() * 1000)\n\n\ndef _assert_recent_timestamp_ms(ts: int, *, tolerance_ms: int = 60_000) -> None:\n    assert isinstance(ts, int)\n    assert ts > 0\n    delta = abs(_now_ms() - ts)\n    assert delta <= tolerance_ms, f\"timestamp too far from now: delta={delta}ms (ts={ts})\"\n\n\ndef _assert_endpoint_has_port(endpoint: str, expected_port: int) -> None:\n    assert endpoint\n    # In some deployments lifecycle returns direct \"host:port\".\n    # In others it returns a reverse-proxy route like \"domain/route/{id}/{port}\".\n    # In both cases, we expect NO scheme, and the port to be present deterministically.\n    assert \"://\" not in endpoint, f\"unexpected scheme in endpoint: {endpoint}\"\n\n    if \"/\" in endpoint:\n        assert endpoint.endswith(f\"/{expected_port}\"), (\n            f\"endpoint route must end with /{expected_port}: {endpoint}\"\n        )\n        assert endpoint.split(\"/\", 1)[0], f\"missing domain in endpoint: {endpoint}\"\n        return\n\n    host, port = endpoint.rsplit(\":\", 1)\n    assert host, f\"missing host in endpoint: {endpoint}\"\n    assert port.isdigit(), f\"non-numeric port in endpoint: {endpoint}\"\n    assert int(port) == expected_port, f\"endpoint port mismatch: {endpoint} != :{expected_port}\"\n\n\ndef _assert_times_close(created_at, modified_at, *, tolerance_seconds: float = 2.0) -> None:\n    \"\"\"\n    Some filesystems / implementations may report created/modified with slight reordering.\n    We only assert they're close, and rely on explicit update operations to validate mtime.\n    \"\"\"\n    delta = abs((modified_at - created_at).total_seconds())\n    assert delta <= tolerance_seconds, f\"created/modified skew too large: {delta}s\"\n\n\ndef _assert_modified_updated(before, after, *, min_delta_ms: int = 0, allow_skew_ms: int = 1000) -> None:\n    \"\"\"\n    Validate modified_at moved forward after a mutating operation, allowing small clock jitter.\n    \"\"\"\n    delta_ms = int((after - before).total_seconds() * 1000)\n    assert delta_ms >= min_delta_ms - allow_skew_ms, (\n        f\"modified_at did not update as expected: delta_ms={delta_ms} \"\n        f\"(min_delta_ms={min_delta_ms}, allow_skew_ms={allow_skew_ms})\"\n    )\n\n\nclass TestSandboxE2ESync:\n    \"\"\"Comprehensive E2E tests for SandboxSync functionality (ordered).\"\"\"\n\n    sandbox = None\n    connection_config = None\n    _setup_done = False\n\n    @pytest.fixture(scope=\"class\", autouse=True)\n    def _sandbox_lifecycle(self, request):\n        \"\"\"Create sandbox once and ALWAYS cleanup to avoid resource leaks.\"\"\"\n        request.cls._ensure_sandbox_created()\n        try:\n            yield\n        finally:\n            sandbox = request.cls.sandbox\n            if sandbox is not None:\n                try:\n                    sandbox.kill()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.kill() failed: %s\", e, exc_info=True)\n                try:\n                    sandbox.close()\n                except Exception as e:\n                    logger.warning(\"Teardown: sandbox.close() failed: %s\", e, exc_info=True)\n\n            cfg = request.cls.connection_config\n            if cfg is not None:\n                try:\n                    cfg.transport.close()\n                except Exception:\n                    pass\n\n    @classmethod\n    def _ensure_sandbox_created(cls) -> None:\n        if cls._setup_done:\n            return\n\n        logger.info(\"=\" * 100)\n        logger.info(\"SETUP: Creating sandbox (sync)\")\n        logger.info(\"=\" * 100)\n\n        cls.connection_config = create_connection_config_sync()\n\n        cls.sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cls.connection_config,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            metadata={\"tag\": \"e2e-test\"},\n            env={\n                \"E2E_TEST\": \"true\",\n                \"GO_VERSION\": \"1.25\",\n                \"JAVA_VERSION\": \"21\",\n                \"NODE_VERSION\": \"22\",\n                \"PYTHON_VERSION\": \"3.12\",\n            },\n            health_check_polling_interval=timedelta(milliseconds=500),\n        )\n\n        logger.info(\"✓ Sandbox created: %s\", cls.sandbox.id)\n        cls._setup_done = True\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01_sandbox_lifecycle_and_health(self) -> None:\n        \"\"\"Test sandbox lifecycle and health monitoring.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1: Testing sandbox lifecycle and health monitoring (sync)\")\n        logger.info(\"=\" * 80)\n\n        assert isinstance(sandbox.id, str)\n        assert sandbox.is_healthy() is True\n\n        info = sandbox.get_info()\n        assert info.id == sandbox.id\n        assert info.status.state == \"Running\"\n        assert info.created_at is not None\n        assert info.expires_at is not None\n        assert info.expires_at > info.created_at\n        assert info.entrypoint == [\"tail\", \"-f\", \"/dev/null\"]\n\n        duration = info.expires_at - info.created_at\n        min_duration = timedelta(minutes=1)\n        max_duration = timedelta(minutes=3)\n        assert min_duration <= duration <= max_duration, (\n            f\"Duration {duration} should be between 1 and 3 minutes\"\n        )\n\n        assert info.metadata is not None\n        assert info.metadata.get(\"tag\") == \"e2e-test\"\n\n        endpoint = sandbox.get_endpoint(44772)\n        assert endpoint is not None\n        assert endpoint.endpoint is not None\n        _assert_endpoint_has_port(endpoint.endpoint, 44772)\n\n        metrics = sandbox.get_metrics()\n        assert metrics is not None\n        assert metrics.cpu_count > 0\n        assert 0.0 <= metrics.cpu_used_percentage <= 100.0\n        assert metrics.memory_total_in_mib > 0\n        assert 0.0 <= metrics.memory_used_in_mib <= metrics.memory_total_in_mib\n        _assert_recent_timestamp_ms(metrics.timestamp, tolerance_ms=120_000)\n\n        await_renew = timedelta(minutes=20)\n        renew_response = sandbox.renew(await_renew)\n        assert renew_response is not None\n        assert renew_response.expires_at > info.expires_at\n\n        renewed_info = sandbox.get_info()\n        assert renewed_info.expires_at > info.expires_at\n        assert abs((renewed_info.expires_at - renew_response.expires_at).total_seconds()) < 10\n\n        now = renewed_info.expires_at.__class__.now(tz=renewed_info.expires_at.tzinfo)\n        remaining = renewed_info.expires_at - now\n        assert remaining > timedelta(minutes=18), f\"Remaining TTL too small: {remaining}\"\n        assert remaining < timedelta(minutes=22), f\"Remaining TTL too large: {remaining}\"\n\n        assert sandbox.files is not None\n        assert sandbox.commands is not None\n        assert sandbox.metrics is not None\n        assert sandbox.connection_config is not None\n\n        # Connect to existing sandbox by ID and run a basic command.\n        sandbox2 = SandboxSync.connect(\n            sandbox.id, connection_config=TestSandboxE2ESync.connection_config\n        )\n        try:\n            assert sandbox2.id == sandbox.id\n            assert sandbox2.is_healthy() is True\n            connect_result = sandbox2.commands.run(\n                \"echo connect-ok\",\n            )\n            assert connect_result.error is None\n            assert len(connect_result.logs.stdout) == 1\n            assert connect_result.logs.stdout[0].text == \"connect-ok\"\n        finally:\n            sandbox2.close()\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01b_manual_cleanup(self) -> None:\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=TestSandboxE2ESync.connection_config,\n            timeout=None,\n            ready_timeout=timedelta(seconds=30),\n            metadata={\"tag\": \"manual-e2e-test\"},\n        )\n        try:\n            info = sandbox.get_info()\n            assert info.expires_at is None\n            assert info.metadata is not None\n            assert info.metadata.get(\"tag\") == \"manual-e2e-test\"\n        finally:\n            sandbox.kill()\n            sandbox.close()\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01a_network_policy_create(self) -> None:\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1a: Creating sandbox with networkPolicy (sync)\")\n        logger.info(\"=\" * 80)\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            network_policy=NetworkPolicy(\n                defaultAction=\"deny\",\n                egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n            ),\n        )\n        try:\n            time.sleep(5)\n            result = sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert result.error is not None\n            result = sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert result.error is None\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n    @pytest.mark.timeout(180)\n    @pytest.mark.order(1)\n    def test_01aa_network_policy_get_and_patch(self) -> None:\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1aa: networkPolicy get/patch (sync)\")\n        logger.info(\"=\" * 80)\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            network_policy=NetworkPolicy(\n                defaultAction=\"deny\",\n                egress=[NetworkRule(action=\"allow\", target=\"pypi.org\")],\n            ),\n        )\n        try:\n            time.sleep(5)\n\n            policy = sandbox.get_egress_policy()\n            assert policy.default_action == \"deny\"\n            assert policy.egress is not None\n            assert any(rule.target == \"pypi.org\" and rule.action == \"allow\" for rule in policy.egress)\n\n            blocked = sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert blocked.error is not None\n            allowed = sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert allowed.error is None\n\n            sandbox.patch_egress_rules(\n                [\n                    NetworkRule(action=\"allow\", target=\"www.github.com\"),\n                    NetworkRule(action=\"deny\", target=\"pypi.org\"),\n                ],\n            )\n            time.sleep(2)\n\n            patched_policy = sandbox.get_egress_policy()\n            assert patched_policy.egress is not None\n            assert any(\n                rule.target == \"www.github.com\" and rule.action == \"allow\"\n                for rule in patched_policy.egress\n            )\n            assert any(\n                rule.target == \"pypi.org\" and rule.action == \"deny\"\n                for rule in patched_policy.egress\n            )\n\n            github_allowed = sandbox.commands.run(\"curl -I https://www.github.com\")\n            assert github_allowed.error is None\n            pypi_denied = sandbox.commands.run(\"curl -I https://pypi.org\")\n            assert pypi_denied.error is not None\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01b_host_volume_mount(self) -> None:\n        \"\"\"Test creating a sandbox with a host volume mount (sync).\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1b: Creating sandbox with host volume mount (sync)\")\n        logger.info(\"=\" * 80)\n\n        host_dir = \"/tmp/opensandbox-e2e/host-volume-test\"\n        container_mount_path = \"/mnt/host-data\"\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-host-vol\",\n                    host=Host(path=host_dir),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                ),\n            ],\n        )\n        try:\n            logger.info(\"✓ Sandbox with volume created: %s\", sandbox.id)\n\n            # Step 1: Verify the host marker file is visible inside the sandbox\n            result = sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"opensandbox-e2e-marker\"\n            logger.info(\"✓ Host marker file read successfully inside sandbox\")\n\n            # Step 2: Write a file from inside the sandbox to the mounted path (read-write)\n            result = sandbox.commands.run(\n                f\"echo 'written-from-sandbox' > {container_mount_path}/sandbox-output.txt\"\n            )\n            assert result.error is None, f\"Failed to write file: {result.error}\"\n\n            # Step 3: Verify the written file is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/sandbox-output.txt\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"written-from-sandbox\"\n            logger.info(\"✓ File written and verified inside sandbox\")\n\n            # Step 4: Verify the mount path is a proper directory\n            result = sandbox.commands.run(f\"test -d {container_mount_path} && echo OK\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"OK\"\n            logger.info(\"✓ Mount path is a valid directory\")\n\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n        logger.info(\"TEST 1b PASSED: Host volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01c_host_volume_mount_readonly(self) -> None:\n        \"\"\"Test creating a sandbox with a read-only host volume mount (sync).\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1c: Creating sandbox with read-only host volume mount (sync)\")\n        logger.info(\"=\" * 80)\n\n        host_dir = \"/tmp/opensandbox-e2e/host-volume-test\"\n        container_mount_path = \"/mnt/host-data-ro\"\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-host-vol-ro\",\n                    host=Host(path=host_dir),\n                    mountPath=container_mount_path,\n                    readOnly=True,\n                ),\n            ],\n        )\n        try:\n            logger.info(\"✓ Sandbox with read-only volume created: %s\", sandbox.id)\n\n            # Step 1: Verify the host marker file is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"opensandbox-e2e-marker\"\n            logger.info(\"✓ Host marker file read successfully in read-only mount\")\n\n            # Step 2: Verify writing is denied on read-only mount\n            result = sandbox.commands.run(\n                f\"touch {container_mount_path}/should-fail.txt\"\n            )\n            assert result.error is not None, \"Write should fail on read-only mount\"\n            logger.info(\"✓ Write correctly denied on read-only mount\")\n\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n        logger.info(\"TEST 1c PASSED: Read-only host volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01d_pvc_named_volume_mount(self) -> None:\n        \"\"\"Test creating a sandbox with a PVC (Docker named volume) mount (sync).\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1d: Creating sandbox with PVC named volume mount (sync)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/pvc-data\"\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-vol\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                ),\n            ],\n        )\n        try:\n            logger.info(\"✓ Sandbox with PVC volume created: %s\", sandbox.id)\n\n            # Step 1: Verify the marker file seeded into the named volume is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-marker-data\"\n            logger.info(\"✓ PVC marker file read successfully inside sandbox\")\n\n            # Step 2: Write a file from inside the sandbox to the named volume\n            result = sandbox.commands.run(\n                f\"echo 'written-to-pvc' > {container_mount_path}/pvc-output.txt\"\n            )\n            assert result.error is None, f\"Failed to write file: {result.error}\"\n\n            # Step 3: Verify the written file is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/pvc-output.txt\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"written-to-pvc\"\n            logger.info(\"✓ File written and verified inside sandbox via PVC mount\")\n\n            # Step 4: Verify the mount path is a proper directory\n            result = sandbox.commands.run(f\"test -d {container_mount_path} && echo OK\")\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"OK\"\n            logger.info(\"✓ PVC mount path is a valid directory\")\n\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n        logger.info(\"TEST 1d PASSED: PVC named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01e_pvc_named_volume_mount_readonly(self) -> None:\n        \"\"\"Test creating a sandbox with a read-only PVC (Docker named volume) mount (sync).\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1e: Creating sandbox with read-only PVC named volume mount (sync)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/pvc-data-ro\"\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-vol-ro\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=True,\n                ),\n            ],\n        )\n        try:\n            logger.info(\"✓ Sandbox with read-only PVC volume created: %s\", sandbox.id)\n\n            # Step 1: Verify the marker file is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-marker-data\"\n            logger.info(\"✓ PVC marker file read successfully in read-only mount\")\n\n            # Step 2: Verify writing is denied on read-only mount\n            result = sandbox.commands.run(\n                f\"touch {container_mount_path}/should-fail.txt\"\n            )\n            assert result.error is not None, \"Write should fail on read-only PVC mount\"\n            logger.info(\"✓ Write correctly denied on read-only PVC mount\")\n\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n        logger.info(\"TEST 1e PASSED: Read-only PVC named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(1)\n    def test_01f_pvc_named_volume_subpath_mount(self) -> None:\n        \"\"\"Test creating a sandbox with a PVC named volume mount using subPath (sync).\"\"\"\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 1f: Creating sandbox with PVC named volume subPath mount (sync)\")\n        logger.info(\"=\" * 80)\n\n        pvc_volume_name = \"opensandbox-e2e-pvc-test\"\n        container_mount_path = \"/mnt/train\"\n\n        cfg = create_connection_config_sync()\n        sandbox = SandboxSync.create(\n            image=SandboxImageSpec(get_sandbox_image()),\n            connection_config=cfg,\n            timeout=timedelta(minutes=2),\n            ready_timeout=timedelta(seconds=30),\n            volumes=[\n                Volume(\n                    name=\"test-pvc-subpath\",\n                    pvc=PVC(claimName=pvc_volume_name),\n                    mountPath=container_mount_path,\n                    readOnly=False,\n                    subPath=\"datasets/train\",\n                ),\n            ],\n        )\n        try:\n            logger.info(\"✓ Sandbox with PVC subPath volume created: %s\", sandbox.id)\n\n            # Step 1: Verify the subpath marker file is readable\n            result = sandbox.commands.run(f\"cat {container_mount_path}/marker.txt\")\n            assert result.error is None, f\"Failed to read subpath marker file: {result.error}\"\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"pvc-subpath-marker\"\n            logger.info(\"✓ SubPath marker file read successfully\")\n\n            # Step 2: Verify we only see the subpath contents (not the full volume)\n            result = sandbox.commands.run(f\"ls {container_mount_path}/\")\n            assert result.error is None\n            stdout_text = \"\\n\".join(msg.text for msg in result.logs.stdout)\n            assert \"marker.txt\" in stdout_text\n            assert \"datasets\" not in stdout_text\n            logger.info(\"✓ Only subPath contents are visible inside the sandbox\")\n\n            # Step 3: Write a file and verify (retry read-back for transient SSE drops)\n            result = sandbox.commands.run(\n                f\"echo 'subpath-write-test' > {container_mount_path}/output.txt\"\n            )\n            assert result.error is None\n            for _attempt in range(3):\n                result = sandbox.commands.run(f\"cat {container_mount_path}/output.txt\")\n                if result.logs.stdout:\n                    break\n                time.sleep(1)\n            assert result.error is None\n            assert len(result.logs.stdout) == 1\n            assert result.logs.stdout[0].text == \"subpath-write-test\"\n            logger.info(\"✓ File written and verified inside subPath mount\")\n\n        finally:\n            try:\n                sandbox.kill()\n            except Exception:\n                pass\n            sandbox.close()\n            try:\n                cfg.transport.close()\n            except Exception:\n                pass\n\n        logger.info(\"TEST 1f PASSED: PVC subPath named volume mount test completed successfully\")\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(2)\n    def test_02_basic_command_execution(self) -> None:\n        \"\"\"Test basic command execution.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 2: Testing basic command execution (sync)\")\n        logger.info(\"=\" * 80)\n\n        stdout_messages: list[OutputMessage] = []\n        stderr_messages: list[OutputMessage] = []\n        results = []\n        completed_events: list[ExecutionComplete] = []\n        errors: list[ExecutionError] = []\n        init_events: list[ExecutionInit] = []\n\n        def on_stdout(msg):\n            stdout_messages.append(msg)\n\n        def on_stderr(msg):\n            stderr_messages.append(msg)\n\n        def on_result(result):\n            results.append(result)\n\n        def on_execution_complete(complete):\n            completed_events.append(complete)\n\n        def on_error(error):\n            errors.append(error)\n\n        def on_init(init):\n            init_events.append(init)\n\n        handlers = ExecutionHandlersSync(\n            on_stdout=on_stdout,\n            on_stderr=on_stderr,\n            on_result=on_result,\n            on_execution_complete=on_execution_complete,\n            on_error=on_error,\n            on_init=on_init,\n        )\n\n        echo_result = sandbox.commands.run(\n            \"echo 'Hello OpenSandbox E2E'\",\n            handlers=handlers,\n        )\n\n        assert echo_result is not None\n        assert echo_result.id is not None and echo_result.id.strip()\n        assert echo_result.error is None\n        assert len(echo_result.logs.stdout) == 1\n        assert echo_result.logs.stdout[0].text == \"Hello OpenSandbox E2E\"\n        assert echo_result.logs.stdout[0].is_error is False\n        _assert_recent_timestamp_ms(echo_result.logs.stdout[0].timestamp)\n        assert len(echo_result.logs.stderr) == 0\n\n        assert len(init_events) == 1\n        assert len(completed_events) == 1\n        assert init_events[0].id == echo_result.id\n        _assert_recent_timestamp_ms(init_events[0].timestamp)\n        _assert_recent_timestamp_ms(completed_events[0].timestamp)\n        assert completed_events[0].execution_time_in_millis >= 0\n\n        assert len(stdout_messages) == 1\n        assert stdout_messages[0].text == \"Hello OpenSandbox E2E\"\n        assert stdout_messages[0].is_error is False\n        _assert_recent_timestamp_ms(stdout_messages[0].timestamp)\n        assert len(errors) == 0\n\n        pwd_result = sandbox.commands.run(\n            \"pwd\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert pwd_result is not None\n        assert pwd_result.id is not None and pwd_result.id.strip()\n        assert pwd_result.error is None\n        assert len(pwd_result.logs.stdout) == 1\n        assert pwd_result.logs.stdout[0].text == \"/tmp\"\n        assert pwd_result.logs.stdout[0].is_error is False\n        _assert_recent_timestamp_ms(pwd_result.logs.stdout[0].timestamp)\n\n        start_time = time.time()\n        sandbox.commands.run(\n            \"sleep 30\",\n            opts=RunCommandOpts(background=True),\n        )\n        end_time = time.time()\n        execution_time_ms = (end_time - start_time) * 1000\n        assert execution_time_ms < 10000\n\n        stdout_messages.clear()\n        stderr_messages.clear()\n        errors.clear()\n        completed_events.clear()\n        init_events.clear()\n\n        fail_result = sandbox.commands.run(\n            \"nonexistent-command-that-does-not-exist\",\n            handlers=handlers,\n        )\n\n        assert fail_result.error is not None\n        assert fail_result.error.name == \"CommandExecError\"\n        assert len(fail_result.logs.stderr) > 0\n        assert any(\n            \"nonexistent-command-that-does-not-exist\" in m.text for m in fail_result.logs.stderr\n        )\n        assert all(m.is_error is True for m in fail_result.logs.stderr)\n        _assert_recent_timestamp_ms(fail_result.logs.stderr[0].timestamp)\n\n        assert len(init_events) == 1\n        assert init_events[0].id == fail_result.id\n        _assert_recent_timestamp_ms(init_events[0].timestamp)\n        # Contract: error and complete are mutually exclusive; failing command should emit error only.\n        assert len(errors) >= 1\n        assert len(completed_events) == 0\n\n        assert errors[0].name == \"CommandExecError\"\n        assert len(stderr_messages) > 0\n        assert \"nonexistent-command-that-does-not-exist\" in stderr_messages[0].text\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(3)\n    def test_02a_command_status_and_logs(self) -> None:\n        \"\"\"Test command status + background logs (sync).\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        exec_result = sandbox.commands.run(\n            \"sh -c 'echo log-line-1; echo log-line-2; sleep 2'\",\n            opts=RunCommandOpts(background=True),\n        )\n        assert exec_result.id is not None\n        command_id = exec_result.id\n\n        status = sandbox.commands.get_command_status(command_id)\n        assert status.id == command_id\n        assert isinstance(status.running, bool)\n\n        logs_text = \"\"\n        cursor = None\n        for _ in range(20):\n            logs = sandbox.commands.get_background_command_logs(command_id, cursor=cursor)\n            logs_text += logs.content\n            cursor = logs.cursor if logs.cursor is not None else cursor\n            if \"log-line-2\" in logs_text:\n                break\n            time.sleep(1.0)\n\n        assert \"log-line-1\" in logs_text\n        assert \"log-line-2\" in logs_text\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(3)\n    def test_02b_run_command_with_envs(self) -> None:\n        \"\"\"Test run_command env injection via RunCommandOpts.envs (sync).\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        env_key = \"OPEN_SANDBOX_E2E_CMD_ENV\"\n        env_value = f\"env-ok-{int(time.time())}\"\n        probe_command = (\n            f\"sh -c 'if [ -z \\\"${{{env_key}:-}}\\\" ]; then echo \\\"__EMPTY__\\\"; \"\n            f\"else echo \\\"${{{env_key}}}\\\"; fi'\"\n        )\n\n        # Baseline: variable should be empty when not injected.\n        baseline = sandbox.commands.run(probe_command)\n        assert baseline.error is None\n        baseline_output = \"\\n\".join(msg.text for msg in baseline.logs.stdout).strip()\n        assert baseline_output == \"__EMPTY__\"\n\n        # Inject environment variables for this command only.\n        injected = sandbox.commands.run(\n            probe_command,\n            opts=RunCommandOpts(\n                envs={\n                    env_key: env_value,\n                    \"OPEN_SANDBOX_E2E_SECOND_ENV\": \"second-ok\",\n                }\n            ),\n        )\n        assert injected.error is None\n        injected_output = \"\\n\".join(msg.text for msg in injected.logs.stdout).strip()\n        assert injected_output == env_value\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(4)\n    def test_03_basic_filesystem_operations(self) -> None:\n        \"\"\"Test basic filesystem operations.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 3: Testing basic filesystem operations (sync)\")\n        logger.info(\"=\" * 80)\n\n        test_dir1 = f\"/tmp/fs_test1_{int(time.time() * 1000)}\"\n        test_dir2 = f\"/tmp/fs_test2_{int(time.time() * 1000)}\"\n\n        dir_entry1 = WriteEntry(path=test_dir1, mode=755)\n        dir_entry2 = WriteEntry(path=test_dir2, mode=644)\n        sandbox.files.create_directories([dir_entry1, dir_entry2])\n\n        dir_info_map = sandbox.files.get_file_info([test_dir1, test_dir2])\n        assert test_dir1 in dir_info_map\n        assert test_dir2 in dir_info_map\n        assert dir_info_map[test_dir1].path == test_dir1\n        assert dir_info_map[test_dir2].path == test_dir2\n        assert dir_info_map[test_dir1].mode == 755\n        assert dir_info_map[test_dir2].mode == 644\n        assert dir_info_map[test_dir1].owner\n        assert dir_info_map[test_dir1].group\n        _assert_times_close(dir_info_map[test_dir1].created_at, dir_info_map[test_dir1].modified_at)\n\n        ls_result = sandbox.commands.run(\n            \"ls -la | grep fs_test\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert len(ls_result.logs.stdout) == 2\n\n        test_file1 = f\"{test_dir1}/test_file1.txt\"\n        test_file2 = f\"{test_dir1}/test_file2.txt\"\n        test_file3 = f\"{test_dir1}/test_file3.txt\"\n        test_content = \"Hello Filesystem!\\nLine 2 with special chars: åäö\\nLine 3\"\n\n        write_entry1 = WriteEntry(path=test_file1, data=test_content, mode=644)\n        write_entry2 = WriteEntry(path=test_file2, data=test_content.encode(\"utf-8\"), mode=755)\n        write_entry3 = WriteEntry(\n            path=test_file3,\n            data=BytesIO(test_content.encode(\"utf-8\")),\n            group=\"nogroup\",\n            owner=\"nobody\",\n            mode=755,\n        )\n        sandbox.files.write_files([write_entry1, write_entry2, write_entry3])\n\n        read_content1 = sandbox.files.read_file(test_file1, encoding=\"utf-8\")\n        read_content1_partial = sandbox.files.read_file(\n            test_file1,\n            encoding=\"utf-8\",\n            range_header=\"bytes=0-9\",\n        )\n        read_bytes2 = sandbox.files.read_bytes(test_file2)\n        read_content2 = read_bytes2.decode(\"utf-8\")\n\n        stream3 = sandbox.files.read_bytes_stream(test_file3)\n        read_content3_bytes = b\"\"\n        for chunk in stream3:\n            read_content3_bytes += chunk\n        read_content3 = read_content3_bytes.decode(\"utf-8\")\n\n        expected_size = len(test_content.encode(\"utf-8\"))\n        assert read_content1 == test_content\n        assert read_content2 == test_content\n        assert read_content3 == test_content\n        assert read_content1_partial == test_content[:10]\n\n        file_info_map = sandbox.files.get_file_info([test_file1, test_file2, test_file3])\n        file_info1 = file_info_map[test_file1]\n        assert file_info1.path == test_file1\n        assert file_info1.size == expected_size\n        assert file_info1.mode == 644\n        assert file_info1.owner is not None\n        assert file_info1.group is not None\n        _assert_times_close(file_info1.created_at, file_info1.modified_at)\n\n        file_info2 = file_info_map[test_file2]\n        assert file_info2.path == test_file2\n        assert file_info2.size == expected_size\n        assert file_info2.mode == 755\n        assert file_info2.owner is not None\n        assert file_info2.group is not None\n        _assert_times_close(file_info2.created_at, file_info2.modified_at)\n\n        file_info3 = file_info_map[test_file3]\n        assert file_info3.path == test_file3\n        assert file_info3.size == expected_size\n        assert file_info3.mode == 755\n        assert file_info3.owner == \"nobody\"\n        assert file_info3.group == \"nogroup\"\n        _assert_times_close(file_info3.created_at, file_info3.modified_at)\n\n        search_all_entry = SearchEntry(path=test_dir1, pattern=\"*\")\n        all_files_list = sandbox.files.search(search_all_entry)\n        all_files = {entry.path: entry for entry in all_files_list}\n        assert len(all_files) == 3\n        assert test_file1 in all_files\n        assert test_file2 in all_files\n        assert test_file3 in all_files\n        assert all_files[test_file1].size == expected_size\n        _assert_times_close(all_files[test_file1].created_at, all_files[test_file1].modified_at)\n\n        perm_entry1 = SetPermissionEntry(path=test_file1, mode=755, owner=\"nobody\", group=\"nogroup\")\n        perm_entry2 = SetPermissionEntry(path=test_file2, mode=600, owner=\"nobody\", group=\"nogroup\")\n        sandbox.files.set_permissions([perm_entry1, perm_entry2])\n\n        updated_info_map = sandbox.files.get_file_info([test_file1, test_file2])\n        updated_info1 = updated_info_map[test_file1]\n        updated_info2 = updated_info_map[test_file2]\n        assert updated_info1.mode == 755\n        assert updated_info1.owner == \"nobody\"\n        assert updated_info1.group == \"nogroup\"\n        assert updated_info2.mode == 600\n        assert updated_info2.owner == \"nobody\"\n        assert updated_info2.group == \"nogroup\"\n\n        before_update_info = sandbox.files.get_file_info([test_file1])[test_file1]\n        updated_content1 = test_content + \"\\nAppended line to file1\"\n        updated_content2 = test_content + \"\\nAppended line to file2\"\n        time.sleep(0.05)\n        sandbox.files.write_files(\n            [\n                WriteEntry(path=test_file1, data=updated_content1, mode=644),\n                WriteEntry(path=test_file2, data=updated_content2, mode=755),\n            ]\n        )\n\n        new_content1 = sandbox.files.read_file(test_file1, encoding=\"utf-8\")\n        new_content2 = sandbox.files.read_file(test_file2, encoding=\"utf-8\")\n        assert new_content1 == updated_content1\n        assert new_content2 == updated_content2\n\n        after_update_info = sandbox.files.get_file_info([test_file1])[test_file1]\n        assert after_update_info.size == len(updated_content1.encode(\"utf-8\"))\n        _assert_modified_updated(before_update_info.modified_at, after_update_info.modified_at, min_delta_ms=1)\n\n        # Replace file contents via API (replace_contents)\n        before_replace_info = after_update_info\n        time.sleep(0.05)\n        sandbox.files.replace_contents(\n            [\n                ContentReplaceEntry(\n                    path=test_file1,\n                    old_content=\"Appended line to file1\",\n                    new_content=\"Replaced line in file1\",\n                )\n            ]\n        )\n        replaced_content1 = sandbox.files.read_file(test_file1, encoding=\"utf-8\")\n        assert \"Replaced line in file1\" in replaced_content1\n        assert \"Appended line to file1\" not in replaced_content1\n        after_replace_info = sandbox.files.get_file_info([test_file1])[test_file1]\n        _assert_modified_updated(before_replace_info.modified_at, after_replace_info.modified_at, min_delta_ms=1)\n\n        # Move/rename a file via API (move_files)\n        moved_path = f\"{test_dir2}/moved_file3.txt\"\n        sandbox.files.move_files([MoveEntry(src=test_file3, dest=moved_path)])\n        moved_bytes = sandbox.files.read_bytes(moved_path)\n        assert moved_bytes.decode(\"utf-8\") == test_content\n        with pytest.raises(Exception):\n            sandbox.files.read_bytes(test_file3)\n\n        # Delete file via API (delete_files)\n        sandbox.files.delete_files([test_file2])\n        with pytest.raises(Exception):\n            sandbox.files.read_file(test_file2, encoding=\"utf-8\")\n\n        files_after = sandbox.files.search(SearchEntry(path=test_dir1, pattern=\"*\"))\n        assert {e.path for e in files_after} == {test_file1}\n\n        # Delete directories recursively (delete_directories)\n        sandbox.files.delete_directories([test_dir1, test_dir2])\n        verify_dirs_deleted = sandbox.commands.run(\n            f\"test ! -d {test_dir1} && test ! -d {test_dir2} && echo OK\",\n            opts=RunCommandOpts(working_directory=\"/tmp\"),\n        )\n        assert verify_dirs_deleted.error is None\n        assert len(verify_dirs_deleted.logs.stdout) == 1\n        assert verify_dirs_deleted.logs.stdout[0].text == \"OK\"\n\n    @pytest.mark.timeout(360)\n    @pytest.mark.order(5)\n    def test_04_interrupt_command(self) -> None:\n        \"\"\"Test interrupting a long-running command.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 4: Testing command interrupt (sync)\")\n        logger.info(\"=\" * 80)\n\n        init_events: list[ExecutionInit] = []\n        completed_events: list[ExecutionComplete] = []\n        errors: list[ExecutionError] = []\n\n        def on_init(init: ExecutionInit):\n            init_events.append(init)\n\n        def on_complete(complete: ExecutionComplete):\n            completed_events.append(complete)\n\n        def on_error(error: ExecutionError):\n            errors.append(error)\n\n        handlers = ExecutionHandlersSync(\n            on_init=on_init,\n            on_execution_complete=on_complete,\n            on_error=on_error,\n        )\n\n        start = time.time()\n        with ThreadPoolExecutor(max_workers=1) as ex:\n            future = ex.submit(\n                sandbox.commands.run,\n                \"sleep 30\",\n                handlers=handlers,\n            )\n            deadline = time.time() + 15\n            while len(init_events) == 0 and time.time() < deadline:\n                time.sleep(0.1)\n            assert len(init_events) == 1\n            assert init_events[0].id is not None and init_events[0].id.strip()\n            _assert_recent_timestamp_ms(init_events[0].timestamp)\n\n            sandbox.commands.interrupt(init_events[0].id)\n            execution = future.result(timeout=30)\n\n        elapsed = time.time() - start\n        assert execution is not None\n        assert execution.id == init_events[0].id\n        assert elapsed < 20, f\"Interrupted command took too long: {elapsed:.2f}s\"\n        assert (len(completed_events) > 0) or (len(errors) > 0), (\n            f\"expected exactly one of complete/error, got complete={len(completed_events)} \"\n            f\"error={len(errors)}\"\n        )\n        if len(completed_events) > 0:\n            assert len(completed_events) == 1\n            _assert_recent_timestamp_ms(completed_events[0].timestamp, tolerance_ms=180_000)\n        assert execution.error is not None or len(execution.logs.stderr) > 0\n        if execution.error is not None:\n            assert execution.error.name\n            assert execution.error.value\n            _assert_recent_timestamp_ms(execution.error.timestamp, tolerance_ms=180_000)\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(6)\n    def test_05_sandbox_pause(self) -> None:\n        \"\"\"Test sandbox pause operation.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 5: Testing sandbox pause operation (sync)\")\n        logger.info(\"=\" * 80)\n\n        # Sandbox has been exercised through tests 01-04; a brief settle is sufficient.\n        time.sleep(2)\n        assert sandbox.is_healthy(), \"Sandbox should be healthy before pause\"\n\n        sandbox.pause()\n\n        poll_count = 0\n        final_status = None\n        while poll_count < 30:\n            time.sleep(1)\n            poll_count += 1\n            info = sandbox.get_info()\n            current_status = info.status\n            logger.info(\"Poll %s: Status = %s\", poll_count, current_status.state)\n            if current_status.state == \"Pausing\":\n                continue\n            final_status = current_status\n            break\n\n        assert final_status is not None\n        assert final_status.state == \"Paused\"\n\n        # Verify pause semantics: execd should be unreachable.\n        # The global HTTP request_timeout is 3 min, so we run the single\n        # is_healthy() call in a thread with a short timeout.  A paused\n        # container's frozen process will never reply, causing either a\n        # timeout (good) or an immediate connection refusal (also good).\n        # NOTE: shutdown(wait=False) so we don't block on the lingering\n        # HTTP request after our 15 s deadline.\n        pool = ThreadPoolExecutor(max_workers=1)\n        try:\n            healthy = pool.submit(sandbox.is_healthy).result(timeout=15)\n        except Exception:\n            healthy = False\n        finally:\n            pool.shutdown(wait=False)\n        assert healthy is False, \"Sandbox should be unhealthy after pause\"\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(7)\n    def test_06_sandbox_resume(self) -> None:\n        \"\"\"Test sandbox resume operation.\"\"\"\n        TestSandboxE2ESync._ensure_sandbox_created()\n        sandbox = TestSandboxE2ESync.sandbox\n        assert sandbox is not None\n\n        logger.info(\"=\" * 80)\n        logger.info(\"TEST 6: Testing sandbox resume operation (sync)\")\n        logger.info(\"=\" * 80)\n\n        resumed = SandboxSync.resume(\n            sandbox_id=sandbox.id,\n            connection_config=TestSandboxE2ESync.connection_config,\n        )\n        TestSandboxE2ESync.sandbox = resumed\n        sandbox = resumed\n\n        poll_count = 0\n        final_status = None\n        while poll_count < 60:\n            time.sleep(1)\n            poll_count += 1\n            info = sandbox.get_info()\n            current_status = info.status\n            logger.info(\"Poll %s: Status = %s\", poll_count, current_status.state)\n            if current_status.state == \"Running\":\n                final_status = current_status\n                break\n\n        assert final_status is not None\n        assert final_status.state == \"Running\"\n        healthy = False\n        for _ in range(30):\n            healthy = sandbox.is_healthy()\n            if healthy:\n                break\n            time.sleep(1)\n        assert healthy is True, \"Sandbox should be healthy after resume\"\n\n        # Minimal smoke check: after resume, the existing SandboxSync instance should still be usable.\n        echo = sandbox.commands.run(\"echo resume-ok\")\n        assert echo.error is None\n        assert len(echo.logs.stdout) == 1\n        assert echo.logs.stdout[0].text == \"resume-ok\"\n\n    @pytest.mark.timeout(120)\n    @pytest.mark.order(8)\n    def test_07_x_request_id_passthrough_on_server_error(self) -> None:\n        request_id = f\"e2e-py-sync-server-{int(time.time() * 1000)}\"\n        missing_sandbox_id = f\"missing-{request_id}\"\n        cfg = ConnectionConfigSync(\n            domain=TEST_DOMAIN,\n            api_key=TEST_API_KEY,\n            request_timeout=timedelta(minutes=3),\n            protocol=TEST_PROTOCOL,\n            headers={\"X-Request-ID\": request_id},\n            transport=httpx.HTTPTransport(\n                limits=httpx.Limits(\n                    max_connections=100,\n                    max_keepalive_connections=20,\n                    keepalive_expiry=15,\n                )\n            ),\n        )\n\n        try:\n            with pytest.raises(SandboxApiException) as ei:\n                connected = SandboxSync.connect(missing_sandbox_id, connection_config=cfg)\n                connected.get_info()\n            assert ei.value.request_id == request_id\n        finally:\n            cfg.transport.close()\n"
  },
  {
    "path": "tests/python/tests/test_sandbox_manager_e2e.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive E2E tests for SandboxManager functionality.\n\nFocus: Validate `list_sandbox_infos` filter semantics precisely:\n- `states` filter is OR logic\n- `metadata` filter is AND logic\n\nWe create 3 dedicated sandboxes per run to keep assertions deterministic.\n\"\"\"\n\nimport asyncio\nimport logging\nimport time\nfrom datetime import timedelta\nfrom uuid import uuid4\n\nimport pytest\nfrom opensandbox import Sandbox, SandboxManager\nfrom opensandbox.config import ConnectionConfig\nfrom opensandbox.models.sandboxes import (\n    SandboxFilter,\n    SandboxImageSpec,\n)\n\nfrom tests.base_e2e_test import create_connection_config, get_sandbox_image\n\nlogger = logging.getLogger(__name__)\n\n\nasync def _create_sandbox(\n    *,\n    connection_config: ConnectionConfig,\n    image: str,\n    metadata: dict[str, str],\n    env: dict[str, str],\n    timeout: timedelta,\n    ready_timeout: timedelta,\n) -> Sandbox:\n    return await Sandbox.create(\n        image=SandboxImageSpec(image),\n        connection_config=connection_config,\n        resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n        timeout=timeout,\n        ready_timeout=ready_timeout,\n        metadata=metadata,\n        env=env,\n        health_check_polling_interval=timedelta(milliseconds=500),\n    )\n\n\nasync def _wait_for_state(\n    *,\n    manager: SandboxManager,\n    sandbox_id,\n    expected_state: str,\n    timeout: timedelta = timedelta(minutes=3),\n) -> None:\n    deadline = time.time() + timeout.total_seconds()\n    last_state = None\n    while time.time() < deadline:\n        info = await manager.get_sandbox_info(sandbox_id)\n        last_state = info.status.state\n        if last_state == expected_state:\n            return\n        await asyncio.sleep(1)\n    raise AssertionError(f\"Timed out waiting for state={expected_state}, last_state={last_state}\")\n\n\n@pytest.mark.asyncio\nclass TestSandboxManagerE2E:\n    \"\"\"E2E tests for SandboxManager list/filter semantics.\"\"\"\n\n    connection_config: ConnectionConfig | None = None\n    manager: SandboxManager | None = None\n    tag: str | None = None\n    s1: Sandbox | None = None\n    s2: Sandbox | None = None\n    s3: Sandbox | None = None\n\n    @pytest.fixture(scope=\"class\", autouse=True)\n    async def _manager_setup(self, request):\n        cls = request.cls\n        # Create connection config (user-owned transport; we close it explicitly).\n        cls.connection_config = create_connection_config()\n\n        cls.manager = await SandboxManager.create(connection_config=cls.connection_config)\n        cls.tag = f\"e2e-sandbox-manager-{uuid4().hex[:8]}\"\n\n        # Create 3 sandboxes with controlled metadata.\n        # s1: tag + team=t1 + env=prod\n        # s2: tag + team=t1 + env=dev\n        # s3: tag + env=prod (no team), then pause to get Paused state\n        cls.s1 = await _create_sandbox(\n            connection_config=cls.connection_config,\n            image=get_sandbox_image(),\n            metadata={\"tag\": cls.tag, \"team\": \"t1\", \"env\": \"prod\"},\n            env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s1\"},\n            timeout=timedelta(minutes=5),\n            ready_timeout=timedelta(seconds=60),\n        )\n        cls.s2 = await _create_sandbox(\n            connection_config=cls.connection_config,\n            image=get_sandbox_image(),\n            metadata={\"tag\": cls.tag, \"team\": \"t1\", \"env\": \"dev\"},\n            env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s2\"},\n            timeout=timedelta(minutes=5),\n            ready_timeout=timedelta(seconds=60),\n        )\n        cls.s3 = await _create_sandbox(\n            connection_config=cls.connection_config,\n            image=get_sandbox_image(),\n            metadata={\"tag\": cls.tag, \"env\": \"prod\"},\n            env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s3\"},\n            timeout=timedelta(minutes=5),\n            ready_timeout=timedelta(seconds=60),\n        )\n\n        assert await cls.s1.is_healthy() is True\n        assert await cls.s2.is_healthy() is True\n        assert await cls.s3.is_healthy() is True\n\n        # Pause s3 to create a deterministic non-Running state for OR-state tests.\n        await cls.manager.pause_sandbox(cls.s3.id)\n        await _wait_for_state(manager=cls.manager, sandbox_id=cls.s3.id, expected_state=\"Paused\")\n\n        try:\n            yield\n        finally:\n            # Best-effort cleanup: kill sandboxes (remote) and close local resources.\n            for s in [cls.s1, cls.s2, cls.s3]:\n                if s is None:\n                    continue\n                try:\n                    await s.kill()\n                except Exception:\n                    pass\n                try:\n                    await s.close()\n                except Exception:\n                    pass\n\n            if cls.manager is not None:\n                try:\n                    await cls.manager.close()\n                except Exception:\n                    pass\n\n            if cls.connection_config is not None:\n                try:\n                    await cls.connection_config.transport.aclose()\n                except Exception:\n                    pass\n\n    @pytest.mark.timeout(600)\n    async def test_01_states_filter_or_logic(self):\n        manager = TestSandboxManagerE2E.manager\n        assert manager is not None\n        assert TestSandboxManagerE2E.tag is not None\n        assert TestSandboxManagerE2E.s1 is not None and TestSandboxManagerE2E.s2 is not None and TestSandboxManagerE2E.s3 is not None\n\n        # states filter is OR: should return sandboxes in ANY of the requested states.\n        result = await manager.list_sandbox_infos(\n            SandboxFilter(states=[\"Running\", \"Paused\"], metadata={\"tag\": TestSandboxManagerE2E.tag}, page_size=50)\n        )\n        ids = {info.id for info in result.sandbox_infos}\n        assert {TestSandboxManagerE2E.s1.id, TestSandboxManagerE2E.s2.id, TestSandboxManagerE2E.s3.id}.issubset(ids)\n\n        paused_only = await manager.list_sandbox_infos(\n            SandboxFilter(states=[\"Paused\"], metadata={\"tag\": TestSandboxManagerE2E.tag}, page_size=50)\n        )\n        paused_ids = {info.id for info in paused_only.sandbox_infos}\n        assert TestSandboxManagerE2E.s3.id in paused_ids\n        assert TestSandboxManagerE2E.s1.id not in paused_ids\n        assert TestSandboxManagerE2E.s2.id not in paused_ids\n\n        running_only = await manager.list_sandbox_infos(\n            SandboxFilter(states=[\"Running\"], metadata={\"tag\": TestSandboxManagerE2E.tag}, page_size=50)\n        )\n        running_ids = {info.id for info in running_only.sandbox_infos}\n        assert TestSandboxManagerE2E.s1.id in running_ids\n        assert TestSandboxManagerE2E.s2.id in running_ids\n        assert TestSandboxManagerE2E.s3.id not in running_ids\n\n    @pytest.mark.timeout(600)\n    async def test_02_metadata_filter_and_logic(self):\n        manager = TestSandboxManagerE2E.manager\n        assert manager is not None\n        assert TestSandboxManagerE2E.tag is not None\n        assert TestSandboxManagerE2E.s1 is not None and TestSandboxManagerE2E.s2 is not None and TestSandboxManagerE2E.s3 is not None\n\n        # metadata filter is AND across all key-value pairs.\n        # tag+team=t1 should match s1 and s2 (both have team=t1), not s3.\n        tag_and_team = await manager.list_sandbox_infos(\n            SandboxFilter(metadata={\"tag\": TestSandboxManagerE2E.tag, \"team\": \"t1\"}, page_size=50)\n        )\n        ids = {info.id for info in tag_and_team.sandbox_infos}\n        assert TestSandboxManagerE2E.s1.id in ids\n        assert TestSandboxManagerE2E.s2.id in ids\n        assert TestSandboxManagerE2E.s3.id not in ids\n\n        # tag+team=t1+env=prod should match only s1 (AND narrows results).\n        tag_team_env = await manager.list_sandbox_infos(\n            SandboxFilter(metadata={\"tag\": TestSandboxManagerE2E.tag, \"team\": \"t1\", \"env\": \"prod\"}, page_size=50)\n        )\n        ids = {info.id for info in tag_team_env.sandbox_infos}\n        assert TestSandboxManagerE2E.s1.id in ids\n        assert TestSandboxManagerE2E.s2.id not in ids\n        assert TestSandboxManagerE2E.s3.id not in ids\n\n        # tag+env=prod should match s1 and s3.\n        tag_env = await manager.list_sandbox_infos(\n            SandboxFilter(metadata={\"tag\": TestSandboxManagerE2E.tag, \"env\": \"prod\"}, page_size=50)\n        )\n        ids = {info.id for info in tag_env.sandbox_infos}\n        assert TestSandboxManagerE2E.s1.id in ids\n        assert TestSandboxManagerE2E.s3.id in ids\n        assert TestSandboxManagerE2E.s2.id not in ids\n\n        # Negative: tag+team=t2 should match none.\n        none_match = await manager.list_sandbox_infos(\n            SandboxFilter(metadata={\"tag\": TestSandboxManagerE2E.tag, \"team\": \"t2\"}, page_size=50)\n        )\n        assert all(\n            info.id not in {TestSandboxManagerE2E.s1.id, TestSandboxManagerE2E.s2.id, TestSandboxManagerE2E.s3.id}\n            for info in none_match.sandbox_infos\n        )\n"
  },
  {
    "path": "tests/python/tests/test_sandbox_manager_e2e_sync.py",
    "content": "#\n# Copyright 2025 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\"\"\"\nComprehensive Sync E2E tests for SandboxManagerSync functionality.\n\nFocus: Validate `list_sandbox_infos` filter semantics precisely:\n- `states` filter is OR logic\n- `metadata` filter is AND logic\n\nWe create 3 dedicated sandboxes per run to keep assertions deterministic.\n\"\"\"\n\nimport time\nfrom datetime import timedelta\nfrom uuid import uuid4\n\nimport pytest\nfrom opensandbox import SandboxManagerSync, SandboxSync\nfrom opensandbox.models.sandboxes import (\n    SandboxFilter,\n    SandboxImageSpec,\n)\n\nfrom tests.base_e2e_test import create_connection_config_sync, get_sandbox_image\n\n\nclass TestSandboxManagerE2ESync:\n    @pytest.mark.timeout(600)\n    def test_01_states_filter_or_logic(self):\n        cfg = create_connection_config_sync()\n\n        manager = SandboxManagerSync.create(connection_config=cfg)\n        tag = f\"e2e-sandbox-manager-{uuid4().hex[:8]}\"\n\n        s1 = s2 = s3 = None\n        try:\n            s1 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"team\": \"t1\", \"env\": \"prod\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s1\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n            s2 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"team\": \"t1\", \"env\": \"dev\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s2\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n            s3 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"env\": \"prod\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s3\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n\n            assert s1.is_healthy() is True\n            assert s2.is_healthy() is True\n            assert s3.is_healthy() is True\n\n            # Pause s3 and wait for state transition\n            manager.pause_sandbox(s3.id)\n            deadline = time.time() + 180\n            while time.time() < deadline:\n                info = manager.get_sandbox_info(s3.id)\n                if info.status.state == \"Paused\":\n                    break\n                time.sleep(1)\n            assert manager.get_sandbox_info(s3.id).status.state == \"Paused\"\n\n            # OR states\n            both = manager.list_sandbox_infos(\n                SandboxFilter(states=[\"Running\", \"Paused\"], metadata={\"tag\": tag}, page_size=50)\n            )\n            ids = {info.id for info in both.sandbox_infos}\n            assert {s1.id, s2.id, s3.id}.issubset(ids)\n\n            paused_only = manager.list_sandbox_infos(\n                SandboxFilter(states=[\"Paused\"], metadata={\"tag\": tag}, page_size=50)\n            )\n            paused_ids = {info.id for info in paused_only.sandbox_infos}\n            assert s3.id in paused_ids\n            assert s1.id not in paused_ids\n            assert s2.id not in paused_ids\n\n            running_only = manager.list_sandbox_infos(\n                SandboxFilter(states=[\"Running\"], metadata={\"tag\": tag}, page_size=50)\n            )\n            running_ids = {info.id for info in running_only.sandbox_infos}\n            assert s1.id in running_ids\n            assert s2.id in running_ids\n            assert s3.id not in running_ids\n        finally:\n            for s in [s1, s2, s3]:\n                if s is None:\n                    continue\n                try:\n                    s.kill()\n                except Exception:\n                    pass\n                try:\n                    s.close()\n                except Exception:\n                    pass\n            manager.close()\n\n    @pytest.mark.timeout(600)\n    def test_02_metadata_filter_and_logic(self):\n        cfg = create_connection_config_sync()\n\n        manager = SandboxManagerSync.create(connection_config=cfg)\n        tag = f\"e2e-sandbox-manager-{uuid4().hex[:8]}\"\n\n        s1 = s2 = s3 = None\n        try:\n            s1 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"team\": \"t1\", \"env\": \"prod\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s1\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n            s2 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"team\": \"t1\", \"env\": \"dev\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s2\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n            s3 = SandboxSync.create(\n                image=SandboxImageSpec(get_sandbox_image()),\n                connection_config=cfg,\n                resource={\"cpu\": \"1\", \"memory\": \"2Gi\"},\n                timeout=timedelta(minutes=5),\n                ready_timeout=timedelta(seconds=60),\n                metadata={\"tag\": tag, \"env\": \"prod\"},\n                env={\"E2E_TEST\": \"true\", \"CASE\": \"mgr-s3\"},\n                health_check_polling_interval=timedelta(milliseconds=500),\n            )\n\n            assert s1.is_healthy() is True\n            assert s2.is_healthy() is True\n            assert s3.is_healthy() is True\n\n            # AND metadata\n            tag_and_team = manager.list_sandbox_infos(\n                SandboxFilter(metadata={\"tag\": tag, \"team\": \"t1\"}, page_size=50)\n            )\n            ids = {info.id for info in tag_and_team.sandbox_infos}\n            assert s1.id in ids\n            assert s2.id in ids\n            assert s3.id not in ids\n\n            tag_team_env = manager.list_sandbox_infos(\n                SandboxFilter(metadata={\"tag\": tag, \"team\": \"t1\", \"env\": \"prod\"}, page_size=50)\n            )\n            ids = {info.id for info in tag_team_env.sandbox_infos}\n            assert s1.id in ids\n            assert s2.id not in ids\n            assert s3.id not in ids\n\n            tag_env = manager.list_sandbox_infos(\n                SandboxFilter(metadata={\"tag\": tag, \"env\": \"prod\"}, page_size=50)\n            )\n            ids = {info.id for info in tag_env.sandbox_infos}\n            assert s1.id in ids\n            assert s3.id in ids\n            assert s2.id not in ids\n\n            none_match = manager.list_sandbox_infos(\n                SandboxFilter(metadata={\"tag\": tag, \"team\": \"t2\"}, page_size=50)\n            )\n            assert all(info.id not in {s1.id, s2.id, s3.id} for info in none_match.sandbox_infos)\n        finally:\n            for s in [s1, s2, s3]:\n                if s is None:\n                    continue\n                try:\n                    s.kill()\n                except Exception:\n                    pass\n                try:\n                    s.close()\n                except Exception:\n                    pass\n            manager.close()\n"
  }
]