diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a6e4f69
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+.git
+.github
+.codex
+dist
+bin
+stacyvm
+stacyvm-agent
+checksums.txt
+
+web/node_modules
+web/dist
+sdk/js/node_modules
+
+images/evm/contracts/lib
+images/evm/frontend/node_modules
+images/evm/frontend/.next
+
+*.log
+*.db
+*.db-shm
+*.db-wal
+.env
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..8c1c3a4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,71 @@
+name: Bug report
+description: Report a reproducible StacyVM bug with the evidence needed for maintainers to triage it.
+title: "[Bug]: "
+labels: ["bug", "needs-triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report this. For public self-serve installs, attach the support evidence below whenever it applies. Redact anything you cannot share publicly.
+ - type: textarea
+ id: summary
+ attributes:
+ label: What happened?
+ description: Describe the observed behavior and the expected behavior.
+ validations:
+ required: true
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Reproduction steps
+ description: Include commands, API calls, SDK snippets, or config changes.
+ placeholder: |
+ 1. Run ...
+ 2. Call ...
+ 3. Observe ...
+ validations:
+ required: true
+ - type: dropdown
+ id: install_mode
+ attributes:
+ label: Install mode
+ options:
+ - Local/mock provider
+ - Single-node Docker/runc
+ - Docker with gVisor
+ - Docker with Kata
+ - Firecracker
+ - PRoot
+ - E2B/custom provider
+ - Multi-worker/enterprise prototype
+ validations:
+ required: true
+ - type: textarea
+ id: support_evidence
+ attributes:
+ label: Support evidence
+ description: Attach or paste the relevant command output. Use fenced code blocks for logs.
+ value: |
+ - `stacyvm support bundle --output support.json`:
+ - `stacyvm config lint --production --file `:
+ - `stacyvm upgrade rehearse --config --database `:
+ - `stacyvm doctor --production`:
+ - `scripts/certify-runtime.sh --format markdown --output -certification.md`:
+ - `scripts/verify-release.sh ` or installer verification output:
+ validations:
+ required: false
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment
+ description: Include OS, architecture, StacyVM version/commit, provider runtime version, Docker/Firecracker/gVisor/Kata/PRoot version, and whether this is CI or a host install.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs
+ description: Paste redacted server logs, SDK stack traces, or diagnostics JSON.
+ render: shell
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..d79a77e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Public support matrix
+ url: https://github.com/StacyOS/stacyvm/blob/main/docs/public-support-matrix.md
+ about: Check supported install modes and required support evidence before opening an issue.
+ - name: Production readiness checklist
+ url: https://github.com/StacyOS/stacyvm/blob/main/docs/production-readiness.md
+ about: Review the current production-readiness gates and known platform requirements.
diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml
new file mode 100644
index 0000000..5dee663
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/support_request.yml
@@ -0,0 +1,63 @@
+name: Production support request
+description: Ask for help with a public self-serve or production-style StacyVM install.
+title: "[Support]: "
+labels: ["support", "needs-triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Use this form for install, upgrade, runtime certification, and production-readiness help. The public support matrix explains which modes are supported, host-certified, preview, experimental, or planned.
+ - type: dropdown
+ id: support_level
+ attributes:
+ label: Runtime or deployment mode
+ options:
+ - Single-node Docker/runc
+ - Docker with gVisor
+ - Docker with Kata
+ - Firecracker
+ - PRoot
+ - E2B/custom provider
+ - Multi-worker/enterprise prototype
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ id: goal
+ attributes:
+ label: What are you trying to do?
+ description: Describe the target setup, workload, and what production-ready means for your environment.
+ validations:
+ required: true
+ - type: checkboxes
+ id: evidence_checklist
+ attributes:
+ label: Evidence checklist
+ options:
+ - label: I ran release or installer verification.
+ - label: I ran production config lint with the same environment the service uses.
+ - label: I ran upgrade rehearsal before changing binaries or images.
+ - label: I ran `stacyvm doctor --production` on the target host.
+ - label: I generated a redacted support bundle.
+ - label: I generated runtime certification output for host-certified runtimes.
+ - type: textarea
+ id: evidence
+ attributes:
+ label: Evidence output
+ description: Paste or attach redacted output for every checked item above.
+ value: |
+ - Release/install verification:
+ - Config lint:
+ - Upgrade rehearsal:
+ - Doctor:
+ - Support bundle:
+ - Runtime certification:
+ validations:
+ required: true
+ - type: textarea
+ id: blocker
+ attributes:
+ label: Current blocker
+ description: What is preventing you from proceeding?
+ validations:
+ required: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b6e4804
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,289 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - 'phase-*'
+ - 'feat/*'
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ go:
+ name: Go tests and build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run Go tests
+ run: make test
+
+ - name: Build CLI
+ run: make build
+
+ swagger:
+ name: Swagger drift
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Check generated Swagger docs
+ run: scripts/check-swagger.sh
+
+ deployment-smoke:
+ name: Deployment smoke
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build CLI
+ run: make build
+
+ - name: Run mock-provider deployment smoke
+ run: scripts/ci-smoke-deployment.sh
+
+ upgrade-migration:
+ name: Upgrade and migration checks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run upgrade and migration checks
+ run: scripts/ci-upgrade-migration.sh
+
+ cluster-conformance:
+ name: Cluster conformance checks
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: stacyvm
+ POSTGRES_PASSWORD: stacyvm
+ POSTGRES_DB: stacyvm
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U stacyvm -d stacyvm"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run cluster conformance checks
+ env:
+ STACYVM_POSTGRES_TEST_DSN: postgres://stacyvm:stacyvm@127.0.0.1:5432/stacyvm?sslmode=disable
+ run: scripts/ci-cluster-conformance.sh
+
+ remote-worker-postgres-smoke:
+ name: Remote worker Postgres smoke
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: stacyvm
+ POSTGRES_PASSWORD: stacyvm
+ POSTGRES_DB: stacyvm
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U stacyvm -d stacyvm"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build CLI
+ run: make build
+
+ - name: Run remote worker smoke with Postgres store
+ env:
+ STACYVM_REMOTE_SMOKE_DATABASE_DRIVER: postgres
+ STACYVM_REMOTE_SMOKE_DATABASE_DSN: postgres://stacyvm:stacyvm@127.0.0.1:5432/stacyvm?sslmode=disable
+ run: scripts/smoke-remote-worker.sh ./stacyvm
+
+ remote-worker-mtls-smoke:
+ name: Remote worker mTLS smoke
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build CLI
+ run: make build
+
+ - name: Run remote worker mTLS smoke with ephemeral certs
+ run: scripts/smoke-remote-worker.sh ./stacyvm --mtls
+
+ runtime-certification:
+ name: Runtime certification (Docker)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Build CLI
+ run: make build
+
+ - name: Run Docker runtime certification with StacyVM integration smoke
+ run: |
+ scripts/certify-runtime.sh docker \
+ --stacyvm-bin ./stacyvm \
+ --format markdown \
+ --output /tmp/docker-certification.md
+ cat /tmp/docker-certification.md
+
+ - name: Upload certification report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: docker-runtime-certification
+ path: /tmp/docker-certification.md
+
+ public-release-sanity:
+ name: Public release sanity
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Run public release sanity checks
+ run: scripts/ci-public-release-sanity.sh
+
+ web:
+ name: Web build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build web app
+ run: npm run build
+
+ sdk-js:
+ name: TypeScript SDK build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: sdk/js
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build TypeScript SDK
+ run: bun run build
+
+ - name: Run TypeScript SDK parity tests
+ run: bun test
+
+ sdk-python:
+ name: Python SDK tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install Python SDK
+ run: python -m pip install -e sdk/python
+
+ - name: Compile Python SDK
+ run: python -m compileall sdk/python/stacyvm
+
+ - name: Import Python SDK
+ run: python -c "import stacyvm; print(stacyvm.__version__)"
+
+ - name: Run Python SDK parity tests
+ run: python -m unittest sdk/python/tests/test_client_parity.py
diff --git a/.github/workflows/public-readiness-certification.yml b/.github/workflows/public-readiness-certification.yml
new file mode 100644
index 0000000..ebdf987
--- /dev/null
+++ b/.github/workflows/public-readiness-certification.yml
@@ -0,0 +1,86 @@
+name: Public Readiness Certification
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Published release tag to certify, for example v0.14.3'
+ required: true
+ type: string
+ runtime:
+ description: 'Runtime to certify on the GitHub-hosted Linux runner'
+ required: true
+ default: docker
+ type: choice
+ options:
+ - docker
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+permissions:
+ contents: read
+
+jobs:
+ public-readiness-certification:
+ name: Certify published release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Validate published release assets and Linux installer
+ env:
+ GH_TOKEN: ${{ github.token }}
+ STACYVM_VALIDATE_INSTALLER: "true"
+ run: scripts/post-release-validate.sh "${{ inputs.version }}"
+
+ - name: Download published StacyVM binary
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ curl -fsSL -o stacyvm-linux-amd64 \
+ "https://github.com/StacyOS/stacyvm/releases/download/${VERSION}/stacyvm-linux-amd64"
+ chmod +x stacyvm-linux-amd64
+
+ - name: Certify runtime with StacyVM integration smoke
+ env:
+ STACYVM_RUNTIME: ${{ inputs.runtime }}
+ run: |
+ mkdir -p public-readiness-evidence
+ scripts/certify-runtime.sh "$STACYVM_RUNTIME" \
+ --format markdown \
+ --output "public-readiness-evidence/${STACYVM_RUNTIME}-certification.md" \
+ --stacyvm-bin "./stacyvm-linux-amd64"
+
+ - name: Write certification summary
+ env:
+ VERSION: ${{ inputs.version }}
+ STACYVM_RUNTIME: ${{ inputs.runtime }}
+ run: |
+ {
+ echo "# Public Readiness Certification"
+ echo
+ echo "- Version: \`${VERSION}\`"
+ echo "- Runtime: \`${STACYVM_RUNTIME}\`"
+ echo "- Release validation: \`PASS\`"
+ echo "- Installer verify-only: \`PASS\`"
+ echo "- Runtime certification: \`PASS\`"
+ echo "- Evidence generated at: \`$(date -u '+%Y-%m-%dT%H:%M:%SZ')\`"
+ echo
+ echo "## Runtime Report"
+ echo
+ cat "public-readiness-evidence/${STACYVM_RUNTIME}-certification.md"
+ } > public-readiness-evidence/public-readiness-certification.md
+
+ cat public-readiness-evidence/public-readiness-certification.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload public readiness evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: public-readiness-evidence-${{ inputs.version }}-${{ inputs.runtime }}
+ path: public-readiness-evidence/*
+ if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..5ae1b5b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,215 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Release version or image tag, for example v0.4.0'
+ required: true
+ type: string
+ publish_image:
+ description: 'Publish the container image to GHCR'
+ required: true
+ default: true
+ type: boolean
+ create_release:
+ description: 'Create a GitHub release with binary artifacts'
+ required: true
+ default: false
+ type: boolean
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ IMAGE_NAME: ghcr.io/stacyos/stacyvm
+
+permissions:
+ actions: read
+ contents: write
+ packages: write
+ id-token: write
+
+jobs:
+ release-artifacts:
+ name: Build release artifacts
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Resolve release version
+ id: version
+ shell: bash
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build binaries
+ run: make release-build-all VERSION="${{ steps.version.outputs.value }}"
+
+ - name: Install cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Sign release artifacts
+ shell: bash
+ run: |
+ for artifact in dist/*; do
+ cosign sign-blob --yes \
+ --output-signature "${artifact}.sig" \
+ --output-certificate "${artifact}.pem" \
+ "${artifact}"
+ done
+
+ - name: Upload release artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: stacyvm-release-${{ steps.version.outputs.value }}
+ path: dist/*
+ if-no-files-found: error
+
+ - name: Create GitHub release
+ if: startsWith(github.ref, 'refs/tags/') || inputs.create_release == true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "${{ steps.version.outputs.value }}" dist/* \
+ --title "${{ steps.version.outputs.value }}" \
+ --generate-notes
+
+ container-image:
+ name: Publish container image
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish_image == true
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Resolve image version
+ id: version
+ shell: bash
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ echo "value=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract image metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=${{ steps.version.outputs.value }}
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+
+ - name: Build and publish image
+ id: build
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64
+ build-args: |
+ VERSION=${{ steps.version.outputs.value }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Install cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Sign published image digest
+ run: cosign sign --yes "${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
+
+ public-readiness-certification:
+ name: Public readiness certification
+ needs:
+ - release-artifacts
+ - container-image
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install cosign
+ uses: sigstore/cosign-installer@v3
+
+ - name: Resolve release version
+ id: version
+ shell: bash
+ run: echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate published release assets and Linux installer
+ env:
+ GH_TOKEN: ${{ github.token }}
+ STACYVM_VALIDATE_INSTALLER: "true"
+ run: scripts/post-release-validate.sh "${{ steps.version.outputs.value }}"
+
+ - name: Download published StacyVM binary
+ run: |
+ curl -fsSL -o stacyvm-linux-amd64 \
+ "https://github.com/StacyOS/stacyvm/releases/download/${{ steps.version.outputs.value }}/stacyvm-linux-amd64"
+ chmod +x stacyvm-linux-amd64
+
+ - name: Certify Docker runtime with StacyVM integration smoke
+ run: |
+ mkdir -p public-readiness-evidence
+ scripts/certify-runtime.sh docker \
+ --format markdown \
+ --output public-readiness-evidence/docker-certification.md \
+ --stacyvm-bin ./stacyvm-linux-amd64
+
+ - name: Write certification summary
+ run: |
+ {
+ echo "# Public Readiness Certification"
+ echo
+ echo "- Version: \`${{ steps.version.outputs.value }}\`"
+ echo "- Runtime: \`docker\`"
+ echo "- Release validation: \`PASS\`"
+ echo "- Installer verify-only: \`PASS\`"
+ echo "- Runtime certification: \`PASS\`"
+ echo "- Evidence generated at: \`$(date -u '+%Y-%m-%dT%H:%M:%SZ')\`"
+ echo
+ echo "## Runtime Report"
+ echo
+ cat public-readiness-evidence/docker-certification.md
+ } > public-readiness-evidence/public-readiness-certification.md
+
+ cat public-readiness-evidence/public-readiness-certification.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload public readiness evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: public-readiness-evidence-${{ steps.version.outputs.value }}-docker
+ path: public-readiness-evidence/*
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 72bbb2d..f731b52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,6 +52,14 @@ web/dist/
.env
.env.local
+# TLS certificates and private keys — never commit real certs or keys to the repo
+*.pem
+*.key
+*.crt
+*.p12
+*.pfx
+*.csr
+
# Coverage
coverage/
*.cover
diff --git a/.mintignore b/.mintignore
new file mode 100644
index 0000000..5a8cbbc
--- /dev/null
+++ b/.mintignore
@@ -0,0 +1,20 @@
+node_modules
+.git
+.github
+.codex
+cmd
+deploy
+dist
+tmp
+coverage
+examples
+images
+internal
+scripts
+sdk
+tests
+tui
+web
+*.db
+*.log
+docs/docs.go
diff --git a/.mintlify/Assistant.md b/.mintlify/Assistant.md
new file mode 100644
index 0000000..9e7b73c
--- /dev/null
+++ b/.mintlify/Assistant.md
@@ -0,0 +1,25 @@
+You are the StacyVM documentation assistant.
+
+## Tone
+
+- Be concise, technical, and direct.
+- Assume readers are developers or infrastructure operators.
+- Prefer runnable commands and links to relevant docs pages.
+
+## Product Context
+
+StacyVM is self-hosted execution infrastructure for autonomous software systems.
+It provides isolated sandbox runtimes through Docker, Firecracker, PRoot, and remote workers.
+
+## Support Boundaries
+
+- Treat Docker public self-serve support as certified only for the published release path that has public readiness certification evidence.
+- Do not claim Firecracker, gVisor, Kata, or PRoot are production-certified unless the user has host-level runtime certification output for that runtime.
+- For production questions, point users to the production readiness, deployment, runtime certification, and public support matrix pages.
+
+## Terminology
+
+- Use "sandbox" for an isolated execution environment.
+- Use "provider" for Docker, Firecracker, PRoot, mock, or remote worker runtime implementations.
+- Use "runtime certification" for host-level proof generated by `scripts/certify-runtime.sh`.
+- Use "public readiness certification" for GitHub release validation plus Docker runtime smoke evidence.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..bc3cfd7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,509 @@
+# Changelog
+
+## Phase 14 Public-Readiness Repair - 2026-05-10
+
+Security fixes required before public launch. No new features.
+
+### Fixed (security)
+
+- **RS256 JWT verification used `hash=0` instead of `crypto.SHA256`** — tokens from real OIDC providers (Google Workspace, Okta, Azure AD, Cloudflare Access) were rejected because they sign with SHA256, but verification was using `rsa.VerifyPKCS1v15(key, 0, ...)`. Fixed to `rsa.VerifyPKCS1v15(key, crypto.SHA256, ...)`. Tests updated to sign with `crypto.SHA256` and a new regression test `TestVerifyJWT_RS256UsesSHA256` proves hash=0 signatures are rejected.
+- **Admin routes unprotected in OIDC-only mode** — when no `auth.admin_api_key` was configured and only OIDC was set, `AdminAuth` passed anonymous requests through silently (the `adminAPIKey == ""` branch fell through) and `RequireScope(ScopeAdmin)` was not applied. Fixed: `RequireScope(ScopeAdmin)` now fires whenever any auth is configured (`authConfigured`), not just when API keys are set. `AdminAuth` now correctly passes through only OIDC-established identities (Bearer token header) so API-key role promotion still works.
+- **`AuthAny` rejected valid OIDC tokens in mixed mode** — when both OIDC and API keys were configured, a valid Bearer token was accepted by `OIDCAuth` but then rejected by `AuthAny` (which always looked for an API key header). Fixed: `AuthAny` now skips its check when OIDC already set an identity via `Authorization` header.
+- **`PolicyEnforcer` was never mounted** — policy CRUD routes existed and policies could be created, but they were never evaluated on sandbox creation. Fixed: `PolicyEnforcer(st)` is now applied around `POST /api/v1/sandboxes` when a policy store is wired in.
+- **Worker token issuer accepted non-worker scopes** — a caller could request `admin:*` in a worker token. The scope was silently filtered at verification, but the issuer should reject it outright. Fixed: `POST /api/v1/admin/worker-tokens` now explicitly validates that all requested scopes begin with `worker:` and returns 400 if any non-worker scope is present.
+
+### Added
+
+- **Auth matrix regression tests** (`internal/api/auth_matrix_test.go`) — 7 tests covering API-key only, OIDC only (normal route, viewer-cannot-spawn, admin route blocked for anonymous/non-admin/admin), mixed OIDC+API-key, worker token not treated as Bearer, and token issuer scope rejection.
+- `TestVerifyJWT_RS256UsesSHA256` — explicitly proves the verifier uses `crypto.SHA256` and rejects hash=0 signatures.
+
+---
+
+## Phase 14 Enterprise Governance - 2026-05-10
+
+This release closes the enterprise production readiness gates: OIDC/SSO, RBAC, multi-tenancy, policy controls, and HA event durability.
+
+### Added
+
+- **OIDC/JWT authentication** — RS256 and ES256/ES384/ES512 Bearer token validation with configurable JWKS URL, static PEM public key (RSA or EC), issuer, audience, and clock-skew tolerance. Config keys: `auth.oidc_enabled`, `auth.oidc_issuer`, `auth.oidc_jwks_url`, `auth.oidc_public_key_file`.
+- **RBAC roles** — `viewer` (read-only), `operator` (spawn/exec/files), `tenant_admin` (per-tenant admin) beyond existing `api` and `admin`. OIDC group-to-role mapping via `auth.oidc_admin_groups`, `auth.oidc_operator_groups`, `auth.oidc_viewer_groups`.
+- **RBAC scope enforcement on sandbox routes** — read operations require `read:*`, mutating operations require `api:*`. Viewer-role users cannot spawn, exec, or destroy sandboxes.
+- **Tenant/project model** — `tenants`, `tenant_members`, `policies` tables (migration 11). `tenant_id` on sandboxes, admin audit logs, and operation audit logs. Tenant CRUD, per-tenant member RBAC, per-tenant audit export, and per-tenant policy management via `/api/v1/admin/tenants`.
+- **Policy controls** — per-tenant allow/deny rules for `image`, `provider`, and `network` resources with glob pattern matching and priority ordering. Policy enforcement runs at spawn time without consuming the request body.
+- **Sandbox tenant scoping** — List, Get, Destroy, Exec, file, and log operations enforce tenant boundaries for OIDC-authenticated callers.
+- **Centralized worker token issuer** — `POST /api/v1/admin/worker-tokens` mints short-lived signed worker tokens so workers do not need direct access to `auth.worker_signing_key`. Workers can call the issuer via `--bootstrap-admin-key`.
+- **Durable EventBus (HA)** — When `database.driver` is `postgres`, a Postgres LISTEN/NOTIFY bridge propagates lifecycle events across control-plane replicas. Each bridge stamps events with an instance UUID to prevent double-delivery on the originating replica.
+- **Postgres backup and rehearsal** — `stacyvm db pg-backup
@@ -23,7 +23,7 @@ Self-hosted. Single binary. Python & TypeScript SDKs. MIT licensed. No cloud
-
+
@@ -33,7 +33,8 @@ Self-hosted. Single binary. Python & TypeScript SDKs. MIT licensed. No cloud
Providers •
Live Preview •
Pool Mode •
- API Reference •
+ Deployment •
+ API Reference •
Contributing
@@ -56,6 +57,11 @@ Self-hosted. Single binary. Python & TypeScript SDKs. MIT licensed. No cloud
- [Providers, pool, system](#providers-pool-system)
- [CLI](#cli)
- [Configuration](#configuration)
+- [Enterprise / Multi-worker](#enterprise--multi-worker)
+ - [OIDC & RBAC](#oidc--rbac)
+ - [Multi-tenancy & policy controls](#multi-tenancy--policy-controls)
+ - [Remote workers & mTLS](#remote-workers--mtls)
+- [Production deployment](#production-deployment)
- [Templates](#templates-1)
- [Security defaults](#security-defaults)
- [Architecture](#architecture)
@@ -130,7 +136,7 @@ You're building an AI agent. It generates code. That code needs to run somewhere
| File API (read/write/glob) | ✅ 9 methods | ✅ | ❌ | ❌ | ❌ | ❌ |
| Python + TS SDKs | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| Your data stays local | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
-| License | MIT | Partial | Apache 2.0 | Apache 2.0 | Proprietary | N/A |
+| License | Apache 2.0 | Partial | Apache 2.0 | Apache 2.0 | Proprietary | N/A |
> **On speed:** Zeroboot's 0.8ms is real — they bypass Firecracker's VMM entirely and `mmap(MAP_PRIVATE)` the snapshot memory as copy-on-write. But there's no disk, no network, and I/O is serial UART only. StacyVM's 28ms gives you a full sandbox with networking, file system, virtio, and multi-vCPU. Different tools for different jobs.
@@ -260,6 +266,8 @@ client = Client("http://localhost:7423", user_id="alice@example.com")
const client = new Client({ baseUrl: "http://localhost:7423", userId: "alice@example.com" });
```
+User IDs are trimmed by the server. They must be 128 characters or fewer and cannot contain whitespace, control characters, or path separators.
+
Hardening knobs (Docker provider):
```yaml
@@ -373,6 +381,7 @@ Auth: pass `X-API-Key: ` if `auth.enabled: true`. For pool mode, also
| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/sandboxes` | Spawn a sandbox |
+| `POST` | `/sandboxes/admission` | Preflight quota and scheduler admission |
| `GET` | `/sandboxes` | List active sandboxes |
| `DELETE` | `/sandboxes` | Prune expired sandboxes |
| `GET` | `/sandboxes/{id}` | Get sandbox details |
@@ -382,6 +391,8 @@ Auth: pass `X-API-Key: ` if `auth.enabled: true`. For pool mode, also
| `GET` | `/sandboxes/{id}/exec/ws` | Execute over WebSocket |
| `GET` | `/sandboxes/{id}/logs` | Console logs |
+Exec requests default to backwards-compatible shell mode. Set `mode: "argv"` with `args` to run direct process arguments without shell interpolation.
+
### Files (per sandbox)
| Method | Endpoint | Description |
@@ -413,13 +424,27 @@ Auth: pass `X-API-Key: ` if `auth.enabled: true`. For pool mode, also
| `GET` | `/providers` | List configured providers |
| `GET` | `/providers/{name}` | Provider details + sandbox count |
| `POST` | `/providers/test` | Health-check all providers |
+| `GET` | `/quotas` | List owner quota overrides |
+| `GET` | `/quotas/summary` | Redacted owner quota policy counts |
+| `PUT` | `/quotas/{ownerID}` | Create or update owner quota |
+| `GET` | `/quotas/{ownerID}/usage` | Owner usage against effective quota |
+| `GET` | `/workers` | List registered workers and heartbeat state |
+| `GET` | `/workers/{workerID}` | Get one worker registry record |
+| `POST` | `/worker/{workerID}/heartbeat` | Remote worker heartbeat with worker token |
+| `POST` | `/worker/{workerID}/leases/{resourceID}/renew` | Remote worker lease renewal |
| `GET` | `/pool/status` | Pool VM and user counts |
| `GET` | `/snapshots` | Available VM snapshots |
| `GET` | `/health` | Health check |
+| `GET` | `/ready` | Readiness check |
+| `GET` | `/diagnostics` | Redacted operational diagnostics |
| `GET` | `/metrics` | Runtime metrics (goroutines, alloc, sandbox counts) |
+| `GET` | `/metrics/prometheus` | Prometheus-compatible metrics |
| `GET` | `/events` | Server-sent events stream |
-Full schemas, request/response examples, and error codes: **[docs/api.md](docs/api.md)**.
+Admin aliases for providers, quotas, workers, diagnostics, and metrics are available under `/admin/*` and can be protected with `auth.admin_api_key`. Worker registry deletion remains admin-only under `/admin/workers/*`; remote worker heartbeat and lease renewal use worker-only `/worker/*` endpoints.
+For the operator dashboard, quota workflows, diagnostics, and persisted admin audit history, see [docs/admin-control-plane.md](docs/admin-control-plane.md).
+
+Full schemas, request/response examples, and error codes: **[docs/rest-api.md](docs/rest-api.md)**.
OpenAPI spec: [docs/swagger.yaml](docs/swagger.yaml).
---
@@ -428,11 +453,18 @@ OpenAPI spec: [docs/swagger.yaml](docs/swagger.yaml).
```bash
stacyvm serve # start the API server
+stacyvm worker --once # send one remote-worker heartbeat
+stacyvm worker --listen 127.0.0.1:7430 # heartbeat + worker RPC server
stacyvm spawn --image python:3.12 --ttl 1h # spawn
-stacyvm exec sb-a1b2c3d4 -- python3 app.py # run a command in a sandbox
+stacyvm exec sb-a1b2c3d4 -- python3 app.py # run argv mode in a sandbox
+stacyvm exec sb-a1b2c3d4 --shell -- "echo $HOME && pwd"
stacyvm list # list active sandboxes
stacyvm kill sb-a1b2c3d4 # destroy
stacyvm build-image python:3.12 # pre-build rootfs (Firecracker)
+stacyvm config lint --production # production config lint gate
+stacyvm db backup /backup/stacyvm.db # consistent SQLite backup
+stacyvm upgrade rehearse --database stacyvm.db # rehearse single-node upgrade
+stacyvm support bundle support.json # redacted support diagnostics
stacyvm tui # interactive dashboard
stacyvm version # version info
```
@@ -451,6 +483,24 @@ server:
host: "0.0.0.0"
port: 7423
preview_domain: "localhost" # used to build live-preview URLs
+ cors_allowed_origins: ["*"] # set explicit https:// origins in production
+
+worker:
+ id: "" # defaults to hostname
+ control_plane_url: "http://localhost:7423"
+ listen_addr: "" # set to enable inbound worker RPC
+ heartbeat_interval: "30s"
+ shutdown_timeout: "10s"
+ rpc_tls:
+ enabled: false
+ server_cert_file: ""
+ server_key_file: ""
+ client_ca_file: ""
+ ca_file: ""
+ client_cert_file: ""
+ client_key_file: ""
+ server_name: ""
+ insecure_skip_verify: false
providers:
default: "docker"
@@ -497,13 +547,53 @@ defaults:
image: "alpine:latest"
memory_mb: 1024
vcpus: 1
+ max_ttl: "24h"
+ default_exec_timeout: "0s" # disabled unless set
+ max_exec_timeout: "10m"
+ max_sandboxes: 0 # 0 = unlimited
+ max_sandboxes_per_owner: 0 # 0 = unlimited
+ spawn_overflow: "reject" # reject or queue when sandbox capacity is full
+ spawn_queue_timeout: "30s"
+ max_spawn_queue: 100
auth:
enabled: false
api_key: ""
+ admin_api_key: "" # optional separate key for /api/v1/admin/*
+ worker_token: "" # shared staging worker token
+ worker_token_file: "" # file containing shared worker token
+ worker_tokens: {} # production map of worker_id: token
+ worker_signing_key: "" # production signed worker token verification key
+ worker_signing_key_file: "" # file containing active worker signing key
+ worker_signing_keys: [] # old verification keys accepted during rotation
+ worker_revoked_token_ids: [] # signed worker token jti values rejected during incidents
+ admin_fallback_enabled: true # false requires admin_api_key for admin routes
+ admin_audit_retention: "0s" # 0s disables native audit pruning
+
+ # OIDC/SSO — enterprise single sign-on (RS256 and ES256/ES384/ES512)
+ oidc_enabled: false
+ oidc_issuer: "" # e.g. https://accounts.google.com
+ oidc_audience: "" # your application's client ID / audience
+ oidc_jwks_url: "" # IdP's JWKS endpoint for key verification
+ oidc_public_key_file: "" # alternative: static PEM public key file
+ oidc_groups_claim: "groups" # JWT claim containing group memberships
+ oidc_tenant_claim: "tenant_id" # JWT claim containing tenant identifier
+ oidc_admin_groups: [] # groups that receive the admin role
+ oidc_operator_groups: [] # groups that receive the operator role
+ oidc_viewer_groups: [] # groups that receive the read-only viewer role
+
+rate_limit:
+ enabled: false
+ requests_per_minute: 120
+ burst: 60
+ key_by: "owner" # owner, api_key, or ip
+ bucket_ttl: "15m"
+ cleanup_interval: "1m"
database:
+ driver: "sqlite" # sqlite; postgres config is reserved for cluster builds
path: "stacyvm.db"
+ dsn: "" # required for future postgres-backed cluster mode
logging:
level: "info" # debug | info | warn | error
@@ -526,11 +616,116 @@ pool:
STACYVM_SERVER_PORT=8080
STACYVM_PROVIDERS_DEFAULT=firecracker
STACYVM_AUTH_API_KEY=sk-xyz123
+STACYVM_AUTH_ADMIN_API_KEY=sk-admin-xyz123
+STACYVM_AUTH_ADMIN_FALLBACK_ENABLED=false
+STACYVM_RATE_LIMIT_ENABLED=true
STACYVM_LOGGING_LEVEL=debug
```
---
+## Enterprise / Multi-worker
+
+StacyVM ships everything you need to run as multi-tenant infrastructure. All features below are stable and covered by CI.
+
+### OIDC & RBAC
+
+Enable enterprise SSO by pointing StacyVM at any RFC 7517-compliant identity provider:
+
+```yaml
+auth:
+ enabled: true
+ oidc_enabled: true
+ oidc_issuer: "https://accounts.google.com" # or Okta, Cloudflare, Azure AD
+ oidc_audience: "my-stacyvm"
+ oidc_jwks_url: "https://www.googleapis.com/oauth2/v3/certs"
+ oidc_admin_groups: ["stacyvm-admins"]
+ oidc_operator_groups: ["stacyvm-operators"]
+ oidc_viewer_groups: ["stacyvm-viewers"]
+```
+
+Callers send a standard Bearer token. StacyVM validates it (RS256 and ES256/ES384/ES512 are both supported) and maps IdP group membership to one of four roles:
+
+| Role | Can do |
+|---|---|
+| `viewer` | List and inspect sandboxes |
+| `api` / `operator` | Spawn, exec, read/write files, destroy |
+| `admin` | Everything + quotas, workers, provider config, tenants |
+| `tenant_admin` | Admin within their own tenant |
+
+Scope enforcement is applied on every route when auth is configured. A viewer token cannot spawn or exec — it gets 403.
+
+Run `stacyvm config lint --production` to validate your OIDC config before exposing it.
+
+### Multi-tenancy & policy controls
+
+Create isolated tenants and restrict what each one can use:
+
+```bash
+# Create a tenant
+curl -X POST /api/v1/admin/tenants \
+ -d '{"id":"acme","name":"Acme Corp","owner_id":"user-alice"}'
+
+# Add a member with operator role
+curl -X PUT /api/v1/admin/tenants/acme/members/user-bob \
+ -d '{"role":"operator"}'
+
+# Allow only trusted images
+curl -X POST /api/v1/admin/tenants/acme/policies \
+ -d '{"resource_type":"image","effect":"allow","pattern":"alpine:*","priority":10}'
+
+# Block untrusted networks
+curl -X POST /api/v1/admin/tenants/acme/policies \
+ -d '{"resource_type":"network","effect":"deny","pattern":"host","priority":1}'
+
+# Export per-tenant audit log
+curl /api/v1/admin/tenants/acme/audit
+```
+
+Each tenant's sandboxes, audit logs, and policies are fully isolated. OIDC callers are automatically scoped to their tenant via the configurable `oidc_tenant_claim`.
+
+### Remote workers & mTLS
+
+Run a distributed cluster with signed worker tokens and mutual TLS on the RPC channel:
+
+```yaml
+# Control plane
+auth:
+ worker_signing_key: "<32-byte secret>"
+worker:
+ rpc_tls:
+ enabled: true
+ ca_file: /etc/stacyvm/ca.crt
+ client_cert_file: /etc/stacyvm/cp-client.crt
+ client_key_file: /etc/stacyvm/cp-client.key
+```
+
+```bash
+# Worker — receives short-lived signed tokens from the control plane issuer
+# (no direct access to the signing key needed)
+stacyvm worker \
+ --control-plane https://cp.internal:7423 \
+ --bootstrap-admin-key "$STACYVM_ADMIN_KEY" \
+ --bootstrap-token-ttl 5m \
+ --listen 0.0.0.0:7430
+```
+
+Key rotation, token revocation, mTLS cert management, and the full enterprise signoff checklist are documented in [docs/enterprise-signoff-runbook.md](docs/enterprise-signoff-runbook.md).
+
+---
+
+## Production deployment
+
+Use [docs/deployment.md](docs/deployment.md) for production setup guidance, including Docker Compose and systemd templates, auth, explicit CORS origins, rate-limit defaults, health/readiness probes, Prometheus scraping, backup steps, and provider-specific rollout notes. Remote worker staging guidance lives in [docs/remote-worker-staging.md](docs/remote-worker-staging.md). Runtime signoff expectations live in [docs/runtime-conformance.md](docs/runtime-conformance.md), public self-serve support expectations live in [docs/public-support-matrix.md](docs/public-support-matrix.md), public announcement evidence lives in [docs/public-readiness-evidence.md](docs/public-readiness-evidence.md), and release-candidate gates live in [docs/production-readiness.md](docs/production-readiness.md). The reusable templates live under [`deploy/`](deploy/).
+
+For enterprise multi-worker deployments, follow [docs/enterprise-signoff-runbook.md](docs/enterprise-signoff-runbook.md) — it covers mTLS smoke with deployment-issued certificates, per-host runtime certification, Postgres migration rehearsal, OIDC token validation, and the full pre-go-live checklist.
+
+Run `stacyvm doctor --production` on a target host before treating it as production-ready. Runtime host certification checks live in [docs/runtime-certification.md](docs/runtime-certification.md).
+
+Release automation and GHCR publishing are documented in [docs/releasing.md](docs/releasing.md).
+
+---
+
## Templates
Templates are pre-baked sandbox specs stored server-side. Define once, spawn many times.
@@ -577,7 +772,9 @@ Every sandbox ships locked down. You opt *in* to less restriction, not out.
With the Firecracker provider you also get: dedicated kernel per sandbox, vsock-only host-guest communication (no TCP between host and guest), and ephemeral rootfs destroyed on teardown.
-Full security model and reporting policy: [SECURITY.md](SECURITY.md).
+For enterprise deployments, OIDC/JWT authentication (RS256 + ES256) and RBAC role enforcement replace static API keys. Scope checks are applied on every sandbox route — a viewer-role token cannot spawn or exec. See the [Enterprise / Multi-worker](#enterprise--multi-worker) section above.
+
+Full security model and reporting policy: [SECURITY.md](SECURITY.md). Production admin hardening and identity-provider planning: [docs/security-governance.md](docs/security-governance.md). Release-candidate threat model: [docs/threat-model.md](docs/threat-model.md). Worker RPC and multi-worker trust boundary: [docs/worker-rpc-contract.md](docs/worker-rpc-contract.md).
---
@@ -641,6 +838,14 @@ curl -fsSL https://github.com/StacyOs/stacyvm/releases/latest/download/stacyvm-l
chmod +x stacyvm && sudo mv stacyvm /usr/local/bin/
```
+For public installs, verify the release first:
+
+```bash
+scripts/verify-release.sh v0.4.0 amd64
+```
+
+The installer verifies Sigstore signatures automatically when `cosign` is installed. Set `STACYVM_REQUIRE_SIGNATURES=true` to fail closed when signature verification is unavailable.
+
---
## Project layout
@@ -670,6 +875,7 @@ stacyvm/
## Roadmap
+**Single-node & public self-serve**
- [x] Firecracker provider (KVM microVMs, ~28ms snapshot restore)
- [x] Docker provider (OCI containers, seccomp, no KVM needed)
- [x] gVisor support (user-space kernel via runsc runtime)
@@ -680,11 +886,29 @@ stacyvm/
- [x] Template system + warm pools
- [x] PRoot provider (root-less, KVM-less)
- [x] E2B + custom HTTP provider
+- [x] Signed release binaries + Sigstore verification
+- [x] `stacyvm doctor`, config lint, upgrade rehearsal, support bundle
+
+**Enterprise / multi-worker**
+- [x] Postgres store with durable leases and migration rehearsal
+- [x] Remote worker registry, placement, RPC routing
+- [x] Signed worker tokens (HMAC-SHA256, rotation, revocation)
+- [x] Worker RPC mutual TLS (mTLS)
+- [x] Centralized worker token issuance (no signing key on workers)
+- [x] Durable event bus (Postgres LISTEN/NOTIFY for HA replicas)
+- [x] OIDC/SSO — RS256 + ES256/ES384/ES512, JWKS, Google/Okta/Cloudflare/Azure
+- [x] RBAC roles — viewer, operator, admin, tenant_admin with scope enforcement
+- [x] Multi-tenancy — tenant model, member RBAC, per-tenant audit export
+- [x] Policy controls — image/provider/network allow-deny per tenant
+- [x] Enterprise signoff runbook + runtime certification script
+
+**Planned**
- [ ] Live Preview for Firecracker
- [ ] Kata Containers provider (K8s-native)
- [ ] Persistent volumes across sandboxes
- [ ] MCP server mode
- [ ] GPU passthrough
+- [ ] Centralized token issuer as a standalone sidecar service
---
@@ -696,7 +920,7 @@ If you find a security issue, do **not** open a public issue — follow [SECURIT
## License
-[MIT](LICENSE) — use it however you want.
+[Apache 2.0](LICENSE) — use it however you want.
---
diff --git a/SECURITY.md b/SECURITY.md
index cfcd7c0..ed2aa01 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -12,26 +12,51 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 7 da
## Security Model
-StacyVM provides hardware-level isolation via Firecracker microVMs:
+### Sandbox isolation
-- Each sandbox runs in its own KVM virtual machine with a dedicated kernel
+- Each Firecracker sandbox runs in its own KVM virtual machine with a dedicated kernel
- No shared kernel between sandboxes or between sandbox and host
- Per-VM rootfs — destroyed on teardown
- Host-guest communication via virtio-vsock (no network exposure)
-- Optional API key authentication on all endpoints
+- Docker sandboxes use dropped capabilities (`CAP_ALL`), seccomp, pids limit, and memory/CPU limits
- TTL-based auto-expiry prevents resource leaks
+### Authentication and authorisation
+
+- **API key auth** — static API and admin keys; `stacyvm config lint --production` enforces minimum entropy and key separation
+- **OIDC/JWT auth** — RS256 and ES256/ES384/ES512 Bearer token verification with configurable JWKS endpoint, audience, and issuer; supports Google Workspace, Okta, Cloudflare Access, Azure AD, and any RFC 7517-compliant IdP
+- **RBAC** — `viewer` (read-only), `api`, `operator`, `admin`, `tenant_admin` roles with scope enforcement on every API route; OIDC group-to-role mapping is configurable
+- **Multi-tenancy** — sandboxes, audit logs, and policies are scoped to tenants; cross-tenant access returns 404
+
+### Worker security
+
+- Remote workers authenticate with HMAC-SHA256 signed tokens (`stacyvm-worker-v1` format) or individually rotatable static tokens
+- Worker RPC supports mutual TLS (mTLS) for network-level transport identity
+- Signed tokens carry `worker_id`, `jti`, `aud`, `exp`, `iat`; emergency revocation is supported via `auth.worker_revoked_token_ids`
+- Workers can receive short-lived signed tokens from the centralized issuer (`POST /api/v1/admin/worker-tokens`) without needing direct access to the signing key
+
+### Audit
+
+- All admin operations are persisted in `admin_audit_logs` with actor, method, path, status, and tenant
+- All sandbox lifecycle, exec, and file operations are persisted in `operation_audit_logs`
+- Per-tenant audit export is available at `GET /api/v1/admin/tenants/{id}/audit`
+- Audit records redacted automatically in support bundles
+
## Scope
The following are in scope for security reports:
-- VM escape vulnerabilities
-- API authentication bypass
+- VM or container escape vulnerabilities
+- API authentication or authorisation bypass
+- OIDC JWT validation bypass (algorithm confusion, `alg: none`, signature skip)
+- Cross-tenant data access
- Unauthorized access to host filesystem or network
- Denial of service via resource exhaustion
-- Command injection through the API
+- Command injection through the API or exec interface
+- Worker impersonation or signed-token forgery
## Out of Scope
-- The mock provider intentionally runs commands on the host (development only)
-- Self-hosted deployments without API key auth enabled
+- The mock provider intentionally runs commands on the host and is for development only
+- Self-hosted deployments where the operator has explicitly disabled auth (`auth.enabled: false`) — this is flagged as a production failure by `stacyvm doctor --production` and `stacyvm config lint --production`
+- Vulnerabilities in third-party runtimes (Docker, Firecracker, gVisor, Kata) that are outside StacyVM's control
diff --git a/assets/stacy-logo-dark.png b/assets/stacy-logo-dark.png
new file mode 100644
index 0000000..05226d8
Binary files /dev/null and b/assets/stacy-logo-dark.png differ
diff --git a/assets/stacy-logo-light.png b/assets/stacy-logo-light.png
new file mode 100644
index 0000000..34064f0
Binary files /dev/null and b/assets/stacy-logo-light.png differ
diff --git a/assets/stacy-mark-dark.png b/assets/stacy-mark-dark.png
new file mode 100644
index 0000000..b188bab
Binary files /dev/null and b/assets/stacy-mark-dark.png differ
diff --git a/assets/stacy-mark-orange.png b/assets/stacy-mark-orange.png
new file mode 100644
index 0000000..6a95f95
Binary files /dev/null and b/assets/stacy-mark-orange.png differ
diff --git a/assets/stacyvm-favicon.svg b/assets/stacyvm-favicon.svg
new file mode 100644
index 0000000..d2d3f8b
--- /dev/null
+++ b/assets/stacyvm-favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/stacyvm-logo-dark.svg b/assets/stacyvm-logo-dark.svg
new file mode 100644
index 0000000..5852240
--- /dev/null
+++ b/assets/stacyvm-logo-dark.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/stacyvm-logo-light.svg b/assets/stacyvm-logo-light.svg
new file mode 100644
index 0000000..591b8d8
--- /dev/null
+++ b/assets/stacyvm-logo-light.svg
@@ -0,0 +1,6 @@
+
diff --git a/cmd/stacyvm-agent/main.go b/cmd/stacyvm-agent/main.go
index 1a105f7..2d2f095 100644
--- a/cmd/stacyvm-agent/main.go
+++ b/cmd/stacyvm-agent/main.go
@@ -1,3 +1,5 @@
+//go:build linux
+
// stacyvm-agent runs inside a Firecracker VM and serves exec/file requests
// over vsock. Build with: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/stacyvm-agent ./cmd/stacyvm-agent
package main
@@ -173,6 +175,24 @@ func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
+func buildExecCommand(params agentproto.ExecParams) ([]string, error) {
+ switch strings.TrimSpace(params.Mode) {
+ case "", "shell":
+ shellCmd := params.Command
+ for _, arg := range params.Args {
+ shellCmd += " " + shellQuote(arg)
+ }
+ return []string{"/bin/sh", "-c", shellCmd}, nil
+ case "argv":
+ if strings.TrimSpace(params.Command) == "" {
+ return nil, fmt.Errorf("argv exec mode requires command")
+ }
+ return append([]string{params.Command}, params.Args...), nil
+ default:
+ return nil, fmt.Errorf("unsupported exec mode %q", params.Mode)
+ }
+}
+
func handleExec(w io.Writer, req *agentproto.Request) {
var params agentproto.ExecParams
if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil {
@@ -183,15 +203,13 @@ func handleExec(w io.Writer, req *agentproto.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- // Build full shell command: if args provided, shell-quote and append them
- shellCmd := params.Command
- if len(params.Args) > 0 {
- for _, a := range params.Args {
- shellCmd += " " + shellQuote(a)
- }
+ args, err := buildExecCommand(params)
+ if err != nil {
+ sendError(w, req.ID, err.Error())
+ return
}
- cmd := exec.CommandContext(ctx, "/bin/sh", "-c", shellCmd)
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
if params.WorkDir != "" {
cmd.Dir = params.WorkDir
}
@@ -206,7 +224,7 @@ func handleExec(w io.Writer, req *agentproto.Request) {
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
- err := cmd.Run()
+ err = cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
@@ -238,15 +256,15 @@ func handleExecStream(w io.Writer, req *agentproto.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
- // Build full shell command: if args provided, shell-quote and append them
- shellCmd := params.Command
- if len(params.Args) > 0 {
- for _, a := range params.Args {
- shellCmd += " " + shellQuote(a)
- }
+ args, err := buildExecCommand(params)
+ if err != nil {
+ agentproto.SendStreamResponse(w, &agentproto.StreamResponse{
+ ID: req.ID, Error: err.Error(), Done: true,
+ })
+ return
}
- cmd := exec.CommandContext(ctx, "/bin/sh", "-c", shellCmd)
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
if params.WorkDir != "" {
cmd.Dir = params.WorkDir
}
diff --git a/cmd/stacyvm-agent/main_unsupported.go b/cmd/stacyvm-agent/main_unsupported.go
new file mode 100644
index 0000000..bd90e76
--- /dev/null
+++ b/cmd/stacyvm-agent/main_unsupported.go
@@ -0,0 +1,9 @@
+//go:build !linux
+
+package main
+
+import "fmt"
+
+func main() {
+ fmt.Println("stacyvm-agent only runs on Linux")
+}
diff --git a/cmd/stacyvm/cmd_config.go b/cmd/stacyvm/cmd_config.go
new file mode 100644
index 0000000..f268f47
--- /dev/null
+++ b/cmd/stacyvm/cmd_config.go
@@ -0,0 +1,452 @@
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+ "github.com/spf13/cobra"
+)
+
+func newConfigCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "config",
+ Short: "Inspect and validate StacyVM configuration",
+ }
+ cmd.AddCommand(newConfigLintCmd())
+ return cmd
+}
+
+func newConfigLintCmd() *cobra.Command {
+ var file string
+ var production bool
+ cmd := &cobra.Command{
+ Use: "lint",
+ Short: "Lint configuration for operational safety",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := loadLintConfig(file)
+ if err != nil {
+ return err
+ }
+ checks := lintConfig(cfg, production)
+ failed := printDoctorChecks(checks)
+ if failed > 0 {
+ return fmt.Errorf("config lint found %d failing check(s)", failed)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&file, "file", "", "config file to lint; defaults to normal StacyVM config lookup")
+ cmd.Flags().BoolVar(&production, "production", false, "treat production hardening warnings as failures")
+ return cmd
+}
+
+func loadLintConfig(file string) (*config.Config, error) {
+ if strings.TrimSpace(file) == "" {
+ return config.Load()
+ }
+ return config.LoadFile(file)
+}
+
+func lintConfig(cfg *config.Config, production bool) []doctorCheck {
+ checks := []doctorCheck{
+ {Name: "config", Status: doctorPass, Message: "syntax and schema validation passed"},
+ }
+ checks = append(checks, lintAuthConfig(cfg, production)...)
+ checks = append(checks, lintOIDCConfig(cfg, production)...)
+ checks = append(checks, lintServerConfig(cfg, production)...)
+ checks = append(checks, lintRateLimitConfig(cfg, production)...)
+ checks = append(checks, lintDatabaseConfig(cfg, production)...)
+ checks = append(checks, lintRuntimeLimits(cfg, production)...)
+ checks = append(checks, lintLoggingConfig(cfg, production)...)
+ checks = append(checks, lintWorkerRPCConfig(cfg, production)...)
+ checks = append(checks, lintProviderConfig(cfg, production)...)
+ return checks
+}
+
+func lintServerConfig(cfg *config.Config, production bool) []doctorCheck {
+ origins := cleanCORSOrigins(cfg.Server.CORSAllowedOrigins)
+ if len(origins) == 0 {
+ return []doctorCheck{{Name: "server.cors_allowed_origins", Status: severityForProduction(production), Message: "no CORS origins configured", Remediation: "Set server.cors_allowed_origins to the exact web console or API client origins that may call StacyVM."}}
+ }
+ if containsCORSWildcard(origins) {
+ return []doctorCheck{{Name: "server.cors_allowed_origins", Status: severityForProduction(production), Message: "CORS allows every origin", Remediation: "Set server.cors_allowed_origins to explicit https:// origins before exposing StacyVM publicly."}}
+ }
+ return []doctorCheck{{Name: "server.cors_allowed_origins", Status: doctorPass, Message: fmt.Sprintf("%d explicit origin(s)", len(origins))}}
+}
+
+func cleanCORSOrigins(origins []string) []string {
+ cleaned := make([]string, 0, len(origins))
+ for _, origin := range origins {
+ origin = strings.TrimSpace(origin)
+ if origin != "" {
+ cleaned = append(cleaned, origin)
+ }
+ }
+ return cleaned
+}
+
+func containsCORSWildcard(origins []string) bool {
+ for _, origin := range origins {
+ if strings.TrimSpace(origin) == "*" {
+ return true
+ }
+ }
+ return false
+}
+
+func lintAuthConfig(cfg *config.Config, production bool) []doctorCheck {
+ var checks []doctorCheck
+ if !cfg.Auth.Enabled {
+ checks = append(checks, doctorCheck{Name: "auth.enabled", Status: severityForProduction(production), Message: "authentication is disabled", Remediation: "Set auth.enabled=true before exposing StacyVM beyond a trusted local network."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.enabled", Status: doctorPass, Message: "enabled"})
+ }
+
+ checks = append(checks, lintSecret("auth.api_key", cfg.Auth.APIKey, production, "Set STACYVM_AUTH_API_KEY or auth.api_key to a random 32+ byte secret."))
+ checks = append(checks, lintSecret("auth.admin_api_key", cfg.Auth.AdminAPIKey, production, "Set STACYVM_AUTH_ADMIN_API_KEY or auth.admin_api_key to a separate random 32+ byte secret."))
+ if cfg.Auth.WorkerSigningKey != "" {
+ checks = append(checks, lintSecret("auth.worker_signing_key", cfg.Auth.WorkerSigningKey, production, "Set STACYVM_AUTH_WORKER_SIGNING_KEY or auth.worker_signing_key to a random 32+ byte secret used to verify signed worker tokens."))
+ checks = append(checks, lintWorkerSecretSource("auth.worker_signing_key_file", cfg.Auth.WorkerSigningKeyFile, "auth.worker_signing_key", "Mount the worker signing key through auth.worker_signing_key_file for production services.")...)
+ checks = append(checks, lintWorkerSigningKeyRotation(cfg.Auth.WorkerSigningKey, cfg.Auth.WorkerSigningKeys)...)
+ if len(cfg.Auth.WorkerRevokedTokenIDs) > 0 {
+ checks = append(checks, doctorCheck{Name: "auth.worker_revoked_token_ids", Status: doctorPass, Message: fmt.Sprintf("%d revoked worker token id(s) configured", len(cfg.Auth.WorkerRevokedTokenIDs))})
+ }
+ if cfg.Auth.WorkerToken != "" {
+ checks = append(checks, doctorCheck{Name: "auth.worker_token", Status: doctorWarn, Message: "shared worker token still configured with signed worker tokens", Remediation: "Remove auth.worker_token after workers and worker RPC clients use short-lived signed worker tokens."})
+ checks = append(checks, lintWorkerSecretSource("auth.worker_token_file", cfg.Auth.WorkerTokenFile, "auth.worker_token", "Mount shared staging worker tokens through auth.worker_token_file when they cannot be removed yet.")...)
+ }
+ if len(cfg.Auth.WorkerTokens) > 0 {
+ checks = append(checks, doctorCheck{Name: "auth.worker_tokens", Status: doctorWarn, Message: fmt.Sprintf("%d static per-worker token(s) still configured", len(cfg.Auth.WorkerTokens)), Remediation: "Prefer short-lived signed worker tokens for production workers; keep static worker tokens only during migration."})
+ }
+ } else if len(cfg.Auth.WorkerSigningKeys) > 0 {
+ checks = append(checks, doctorCheck{Name: "auth.worker_signing_key", Status: severityForProduction(production), Message: "additional verification keys configured without a primary signing key", Remediation: "Set auth.worker_signing_key to the active signing key and keep old keys in auth.worker_signing_keys only during rotation."})
+ checks = append(checks, lintWorkerRevokedTokenIDsWithoutSigningKey(cfg.Auth.WorkerRevokedTokenIDs)...)
+ } else if len(cfg.Auth.WorkerTokens) > 0 {
+ checks = append(checks, lintWorkerRevokedTokenIDsWithoutSigningKey(cfg.Auth.WorkerRevokedTokenIDs)...)
+ checks = append(checks, doctorCheck{Name: "auth.worker_tokens", Status: doctorPass, Message: fmt.Sprintf("%d per-worker token(s) configured", len(cfg.Auth.WorkerTokens))})
+ } else if cfg.Auth.WorkerToken != "" {
+ checks = append(checks, lintWorkerRevokedTokenIDsWithoutSigningKey(cfg.Auth.WorkerRevokedTokenIDs)...)
+ checks = append(checks, doctorCheck{Name: "auth.worker_tokens", Status: doctorWarn, Message: "using shared worker token", Remediation: "Configure auth.worker_tokens for production workers so each worker has an individually rotatable credential."})
+ checks = append(checks, lintWorkerSecretSource("auth.worker_token_file", cfg.Auth.WorkerTokenFile, "auth.worker_token", "Mount shared staging worker tokens through auth.worker_token_file when they cannot be replaced yet.")...)
+ } else {
+ checks = append(checks, lintWorkerRevokedTokenIDsWithoutSigningKey(cfg.Auth.WorkerRevokedTokenIDs)...)
+ checks = append(checks, doctorCheck{Name: "auth.worker_tokens", Status: doctorWarn, Message: "no worker credentials configured", Remediation: "Set auth.worker_token for staging, auth.worker_tokens for migration, or auth.worker_signing_key for production signed worker identity."})
+ }
+ if cfg.Auth.APIKey != "" && cfg.Auth.APIKey == cfg.Auth.AdminAPIKey {
+ checks = append(checks, doctorCheck{Name: "auth.key_separation", Status: severityForProduction(production), Message: "regular and admin API keys match", Remediation: "Use separate keys so admin endpoints can be rotated and restricted independently."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.key_separation", Status: doctorPass, Message: "regular and admin keys are separate"})
+ }
+ if cfg.Auth.AdminFallbackEnabled {
+ checks = append(checks, doctorCheck{Name: "auth.admin_fallback_enabled", Status: severityForProduction(production), Message: "admin fallback is enabled", Remediation: "Set auth.admin_fallback_enabled=false in production."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.admin_fallback_enabled", Status: doctorPass, Message: "disabled"})
+ }
+ retention, _ := time.ParseDuration(cfg.Auth.AdminAuditRetention)
+ if retention <= 0 {
+ checks = append(checks, doctorCheck{Name: "auth.admin_audit_retention", Status: severityForProduction(production), Message: "admin audit retention is disabled", Remediation: "Set auth.admin_audit_retention to a positive retention window such as 2160h."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.admin_audit_retention", Status: doctorPass, Message: retention.String()})
+ }
+ return checks
+}
+
+func lintWorkerSecretSource(fileName, filePath, inlineName, remediation string) []doctorCheck {
+ if strings.TrimSpace(filePath) != "" {
+ return []doctorCheck{{Name: fileName, Status: doctorPass, Message: "configured"}}
+ }
+ return []doctorCheck{{Name: fileName, Status: doctorWarn, Message: fmt.Sprintf("%s is configured inline", inlineName), Remediation: remediation}}
+}
+
+func lintWorkerRevokedTokenIDsWithoutSigningKey(tokenIDs []string) []doctorCheck {
+ if len(tokenIDs) == 0 {
+ return nil
+ }
+ return []doctorCheck{{
+ Name: "auth.worker_revoked_token_ids",
+ Status: doctorWarn,
+ Message: fmt.Sprintf("%d revoked worker token id(s) configured without signed worker tokens", len(tokenIDs)),
+ Remediation: "Keep auth.worker_revoked_token_ids only alongside auth.worker_signing_key during an incident-response window.",
+ }}
+}
+
+func lintWorkerSigningKeyRotation(primary string, keys []string) []doctorCheck {
+ if len(keys) == 0 {
+ return nil
+ }
+ primary = strings.TrimSpace(primary)
+ seen := map[string]struct{}{}
+ for _, key := range keys {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ if key == primary {
+ return []doctorCheck{{Name: "auth.worker_signing_keys", Status: doctorWarn, Message: "rotation keys include the active worker signing key", Remediation: "Keep only previous verification keys in auth.worker_signing_keys; auth.worker_signing_key is already accepted."}}
+ }
+ if _, ok := seen[key]; ok {
+ return []doctorCheck{{Name: "auth.worker_signing_keys", Status: doctorWarn, Message: "duplicate worker signing rotation key configured", Remediation: "Remove duplicate entries from auth.worker_signing_keys so rotation state is unambiguous."}}
+ }
+ seen[key] = struct{}{}
+ }
+ return []doctorCheck{{Name: "auth.worker_signing_keys", Status: doctorPass, Message: fmt.Sprintf("%d additional verification key(s) configured", len(seen))}}
+}
+
+func lintSecret(name, value string, production bool, remediation string) doctorCheck {
+ if strings.TrimSpace(value) == "" {
+ return doctorCheck{Name: name, Status: severityForProduction(production), Message: "missing secret", Remediation: remediation}
+ }
+ lower := strings.ToLower(value)
+ if strings.Contains(lower, "change-me") || strings.Contains(lower, "changeme") || strings.Contains(lower, "replace-me") {
+ return doctorCheck{Name: name, Status: severityForProduction(production), Message: "placeholder secret is still configured", Remediation: remediation}
+ }
+ if len(value) < 32 {
+ return doctorCheck{Name: name, Status: severityForProduction(production), Message: "secret is shorter than 32 bytes", Remediation: remediation}
+ }
+ return doctorCheck{Name: name, Status: doctorPass, Message: "configured"}
+}
+
+func lintOIDCConfig(cfg *config.Config, production bool) []doctorCheck {
+ if !cfg.Auth.OIDCEnabled {
+ return []doctorCheck{{Name: "auth.oidc_enabled", Status: doctorPass, Message: "disabled (API key auth only)"}}
+ }
+ var checks []doctorCheck
+ checks = append(checks, doctorCheck{Name: "auth.oidc_enabled", Status: doctorPass, Message: "enabled"})
+
+ // Issuer is required for audience and signature validation.
+ if strings.TrimSpace(cfg.Auth.OIDCIssuer) == "" {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_issuer",
+ Status: severityForProduction(production),
+ Message: "OIDC issuer is not set",
+ Remediation: "Set auth.oidc_issuer to your identity provider's issuer URL (e.g. https://accounts.google.com).",
+ })
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.oidc_issuer", Status: doctorPass, Message: cfg.Auth.OIDCIssuer})
+ }
+
+ // At least one of JWKS URL or static public key must be set.
+ hasKey := strings.TrimSpace(cfg.Auth.OIDCJWKSUrl) != "" || strings.TrimSpace(cfg.Auth.OIDCPublicKey) != ""
+ if !hasKey {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_jwks_url",
+ Status: severityForProduction(production),
+ Message: "no OIDC verification key configured",
+ Remediation: "Set auth.oidc_jwks_url to your identity provider's JWKS endpoint, or set auth.oidc_public_key_file to a PEM public key.",
+ })
+ } else {
+ if strings.TrimSpace(cfg.Auth.OIDCJWKSUrl) != "" {
+ checks = append(checks, doctorCheck{Name: "auth.oidc_jwks_url", Status: doctorPass, Message: cfg.Auth.OIDCJWKSUrl})
+ }
+ if strings.TrimSpace(cfg.Auth.OIDCPublicKey) != "" {
+ src := "inline"
+ if strings.TrimSpace(cfg.Auth.OIDCPublicKeyFile) != "" {
+ src = "file-backed"
+ }
+ checks = append(checks, doctorCheck{Name: "auth.oidc_public_key", Status: doctorPass, Message: src})
+ if src == "inline" {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_public_key_file",
+ Status: doctorWarn,
+ Message: "OIDC public key is configured inline",
+ Remediation: "Mount the OIDC public key via auth.oidc_public_key_file in production deployments.",
+ })
+ }
+ }
+ }
+
+ // Audience is strongly recommended for production.
+ if strings.TrimSpace(cfg.Auth.OIDCAudience) == "" {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_audience",
+ Status: doctorWarn,
+ Message: "OIDC audience not set; any token issued by this provider will be accepted",
+ Remediation: "Set auth.oidc_audience to restrict token acceptance to your specific application.",
+ })
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.oidc_audience", Status: doctorPass, Message: cfg.Auth.OIDCAudience})
+ }
+
+ // At least one group mapping should exist, otherwise all OIDC users get the default API role.
+ hasGroupMapping := len(cfg.Auth.OIDCAdminGroups) > 0 || len(cfg.Auth.OIDCOperatorGroups) > 0 || len(cfg.Auth.OIDCViewerGroups) > 0
+ if !hasGroupMapping {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_groups",
+ Status: doctorWarn,
+ Message: "no OIDC group-to-role mappings configured; all authenticated users get the default api role",
+ Remediation: "Set auth.oidc_admin_groups, auth.oidc_operator_groups, or auth.oidc_viewer_groups to restrict access by group membership.",
+ })
+ } else {
+ checks = append(checks, doctorCheck{
+ Name: "auth.oidc_groups",
+ Status: doctorPass,
+ Message: fmt.Sprintf("admin=%d operator=%d viewer=%d groups configured", len(cfg.Auth.OIDCAdminGroups), len(cfg.Auth.OIDCOperatorGroups), len(cfg.Auth.OIDCViewerGroups)),
+ })
+ }
+
+ return checks
+}
+
+func lintRateLimitConfig(cfg *config.Config, production bool) []doctorCheck {
+ var checks []doctorCheck
+ if !cfg.RateLimit.Enabled {
+ checks = append(checks, doctorCheck{Name: "rate_limit.enabled", Status: severityForProduction(production), Message: "rate limiting is disabled", Remediation: "Set rate_limit.enabled=true and choose bounded request limits."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "rate_limit.enabled", Status: doctorPass, Message: "enabled"})
+ }
+ if cfg.RateLimit.RequestsPerMinute <= 0 || cfg.RateLimit.Burst <= 0 {
+ checks = append(checks, doctorCheck{Name: "rate_limit.capacity", Status: severityForProduction(production), Message: "requests_per_minute and burst must be positive", Remediation: "Set positive rate_limit.requests_per_minute and rate_limit.burst values."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "rate_limit.capacity", Status: doctorPass, Message: fmt.Sprintf("%d rpm, burst %d", cfg.RateLimit.RequestsPerMinute, cfg.RateLimit.Burst)})
+ }
+ if cfg.RateLimit.KeyBy == "ip" {
+ checks = append(checks, doctorCheck{Name: "rate_limit.key_by", Status: doctorWarn, Message: "IP-based limiting can collapse unrelated users behind NAT", Remediation: "Prefer rate_limit.key_by=api_key or owner for authenticated production deployments."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "rate_limit.key_by", Status: doctorPass, Message: cfg.RateLimit.KeyBy})
+ }
+ return checks
+}
+
+func lintDatabaseConfig(cfg *config.Config, production bool) []doctorCheck {
+ driver := strings.ToLower(strings.TrimSpace(cfg.Database.Driver))
+ if driver == "" {
+ driver = "sqlite"
+ }
+ if driver == "postgres" || driver == "postgresql" {
+ if cfg.Database.DSN == "" {
+ return []doctorCheck{{Name: "database.dsn", Status: doctorFail, Message: "postgres DSN is required"}}
+ }
+ return []doctorCheck{{Name: "database.driver", Status: doctorPass, Message: "postgres"}}
+ }
+ if filepath.IsAbs(cfg.Database.Path) {
+ return []doctorCheck{{Name: "database.path", Status: doctorPass, Message: cfg.Database.Path}}
+ }
+ return []doctorCheck{{Name: "database.path", Status: severityForProduction(production), Message: "database path is relative", Remediation: "Use an absolute path on durable storage, for example /var/lib/stacyvm/stacyvm.db."}}
+}
+
+func lintRuntimeLimits(cfg *config.Config, production bool) []doctorCheck {
+ var checks []doctorCheck
+ if cfg.Defaults.MaxSandboxes <= 0 || cfg.Defaults.MaxSandboxesPerOwner <= 0 {
+ checks = append(checks, doctorCheck{Name: "defaults.sandbox_caps", Status: severityForProduction(production), Message: "global and per-owner sandbox caps must be positive", Remediation: "Set defaults.max_sandboxes and defaults.max_sandboxes_per_owner to bounded production values."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "defaults.sandbox_caps", Status: doctorPass, Message: fmt.Sprintf("global %d, per-owner %d", cfg.Defaults.MaxSandboxes, cfg.Defaults.MaxSandboxesPerOwner)})
+ }
+ if cfg.Defaults.MaxSpawnQueue <= 0 {
+ checks = append(checks, doctorCheck{Name: "defaults.max_spawn_queue", Status: severityForProduction(production), Message: "spawn queue is unbounded or disabled by capacity", Remediation: "Set defaults.max_spawn_queue to a positive bounded value."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "defaults.max_spawn_queue", Status: doctorPass, Message: fmt.Sprintf("%d", cfg.Defaults.MaxSpawnQueue)})
+ }
+ defaultExec, _ := time.ParseDuration(cfg.Defaults.DefaultExecTimeout)
+ maxExec, _ := time.ParseDuration(cfg.Defaults.MaxExecTimeout)
+ if defaultExec <= 0 || maxExec <= 0 {
+ checks = append(checks, doctorCheck{Name: "defaults.exec_timeouts", Status: severityForProduction(production), Message: "exec timeouts should be positive", Remediation: "Set defaults.default_exec_timeout and defaults.max_exec_timeout to positive durations."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "defaults.exec_timeouts", Status: doctorPass, Message: fmt.Sprintf("default %s, max %s", defaultExec, maxExec)})
+ }
+ maxTTL, _ := time.ParseDuration(cfg.Defaults.MaxTTL)
+ if maxTTL <= 0 {
+ checks = append(checks, doctorCheck{Name: "defaults.max_ttl", Status: severityForProduction(production), Message: "max TTL should be positive", Remediation: "Set defaults.max_ttl to a positive duration so sandboxes cannot run forever."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "defaults.max_ttl", Status: doctorPass, Message: maxTTL.String()})
+ }
+ return checks
+}
+
+func lintLoggingConfig(cfg *config.Config, production bool) []doctorCheck {
+ if cfg.Logging.Format != "json" {
+ return []doctorCheck{{Name: "logging.format", Status: severityForProduction(production), Message: "logs are not JSON formatted", Remediation: "Set logging.format=json so production log collectors can parse records reliably."}}
+ }
+ return []doctorCheck{{Name: "logging.format", Status: doctorPass, Message: "json"}}
+}
+
+func lintWorkerRPCConfig(cfg *config.Config, production bool) []doctorCheck {
+ rpcTLS := cfg.Worker.RPCTLS
+ if !rpcTLS.Enabled {
+ return []doctorCheck{{Name: "worker.rpc_tls.enabled", Status: doctorWarn, Message: "worker RPC TLS is disabled", Remediation: "Enable worker.rpc_tls.enabled with server, client, and CA certificates before exposing worker RPC across a network."}}
+ }
+ var checks []doctorCheck
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.enabled", Status: doctorPass, Message: "enabled"})
+ if rpcTLS.ServerCertFile == "" || rpcTLS.ServerKeyFile == "" {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.server_cert", Status: severityForProduction(production), Message: "server cert and key are required", Remediation: "Set worker.rpc_tls.server_cert_file and worker.rpc_tls.server_key_file on worker nodes."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.server_cert", Status: doctorPass, Message: "configured"})
+ }
+ if rpcTLS.ClientCAFile == "" {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.client_ca", Status: severityForProduction(production), Message: "client CA is not configured", Remediation: "Set worker.rpc_tls.client_ca_file so worker RPC servers require trusted control-plane client certificates."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.client_ca", Status: doctorPass, Message: "configured"})
+ }
+ if rpcTLS.CAFile == "" {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.ca", Status: severityForProduction(production), Message: "server CA is not configured", Remediation: "Set worker.rpc_tls.ca_file so control planes verify worker RPC server certificates."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.ca", Status: doctorPass, Message: "configured"})
+ }
+ if rpcTLS.ClientCertFile == "" || rpcTLS.ClientKeyFile == "" {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.client_cert", Status: severityForProduction(production), Message: "client cert and key are required", Remediation: "Set worker.rpc_tls.client_cert_file and worker.rpc_tls.client_key_file on control-plane nodes."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.client_cert", Status: doctorPass, Message: "configured"})
+ }
+ if rpcTLS.InsecureSkipVerify {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.insecure_skip_verify", Status: severityForProduction(production), Message: "TLS certificate verification is disabled", Remediation: "Set worker.rpc_tls.insecure_skip_verify=false outside throwaway local tests."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "worker.rpc_tls.insecure_skip_verify", Status: doctorPass, Message: "disabled"})
+ }
+ return checks
+}
+
+func lintProviderConfig(cfg *config.Config, production bool) []doctorCheck {
+ if cfg.Providers.Default != "docker" || !cfg.Providers.Docker.Enabled {
+ return []doctorCheck{{Name: "providers.default", Status: doctorWarn, Message: fmt.Sprintf("default provider is %q", cfg.Providers.Default), Remediation: "For single-node broad compatibility, validate non-Docker providers with runtime certification before production."}}
+ }
+
+ var checks []doctorCheck
+ docker := cfg.Providers.Docker
+ if docker.Runtime == "" {
+ checks = append(checks, doctorCheck{Name: "docker.runtime", Status: severityForProduction(production), Message: "runtime is empty", Remediation: "Set providers.docker.runtime explicitly, such as runc, runsc, or kata."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.runtime", Status: doctorPass, Message: docker.Runtime})
+ }
+ if docker.NetworkMode == "" {
+ checks = append(checks, doctorCheck{Name: "docker.network_mode", Status: severityForProduction(production), Message: "network mode is empty", Remediation: "Set providers.docker.network_mode explicitly."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.network_mode", Status: doctorPass, Message: docker.NetworkMode})
+ }
+ if docker.SeccompProfile == "" || docker.SeccompProfile == "unconfined" {
+ checks = append(checks, doctorCheck{Name: "docker.seccomp_profile", Status: severityForProduction(production), Message: "seccomp is not enforced", Remediation: "Use Docker's default seccomp profile or a custom restrictive profile."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.seccomp_profile", Status: doctorPass, Message: docker.SeccompProfile})
+ }
+ if len(docker.AddedCaps) > 0 {
+ checks = append(checks, doctorCheck{Name: "docker.added_caps", Status: severityForProduction(production), Message: "extra Linux capabilities are configured", Remediation: "Remove providers.docker.added_caps unless a certified workload requires them."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.added_caps", Status: doctorPass, Message: "none"})
+ }
+ if !containsString(docker.DroppedCaps, "ALL") {
+ checks = append(checks, doctorCheck{Name: "docker.dropped_caps", Status: severityForProduction(production), Message: "ALL capabilities are not dropped by default", Remediation: "Set providers.docker.dropped_caps=[\"ALL\"] and add back only certified capabilities."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.dropped_caps", Status: doctorPass, Message: strings.Join(docker.DroppedCaps, ",")})
+ }
+ if docker.Memory == "" || docker.CPUs == "" || docker.PidsLimit <= 0 {
+ checks = append(checks, doctorCheck{Name: "docker.resource_limits", Status: severityForProduction(production), Message: "memory, CPU, and pids limits must be configured", Remediation: "Set providers.docker.memory, providers.docker.cpus, and providers.docker.pids_limit."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.resource_limits", Status: doctorPass, Message: fmt.Sprintf("memory %s, cpus %s, pids %d", docker.Memory, docker.CPUs, docker.PidsLimit)})
+ }
+ if docker.User == "" {
+ checks = append(checks, doctorCheck{Name: "docker.user", Status: severityForProduction(production), Message: "containers may run as image default user", Remediation: "Set providers.docker.user to a non-root UID/GID such as 1000:1000 when images support it."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.user", Status: doctorPass, Message: docker.User})
+ }
+ return checks
+}
+
+func containsString(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/cmd/stacyvm/cmd_config_test.go b/cmd/stacyvm/cmd_config_test.go
new file mode 100644
index 0000000..6774678
--- /dev/null
+++ b/cmd/stacyvm/cmd_config_test.go
@@ -0,0 +1,313 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+)
+
+func TestLintConfigProductionBaselinePasses(t *testing.T) {
+ cfg := validProductionConfig()
+
+ checks := lintConfig(cfg, true)
+ for _, check := range checks {
+ if check.Status == doctorFail {
+ t.Fatalf("%s failed: %s", check.Name, check.Message)
+ }
+ }
+}
+
+func TestLintConfigProductionCatchesUnsafeSettings(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.Enabled = false
+ cfg.Auth.APIKey = "change-me-generate-at-least-32-bytes"
+ cfg.Auth.AdminAPIKey = cfg.Auth.APIKey
+ cfg.Auth.AdminFallbackEnabled = true
+ cfg.Auth.AdminAuditRetention = "0s"
+ cfg.Server.CORSAllowedOrigins = []string{"*"}
+ cfg.RateLimit.Enabled = false
+ cfg.Database.Path = "stacyvm.db"
+ cfg.Defaults.MaxSandboxes = 0
+ cfg.Defaults.DefaultExecTimeout = "0s"
+ cfg.Logging.Format = "console"
+ cfg.Providers.Docker.SeccompProfile = "unconfined"
+ cfg.Providers.Docker.AddedCaps = []string{"SYS_ADMIN"}
+ cfg.Providers.Docker.DroppedCaps = nil
+ cfg.Providers.Docker.PidsLimit = 0
+ cfg.Providers.Docker.User = ""
+
+ checks := lintConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ for _, name := range []string{
+ "auth.enabled",
+ "auth.api_key",
+ "auth.key_separation",
+ "auth.admin_fallback_enabled",
+ "auth.admin_audit_retention",
+ "server.cors_allowed_origins",
+ "rate_limit.enabled",
+ "database.path",
+ "defaults.sandbox_caps",
+ "defaults.exec_timeouts",
+ "logging.format",
+ "docker.seccomp_profile",
+ "docker.added_caps",
+ "docker.dropped_caps",
+ "docker.resource_limits",
+ "docker.user",
+ } {
+ if statuses[name] != doctorFail {
+ t.Fatalf("%s status = %s, want %s", name, statuses[name], doctorFail)
+ }
+ }
+}
+
+func TestLintDatabaseConfigPassesForPostgresWithDSN(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Database = config.DatabaseConfig{
+ Driver: "postgres",
+ DSN: "postgres://stacyvm@example/stacyvm",
+ }
+
+ checks := lintDatabaseConfig(cfg, true)
+ if len(checks) != 1 {
+ t.Fatalf("checks = %+v, want one check", checks)
+ }
+ if checks[0].Name != "database.driver" || checks[0].Status != doctorPass {
+ t.Fatalf("unexpected check: %+v", checks[0])
+ }
+}
+
+func TestLintServerConfigRequiresExplicitCORSInProduction(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Server.CORSAllowedOrigins = []string{"*"}
+
+ checks := lintServerConfig(cfg, true)
+ if len(checks) != 1 {
+ t.Fatalf("checks = %+v, want one check", checks)
+ }
+ if checks[0].Name != "server.cors_allowed_origins" || checks[0].Status != doctorFail {
+ t.Fatalf("unexpected check: %+v", checks[0])
+ }
+}
+
+func TestLintServerConfigAcceptsExplicitCORSOrigins(t *testing.T) {
+ cfg := validProductionConfig()
+
+ checks := lintServerConfig(cfg, true)
+ if len(checks) != 1 {
+ t.Fatalf("checks = %+v, want one check", checks)
+ }
+ if checks[0].Name != "server.cors_allowed_origins" || checks[0].Status != doctorPass {
+ t.Fatalf("unexpected check: %+v", checks[0])
+ }
+}
+
+func TestLintAuthConfigAcceptsWorkerSigningKey(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.WorkerSigningKey = "worker-signing-key-with-at-least-32-bytes"
+ cfg.Auth.WorkerTokens = map[string]string{"worker-a": "legacy-token"}
+
+ checks := lintAuthConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ if statuses["auth.worker_signing_key"] != doctorPass {
+ t.Fatalf("auth.worker_signing_key status = %s, want %s", statuses["auth.worker_signing_key"], doctorPass)
+ }
+ if statuses["auth.worker_tokens"] != doctorWarn {
+ t.Fatalf("auth.worker_tokens status = %s, want migration warning", statuses["auth.worker_tokens"])
+ }
+}
+
+func TestLintAuthConfigReportsWorkerSecretFileSources(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.WorkerSigningKey = "worker-signing-key-with-at-least-32-bytes"
+ cfg.Auth.WorkerSigningKeyFile = "/run/secrets/stacyvm-worker-signing-key"
+ cfg.Auth.WorkerToken = "shared-worker-token-with-at-least-32-bytes"
+ cfg.Auth.WorkerTokenFile = "/run/secrets/stacyvm-worker-token"
+
+ checks := lintAuthConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ if statuses["auth.worker_signing_key_file"] != doctorPass {
+ t.Fatalf("auth.worker_signing_key_file status = %s, want %s", statuses["auth.worker_signing_key_file"], doctorPass)
+ }
+ if statuses["auth.worker_token_file"] != doctorPass {
+ t.Fatalf("auth.worker_token_file status = %s, want %s", statuses["auth.worker_token_file"], doctorPass)
+ }
+
+ cfg.Auth.WorkerSigningKeyFile = ""
+ cfg.Auth.WorkerTokenFile = ""
+ checks = lintAuthConfig(cfg, true)
+ statuses = map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+ if statuses["auth.worker_signing_key_file"] != doctorWarn {
+ t.Fatalf("auth.worker_signing_key_file inline status = %s, want %s", statuses["auth.worker_signing_key_file"], doctorWarn)
+ }
+ if statuses["auth.worker_token_file"] != doctorWarn {
+ t.Fatalf("auth.worker_token_file inline status = %s, want %s", statuses["auth.worker_token_file"], doctorWarn)
+ }
+}
+
+func TestLintAuthConfigWarnsRevokedWorkerTokenIDsWithoutSigningKey(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.WorkerRevokedTokenIDs = []string{"revoked-token-id"}
+
+ checks := lintAuthConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ if statuses["auth.worker_revoked_token_ids"] != doctorWarn {
+ t.Fatalf("auth.worker_revoked_token_ids status = %s, want %s", statuses["auth.worker_revoked_token_ids"], doctorWarn)
+ }
+}
+
+func TestLintAuthConfigWarnsSharedWorkerTokenWithSigningKey(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.WorkerSigningKey = "worker-signing-key-with-at-least-32-bytes"
+ cfg.Auth.WorkerToken = "shared-worker-token-with-at-least-32-bytes"
+
+ checks := lintAuthConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ if statuses["auth.worker_token"] != doctorWarn {
+ t.Fatalf("auth.worker_token status = %s, want %s", statuses["auth.worker_token"], doctorWarn)
+ }
+}
+
+func TestLintAuthConfigWarnsInvalidWorkerSigningKeyRotation(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Auth.WorkerSigningKey = "worker-signing-key-with-at-least-32-bytes"
+ cfg.Auth.WorkerSigningKeys = []string{"worker-signing-key-with-at-least-32-bytes"}
+
+ checks := lintAuthConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ if statuses["auth.worker_signing_keys"] != doctorWarn {
+ t.Fatalf("auth.worker_signing_keys status = %s, want %s", statuses["auth.worker_signing_keys"], doctorWarn)
+ }
+
+ cfg.Auth.WorkerSigningKeys = []string{
+ "old-worker-signing-key-with-at-least-32-bytes",
+ "old-worker-signing-key-with-at-least-32-bytes",
+ }
+ checks = lintAuthConfig(cfg, true)
+ statuses = map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+ if statuses["auth.worker_signing_keys"] != doctorWarn {
+ t.Fatalf("auth.worker_signing_keys duplicate status = %s, want %s", statuses["auth.worker_signing_keys"], doctorWarn)
+ }
+}
+
+func TestLintWorkerRPCConfigChecksMTLSInputs(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Worker.RPCTLS.Enabled = true
+
+ checks := lintWorkerRPCConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+ for _, name := range []string{
+ "worker.rpc_tls.server_cert",
+ "worker.rpc_tls.client_ca",
+ "worker.rpc_tls.ca",
+ "worker.rpc_tls.client_cert",
+ } {
+ if statuses[name] != doctorFail {
+ t.Fatalf("%s status = %s, want %s", name, statuses[name], doctorFail)
+ }
+ }
+}
+
+func TestLintWorkerRPCConfigPassesCompleteMTLSInputs(t *testing.T) {
+ cfg := validProductionConfig()
+ cfg.Worker.RPCTLS = config.WorkerRPCTLSConfig{
+ Enabled: true,
+ ServerCertFile: "/etc/stacyvm/tls/worker.crt",
+ ServerKeyFile: "/etc/stacyvm/tls/worker.key",
+ ClientCAFile: "/etc/stacyvm/tls/control-plane-ca.crt",
+ CAFile: "/etc/stacyvm/tls/worker-ca.crt",
+ ClientCertFile: "/etc/stacyvm/tls/control-plane.crt",
+ ClientKeyFile: "/etc/stacyvm/tls/control-plane.key",
+ }
+
+ checks := lintWorkerRPCConfig(cfg, true)
+ for _, check := range checks {
+ if check.Status == doctorFail {
+ t.Fatalf("%s failed: %s", check.Name, check.Message)
+ }
+ }
+}
+
+func validProductionConfig() *config.Config {
+ return &config.Config{
+ Server: config.ServerConfig{
+ CORSAllowedOrigins: []string{"https://console.example.com"},
+ },
+ Auth: config.AuthConfig{
+ Enabled: true,
+ APIKey: "regular-api-key-with-at-least-32-bytes",
+ AdminAPIKey: "admin-api-key-with-at-least-32-bytesxx",
+ AdminFallbackEnabled: false,
+ AdminAuditRetention: "2160h",
+ },
+ RateLimit: config.RateLimitConfig{
+ Enabled: true,
+ RequestsPerMinute: 120,
+ Burst: 60,
+ KeyBy: "api_key",
+ },
+ Database: config.DatabaseConfig{
+ Path: "/var/lib/stacyvm/stacyvm.db",
+ },
+ Defaults: config.DefaultsConfig{
+ MaxSandboxes: 100,
+ MaxSandboxesPerOwner: 10,
+ MaxSpawnQueue: 100,
+ DefaultExecTimeout: "30s",
+ MaxExecTimeout: "10m",
+ MaxTTL: "24h",
+ },
+ Logging: config.LoggingConfig{
+ Format: "json",
+ },
+ Providers: config.ProvidersConfig{
+ Default: "docker",
+ Docker: config.DockerConfig{
+ Enabled: true,
+ Runtime: "runc",
+ NetworkMode: "stacyvm-network",
+ SeccompProfile: "default",
+ Memory: "512m",
+ CPUs: "1",
+ PidsLimit: 256,
+ User: "1000:1000",
+ DroppedCaps: []string{"ALL"},
+ AddedCaps: []string{},
+ },
+ },
+ }
+}
diff --git a/cmd/stacyvm/cmd_db.go b/cmd/stacyvm/cmd_db.go
new file mode 100644
index 0000000..792d248
--- /dev/null
+++ b/cmd/stacyvm/cmd_db.go
@@ -0,0 +1,375 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+ "github.com/spf13/cobra"
+ _ "modernc.org/sqlite"
+)
+
+func newDBCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "db",
+ Short: "Manage the StacyVM database (SQLite or Postgres)",
+ }
+ cmd.AddCommand(newDBBackupCmd(), newDBRestoreCmd(), newDBPgBackupCmd(), newDBPgRehearseCmd())
+ return cmd
+}
+
+func newDBBackupCmd() *cobra.Command {
+ var dbPath string
+ var force bool
+ cmd := &cobra.Command{
+ Use: "backup ",
+ Short: "Create a consistent SQLite backup",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ source, err := resolveDatabasePath(dbPath)
+ if err != nil {
+ return err
+ }
+ return backupSQLite(cmd.Context(), source, args[0], force)
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "database", "", "database path; defaults to configured database.path")
+ cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing backup output file")
+ return cmd
+}
+
+func newDBRestoreCmd() *cobra.Command {
+ var dbPath string
+ var yes bool
+ cmd := &cobra.Command{
+ Use: "restore ",
+ Short: "Restore SQLite database from a backup",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !yes {
+ return fmt.Errorf("restore replaces the active database; rerun with --yes after stopping stacyvm")
+ }
+ target, err := resolveDatabasePath(dbPath)
+ if err != nil {
+ return err
+ }
+ return restoreSQLite(cmd.Context(), args[0], target)
+ },
+ }
+ cmd.Flags().StringVar(&dbPath, "database", "", "database path; defaults to configured database.path")
+ cmd.Flags().BoolVar(&yes, "yes", false, "confirm the service is stopped and restore should proceed")
+ return cmd
+}
+
+func resolveDatabasePath(override string) (string, error) {
+ if override != "" {
+ return filepath.Abs(override)
+ }
+ cfg, err := config.Load()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Abs(cfg.Database.Path)
+}
+
+func backupSQLite(ctx context.Context, source, output string, force bool) error {
+ sourceAbs, err := filepath.Abs(source)
+ if err != nil {
+ return err
+ }
+ outputAbs, err := filepath.Abs(output)
+ if err != nil {
+ return err
+ }
+ if _, err := os.Stat(sourceAbs); err != nil {
+ return fmt.Errorf("source database unavailable: %w", err)
+ }
+ if _, err := os.Stat(outputAbs); err == nil && !force {
+ return fmt.Errorf("backup output already exists: %s; use --force to overwrite", outputAbs)
+ } else if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(outputAbs), 0750); err != nil {
+ return err
+ }
+ if force {
+ if err := os.Remove(outputAbs); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+
+ db, err := sql.Open("sqlite", sourceAbs+"?mode=ro&_busy_timeout=5000")
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ if _, err := db.ExecContext(ctx, "VACUUM INTO ?", outputAbs); err != nil {
+ return fmt.Errorf("creating sqlite backup: %w", err)
+ }
+ if err := checkSQLiteIntegrity(ctx, outputAbs); err != nil {
+ return fmt.Errorf("backup integrity check failed: %w", err)
+ }
+ fmt.Printf("backup written: %s\n", outputAbs)
+ return nil
+}
+
+func restoreSQLite(ctx context.Context, backup, target string) error {
+ backupAbs, err := filepath.Abs(backup)
+ if err != nil {
+ return err
+ }
+ targetAbs, err := filepath.Abs(target)
+ if err != nil {
+ return err
+ }
+ if err := checkSQLiteIntegrity(ctx, backupAbs); err != nil {
+ return fmt.Errorf("backup integrity check failed: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(targetAbs), 0750); err != nil {
+ return err
+ }
+ if _, err := os.Stat(targetAbs); err == nil {
+ safety := targetAbs + ".pre-restore-" + time.Now().UTC().Format("20060102T150405Z")
+ if err := copyFile(targetAbs, safety, 0600); err != nil {
+ return fmt.Errorf("creating pre-restore safety copy: %w", err)
+ }
+ fmt.Printf("existing database safety copy: %s\n", safety)
+ } else if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ for _, suffix := range []string{"-wal", "-shm"} {
+ if err := os.Remove(targetAbs + suffix); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("removing stale sqlite sidecar %s: %w", targetAbs+suffix, err)
+ }
+ }
+ if err := copyFile(backupAbs, targetAbs, 0600); err != nil {
+ return fmt.Errorf("restoring database: %w", err)
+ }
+ fmt.Printf("database restored: %s\n", targetAbs)
+ return nil
+}
+
+func checkSQLiteIntegrity(ctx context.Context, path string) error {
+ if _, err := os.Stat(path); err != nil {
+ return err
+ }
+ db, err := sql.Open("sqlite", path+"?mode=ro&_busy_timeout=5000")
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ var result string
+ if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&result); err != nil {
+ return err
+ }
+ if result != "ok" {
+ return fmt.Errorf("integrity_check returned %q", result)
+ }
+ return nil
+}
+
+// newDBPgBackupCmd creates a Postgres backup using pg_dump.
+func newDBPgBackupCmd() *cobra.Command {
+ var dsn string
+ var format string
+ var cfgFile string
+ cmd := &cobra.Command{
+ Use: "pg-backup ",
+ Short: "Backup a Postgres database using pg_dump",
+ Long: `Creates a Postgres backup using pg_dump.
+
+Requires pg_dump to be installed. The DSN can be provided via --dsn,
+STACYVM_DATABASE_DSN environment variable, or from the config file.
+
+Example:
+ stacyvm db pg-backup backup-$(date +%Y%m%d).sql
+ stacyvm db pg-backup --format custom backup.dump`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ output := args[0]
+ if dsn == "" {
+ var cfg *config.Config
+ var err error
+ if cfgFile != "" {
+ cfg, err = config.LoadFile(cfgFile)
+ } else {
+ cfg, err = config.Load()
+ }
+ if err == nil {
+ dsn = cfg.Database.DSN
+ }
+ }
+ if dsn == "" {
+ dsn = os.Getenv("STACYVM_DATABASE_DSN")
+ }
+ if dsn == "" {
+ return fmt.Errorf("no Postgres DSN configured; use --dsn or set database.dsn / STACYVM_DATABASE_DSN")
+ }
+ return pgBackup(cmd.Context(), dsn, output, format)
+ },
+ }
+ cmd.Flags().StringVar(&dsn, "dsn", "", "Postgres DSN (overrides config)")
+ cmd.Flags().StringVar(&format, "format", "plain", "pg_dump format: plain, custom, directory, tar")
+ cmd.Flags().StringVar(&cfgFile, "config", "", "config file path")
+ return cmd
+}
+
+// newDBPgRehearseCmd runs a Postgres migration rehearsal (dry-run apply + rollback check).
+func newDBPgRehearseCmd() *cobra.Command {
+ var dsn string
+ var cfgFile string
+ cmd := &cobra.Command{
+ Use: "pg-rehearse",
+ Short: "Rehearse Postgres migration safety: verify all migrations apply cleanly",
+ Long: `Connects to the Postgres database and verifies that all pending migrations
+can be applied. This is a read-only rehearsal check — it reports the current
+schema version and the next migration versions that would be applied.
+
+Run this before every enterprise production upgrade:
+
+ stacyvm db pg-rehearse --dsn
+ stacyvm db pg-rehearse --config stacyvm.yaml`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dsn == "" {
+ var cfg *config.Config
+ var err error
+ if cfgFile != "" {
+ cfg, err = config.LoadFile(cfgFile)
+ } else {
+ cfg, err = config.Load()
+ }
+ if err == nil {
+ dsn = cfg.Database.DSN
+ }
+ }
+ if dsn == "" {
+ dsn = os.Getenv("STACYVM_DATABASE_DSN")
+ }
+ if dsn == "" {
+ return fmt.Errorf("no Postgres DSN configured; use --dsn or set database.dsn / STACYVM_DATABASE_DSN")
+ }
+ return pgRehearseCheck(cmd.Context(), dsn)
+ },
+ }
+ cmd.Flags().StringVar(&dsn, "dsn", "", "Postgres DSN (overrides config)")
+ cmd.Flags().StringVar(&cfgFile, "config", "", "config file path")
+ return cmd
+}
+
+func pgBackup(ctx context.Context, dsn, output, format string) error {
+ pgDump, err := exec.LookPath("pg_dump")
+ if err != nil {
+ return fmt.Errorf("pg_dump not found in PATH; install postgresql-client to use Postgres backups")
+ }
+
+ formatFlag := "--format=" + format
+ args := []string{formatFlag, "--file=" + output, dsn}
+
+ cmd := exec.CommandContext(ctx, pgDump, args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ fmt.Printf("running: pg_dump %s\n", strings.Join(args, " "))
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("pg_dump failed: %w", err)
+ }
+ fmt.Printf("backup written: %s\n", output)
+ return nil
+}
+
+// expectedTables are the production-aligned tables that must exist after migration.
+var expectedPostgresTables = []string{
+ "sandboxes", "exec_logs", "provider_configs", "templates",
+ "environment_specs", "environment_builds", "environment_artifacts",
+ "registry_connections", "owner_quotas", "admin_audit_logs",
+ "operation_audit_logs", "workers", "leases",
+ "tenants", "tenant_members", "policies",
+}
+
+func pgRehearseCheck(ctx context.Context, dsn string) error {
+ db, err := sql.Open("pgx", dsn)
+ if err != nil {
+ return fmt.Errorf("connecting to Postgres: %w", err)
+ }
+ defer db.Close()
+
+ if err := db.PingContext(ctx); err != nil {
+ return fmt.Errorf("Postgres connection failed: %w", err)
+ }
+ fmt.Println("connection: OK")
+
+ // Check applied migrations.
+ rows, err := db.QueryContext(ctx, "SELECT version FROM schema_migrations ORDER BY version ASC")
+ if err != nil {
+ fmt.Println("schema_migrations: not found — database is uninitialized (will be created on first startup)")
+ fmt.Println("pg-rehearse: PASS (fresh database)")
+ return nil
+ }
+ defer rows.Close()
+
+ var applied []int
+ for rows.Next() {
+ var v int
+ if err := rows.Scan(&v); err != nil {
+ return err
+ }
+ applied = append(applied, v)
+ }
+ if err := rows.Err(); err != nil {
+ return err
+ }
+ fmt.Printf("schema_migrations: %d applied — versions %v\n", len(applied), applied)
+
+ // Verify that all expected tables exist.
+ missing := []string{}
+ for _, table := range expectedPostgresTables {
+ var exists bool
+ err := db.QueryRowContext(ctx,
+ `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1)`,
+ table,
+ ).Scan(&exists)
+ if err != nil {
+ return fmt.Errorf("checking table %q: %w", table, err)
+ }
+ if !exists {
+ missing = append(missing, table)
+ }
+ }
+
+ if len(missing) > 0 {
+ return fmt.Errorf("pg-rehearse: FAIL — missing tables: %s\nRun the server once to apply migrations, or check migration history",
+ strings.Join(missing, ", "))
+ }
+
+ fmt.Printf("tables: all %d expected tables present\n", len(expectedPostgresTables))
+ fmt.Println("pg-rehearse: PASS — schema is production-aligned")
+ return nil
+}
+
+func copyFile(src, dst string, perm os.FileMode) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, in); err != nil {
+ return err
+ }
+ return out.Sync()
+}
diff --git a/cmd/stacyvm/cmd_db_test.go b/cmd/stacyvm/cmd_db_test.go
new file mode 100644
index 0000000..32f0f94
--- /dev/null
+++ b/cmd/stacyvm/cmd_db_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestBackupAndRestoreSQLite(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ source := filepath.Join(dir, "stacyvm.db")
+ backup := filepath.Join(dir, "backup", "stacyvm.db")
+ target := filepath.Join(dir, "restored.db")
+
+ writeTestSQLite(t, source, "phase8")
+ if err := backupSQLite(ctx, source, backup, false); err != nil {
+ t.Fatalf("backup sqlite: %v", err)
+ }
+ if err := checkSQLiteIntegrity(ctx, backup); err != nil {
+ t.Fatalf("backup integrity: %v", err)
+ }
+
+ writeTestSQLite(t, target, "old")
+ if err := os.WriteFile(target+"-wal", []byte("stale wal"), 0600); err != nil {
+ t.Fatalf("write stale wal: %v", err)
+ }
+ if err := os.WriteFile(target+"-shm", []byte("stale shm"), 0600); err != nil {
+ t.Fatalf("write stale shm: %v", err)
+ }
+ if err := restoreSQLite(ctx, backup, target); err != nil {
+ t.Fatalf("restore sqlite: %v", err)
+ }
+ got := readTestSQLiteValue(t, target)
+ if got != "phase8" {
+ t.Fatalf("restored value = %q, want phase8", got)
+ }
+ matches, err := filepath.Glob(target + ".pre-restore-*")
+ if err != nil {
+ t.Fatalf("glob safety copy: %v", err)
+ }
+ if len(matches) != 1 {
+ t.Fatalf("expected one safety copy, got %d", len(matches))
+ }
+ for _, sidecar := range []string{target + "-wal", target + "-shm"} {
+ if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
+ t.Fatalf("expected stale sidecar %s to be removed, got %v", sidecar, err)
+ }
+ }
+}
+
+func TestBackupSQLiteRefusesOverwriteWithoutForce(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ source := filepath.Join(dir, "stacyvm.db")
+ backup := filepath.Join(dir, "backup.db")
+ writeTestSQLite(t, source, "phase8")
+ if err := os.WriteFile(backup, []byte("exists"), 0600); err != nil {
+ t.Fatalf("write existing backup: %v", err)
+ }
+
+ err := backupSQLite(ctx, source, backup, false)
+ if err == nil || !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("expected overwrite refusal, got %v", err)
+ }
+ if err := backupSQLite(ctx, source, backup, true); err != nil {
+ t.Fatalf("backup with force: %v", err)
+ }
+}
+
+func writeTestSQLite(t *testing.T, path, value string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sanity (value TEXT NOT NULL)`); err != nil {
+ t.Fatalf("create table: %v", err)
+ }
+ if _, err := db.Exec(`DELETE FROM sanity`); err != nil {
+ t.Fatalf("delete table: %v", err)
+ }
+ if _, err := db.Exec(`INSERT INTO sanity (value) VALUES (?)`, value); err != nil {
+ t.Fatalf("insert value: %v", err)
+ }
+}
+
+func readTestSQLiteValue(t *testing.T, path string) string {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ defer db.Close()
+ var value string
+ if err := db.QueryRow(`SELECT value FROM sanity LIMIT 1`).Scan(&value); err != nil {
+ t.Fatalf("select value: %v", err)
+ }
+ return value
+}
diff --git a/cmd/stacyvm/cmd_doctor.go b/cmd/stacyvm/cmd_doctor.go
new file mode 100644
index 0000000..f5b35c1
--- /dev/null
+++ b/cmd/stacyvm/cmd_doctor.go
@@ -0,0 +1,234 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+ "github.com/spf13/cobra"
+)
+
+type doctorStatus string
+
+const (
+ doctorPass doctorStatus = "PASS"
+ doctorWarn doctorStatus = "WARN"
+ doctorFail doctorStatus = "FAIL"
+)
+
+type doctorCheck struct {
+ Name string
+ Status doctorStatus
+ Message string
+ Remediation string
+}
+
+func newDoctorCmd() *cobra.Command {
+ var production bool
+ cmd := &cobra.Command{
+ Use: "doctor",
+ Short: "Run local production-readiness diagnostics",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ checks := runDoctor(cmd.Context(), production)
+ failed := printDoctorChecks(checks)
+ if failed > 0 {
+ return fmt.Errorf("doctor found %d failing check(s)", failed)
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&production, "production", false, "treat production hardening warnings as failures")
+ return cmd
+}
+
+func runDoctor(ctx context.Context, production bool) []doctorCheck {
+ var checks []doctorCheck
+
+ cfg, err := config.Load()
+ if err != nil {
+ return []doctorCheck{{
+ Name: "config",
+ Status: doctorFail,
+ Message: err.Error(),
+ Remediation: "Check config file/env values; see docs/configuration.md.",
+ }}
+ }
+
+ checks = append(checks,
+ checkConfig(cfg, production)...,
+ )
+ checks = append(checks,
+ checkDocker(ctx, cfg)...,
+ )
+ checks = append(checks,
+ checkFirecracker(cfg)...,
+ )
+ checks = append(checks,
+ checkPRoot(cfg)...,
+ )
+ return checks
+}
+
+func checkConfig(cfg *config.Config, production bool) []doctorCheck {
+ checks := []doctorCheck{
+ {Name: "config", Status: doctorPass, Message: "loaded successfully"},
+ }
+
+ if cfg.Auth.APIKey == "" {
+ checks = append(checks, doctorCheck{Name: "auth.api_key", Status: severityForProduction(production), Message: "missing API key; endpoints are unauthenticated", Remediation: "Set STACYVM_AUTH_API_KEY or auth.api_key to a random 32+ byte secret."})
+ } else if len(cfg.Auth.APIKey) < 32 {
+ checks = append(checks, doctorCheck{Name: "auth.api_key", Status: severityForProduction(production), Message: "API key is shorter than the recommended 32 bytes", Remediation: "Rotate auth.api_key to a random 32+ byte value before production."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.api_key", Status: doctorPass, Message: "configured"})
+ }
+
+ if cfg.Auth.AdminAPIKey == "" {
+ checks = append(checks, doctorCheck{Name: "auth.admin_api_key", Status: severityForProduction(production), Message: "missing dedicated admin API key", Remediation: "Set STACYVM_AUTH_ADMIN_API_KEY to a separate random 32+ byte secret."})
+ } else if cfg.Auth.AdminAPIKey == cfg.Auth.APIKey {
+ checks = append(checks, doctorCheck{Name: "auth.admin_api_key", Status: severityForProduction(production), Message: "admin API key matches regular API key", Remediation: "Use separate regular and admin keys so admin routes are isolated."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.admin_api_key", Status: doctorPass, Message: "configured separately"})
+ }
+
+ if cfg.Auth.AdminFallbackEnabled {
+ checks = append(checks, doctorCheck{Name: "auth.admin_fallback_enabled", Status: severityForProduction(production), Message: "admin fallback is enabled; production should require a dedicated admin key", Remediation: "Set auth.admin_fallback_enabled=false in production."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "auth.admin_fallback_enabled", Status: doctorPass, Message: "disabled"})
+ }
+
+ driver := strings.ToLower(strings.TrimSpace(cfg.Database.Driver))
+ if driver == "" {
+ driver = "sqlite"
+ }
+ if driver == "postgres" || driver == "postgresql" {
+ checks = append(checks, doctorCheck{Name: "database.driver", Status: doctorWarn, Message: "postgres configured, but this build does not link a postgres store driver yet", Remediation: "Use sqlite for this build or deploy a build that includes the postgres store driver."})
+ } else {
+ dbDir := filepath.Dir(cfg.Database.Path)
+ if dbDir == "." || dbDir == "" {
+ checks = append(checks, doctorCheck{Name: "database.path", Status: doctorWarn, Message: "database path is relative; production should use persistent storage", Remediation: "Use an absolute database.path on durable disk and test backup/restore."})
+ } else if info, err := os.Stat(dbDir); err != nil {
+ checks = append(checks, doctorCheck{Name: "database.path", Status: severityForProduction(production), Message: fmt.Sprintf("database directory unavailable: %v", err), Remediation: "Create the database parent directory and ensure the StacyVM process can write to it."})
+ } else if !info.IsDir() {
+ checks = append(checks, doctorCheck{Name: "database.path", Status: doctorFail, Message: "database parent path is not a directory", Remediation: "Point database.path at a file whose parent is a real directory."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "database.path", Status: doctorPass, Message: dbDir})
+ }
+ }
+
+ return checks
+}
+
+func checkDocker(ctx context.Context, cfg *config.Config) []doctorCheck {
+ if !cfg.Providers.Docker.Enabled && cfg.Providers.Default != "docker" {
+ return []doctorCheck{{Name: "docker", Status: doctorWarn, Message: "Docker provider is not enabled/default", Remediation: "Enable Docker only on hosts intended to run Docker sandboxes."}}
+ }
+
+ var checks []doctorCheck
+ if _, err := exec.LookPath("docker"); err != nil {
+ return []doctorCheck{{Name: "docker.cli", Status: doctorFail, Message: "docker CLI not found in PATH", Remediation: "Install Docker CLI or disable the Docker provider."}}
+ }
+ checks = append(checks, doctorCheck{Name: "docker.cli", Status: doctorPass, Message: "found"})
+
+ if out, err := runDoctorCommand(ctx, 3*time.Second, "docker", "info", "--format", "{{.ServerVersion}}"); err != nil {
+ checks = append(checks, doctorCheck{Name: "docker.daemon", Status: doctorFail, Message: strings.TrimSpace(err.Error() + " " + out), Remediation: "Start Docker and ensure the StacyVM user can reach the Docker daemon."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.daemon", Status: doctorPass, Message: "server " + strings.TrimSpace(out)})
+ }
+
+ if cfg.Providers.Docker.NetworkMode == "" {
+ checks = append(checks, doctorCheck{Name: "docker.network_mode", Status: doctorWarn, Message: "empty network mode; explicit mode is recommended", Remediation: "Set providers.docker.network_mode explicitly; prefer none/allowlisted networking for untrusted workloads."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.network_mode", Status: doctorPass, Message: cfg.Providers.Docker.NetworkMode})
+ }
+ if len(cfg.Providers.Docker.DroppedCaps) == 0 {
+ checks = append(checks, doctorCheck{Name: "docker.dropped_caps", Status: doctorWarn, Message: "no dropped capabilities configured", Remediation: "Configure dropped capabilities, seccomp, pids, memory, and CPU limits before production."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "docker.dropped_caps", Status: doctorPass, Message: strings.Join(cfg.Providers.Docker.DroppedCaps, ",")})
+ }
+ return checks
+}
+
+func checkFirecracker(cfg *config.Config) []doctorCheck {
+ if !cfg.Providers.Firecracker.Enabled && cfg.Providers.Default != "firecracker" {
+ return []doctorCheck{{Name: "firecracker", Status: doctorWarn, Message: "Firecracker provider is not enabled/default", Remediation: "Enable Firecracker only on Linux/KVM hosts prepared for VM workloads."}}
+ }
+
+ checks := []doctorCheck{}
+ if _, err := exec.LookPath(filepath.Base(cfg.Providers.Firecracker.FirecrackerPath)); err != nil {
+ if _, statErr := os.Stat(cfg.Providers.Firecracker.FirecrackerPath); statErr != nil {
+ checks = append(checks, doctorCheck{Name: "firecracker.binary", Status: doctorFail, Message: "Firecracker binary unavailable", Remediation: "Install Firecracker or set providers.firecracker.firecracker_path."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "firecracker.binary", Status: doctorPass, Message: cfg.Providers.Firecracker.FirecrackerPath})
+ }
+ } else {
+ checks = append(checks, doctorCheck{Name: "firecracker.binary", Status: doctorPass, Message: "found in PATH"})
+ }
+
+ checks = append(checks, fileCheck("firecracker.kvm", "/dev/kvm", false))
+ checks = append(checks, fileCheck("firecracker.kernel", cfg.Providers.Firecracker.KernelPath, false))
+ checks = append(checks, fileCheck("firecracker.agent", cfg.Providers.Firecracker.AgentPath, false))
+ return checks
+}
+
+func checkPRoot(cfg *config.Config) []doctorCheck {
+ if !cfg.Providers.PRoot.Enabled && cfg.Providers.Default != "proot" {
+ return []doctorCheck{{Name: "proot", Status: doctorWarn, Message: "PRoot provider is not enabled/default", Remediation: "Enable PRoot only on hosts with proot, rootfs, and workspace base configured."}}
+ }
+
+ var checks []doctorCheck
+ if _, err := exec.LookPath(cfg.Providers.PRoot.PRootBinary); err != nil {
+ checks = append(checks, doctorCheck{Name: "proot.binary", Status: doctorFail, Message: "proot binary unavailable", Remediation: "Install proot or set providers.proot.proot_binary."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "proot.binary", Status: doctorPass, Message: cfg.Providers.PRoot.PRootBinary})
+ }
+ checks = append(checks, fileCheck("proot.rootfs", cfg.Providers.PRoot.RootfsPath, true))
+ checks = append(checks, fileCheck("proot.workspace_base", cfg.Providers.PRoot.WorkspaceBase, true))
+ return checks
+}
+
+func fileCheck(name, path string, wantDir bool) doctorCheck {
+ if strings.TrimSpace(path) == "" {
+ return doctorCheck{Name: name, Status: doctorFail, Message: "path is empty", Remediation: "Configure this path before enabling the provider."}
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return doctorCheck{Name: name, Status: doctorFail, Message: err.Error(), Remediation: "Create the path or update provider configuration to the correct location."}
+ }
+ if wantDir && !info.IsDir() {
+ return doctorCheck{Name: name, Status: doctorFail, Message: "path is not a directory", Remediation: "Configure a directory path for this check."}
+ }
+ return doctorCheck{Name: name, Status: doctorPass, Message: path}
+}
+
+func severityForProduction(production bool) doctorStatus {
+ if production {
+ return doctorFail
+ }
+ return doctorWarn
+}
+
+func runDoctorCommand(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
+ cmdCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ out, err := exec.CommandContext(cmdCtx, name, args...).CombinedOutput()
+ return string(out), err
+}
+
+func printDoctorChecks(checks []doctorCheck) int {
+ failed := 0
+ for _, check := range checks {
+ if check.Status == doctorFail {
+ failed++
+ }
+ fmt.Printf("[%s] %s: %s\n", check.Status, check.Name, check.Message)
+ if check.Status != doctorPass && check.Remediation != "" {
+ fmt.Printf(" fix: %s\n", check.Remediation)
+ }
+ }
+ return failed
+}
diff --git a/cmd/stacyvm/cmd_doctor_test.go b/cmd/stacyvm/cmd_doctor_test.go
new file mode 100644
index 0000000..9cecf0c
--- /dev/null
+++ b/cmd/stacyvm/cmd_doctor_test.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+)
+
+func TestSeverityForProduction(t *testing.T) {
+ if got := severityForProduction(false); got != doctorWarn {
+ t.Fatalf("non-production severity = %s, want %s", got, doctorWarn)
+ }
+ if got := severityForProduction(true); got != doctorFail {
+ t.Fatalf("production severity = %s, want %s", got, doctorFail)
+ }
+}
+
+func TestCheckConfigProductionAuthPosture(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.Auth.APIKey = "short"
+ cfg.Auth.AdminAPIKey = "short"
+ cfg.Auth.AdminFallbackEnabled = true
+ cfg.Database.Path = "stacyvm.db"
+
+ checks := checkConfig(cfg, true)
+ statuses := map[string]doctorStatus{}
+ for _, check := range checks {
+ statuses[check.Name] = check.Status
+ }
+
+ for _, name := range []string{"auth.api_key", "auth.admin_api_key", "auth.admin_fallback_enabled"} {
+ if statuses[name] != doctorFail {
+ t.Fatalf("%s status = %s, want %s", name, statuses[name], doctorFail)
+ }
+ }
+}
diff --git a/cmd/stacyvm/cmd_exec.go b/cmd/stacyvm/cmd_exec.go
index 9ca97f5..48af42a 100644
--- a/cmd/stacyvm/cmd_exec.go
+++ b/cmd/stacyvm/cmd_exec.go
@@ -9,30 +9,43 @@ import (
)
func newExecCmd() *cobra.Command {
+ var shell bool
cmd := &cobra.Command{
Use: "exec -- ",
Short: "Execute a command in a sandbox",
Example: ` stacyvm exec sb-a1b2c3d4 -- echo hello
- stacyvm exec sb-a1b2c3d4 -- ls -la`,
+ stacyvm exec sb-a1b2c3d4 -- ls -la
+ stacyvm exec sb-a1b2c3d4 --shell -- "echo $HOME && pwd"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
sandboxID := args[0]
- // Find everything after "--"
- var command string
+ // Find everything after "--".
+ var commandArgs []string
dashIdx := cmd.ArgsLenAtDash()
if dashIdx >= 0 && dashIdx < len(args) {
- command = strings.Join(args[dashIdx:], " ")
+ commandArgs = args[dashIdx:]
} else if len(args) > 1 {
- command = strings.Join(args[1:], " ")
+ commandArgs = args[1:]
} else {
return fmt.Errorf("no command specified; use: stacyvm exec -- ")
}
+ if len(commandArgs) == 0 {
+ return fmt.Errorf("no command specified; use: stacyvm exec -- ")
+ }
+
+ req := orchestrator.ExecRequest{Mode: "argv", Command: commandArgs[0]}
+ if len(commandArgs) > 1 {
+ req.Args = commandArgs[1:]
+ }
+ if shell {
+ req.Mode = "shell"
+ req.Command = strings.Join(commandArgs, " ")
+ req.Args = nil
+ }
c := getClient()
- resp, err := c.do("POST", "/api/v1/sandboxes/"+sandboxID+"/exec", orchestrator.ExecRequest{
- Command: command,
- })
+ resp, err := c.do("POST", "/api/v1/sandboxes/"+sandboxID+"/exec", req)
if err != nil {
return err
}
@@ -54,5 +67,6 @@ func newExecCmd() *cobra.Command {
return nil
},
}
+ cmd.Flags().BoolVar(&shell, "shell", false, "run the command through /bin/sh -c instead of argv mode")
return cmd
}
diff --git a/cmd/stacyvm/cmd_serve.go b/cmd/stacyvm/cmd_serve.go
index 52c0763..799f665 100644
--- a/cmd/stacyvm/cmd_serve.go
+++ b/cmd/stacyvm/cmd_serve.go
@@ -4,10 +4,12 @@ import (
"context"
"os"
"os/signal"
+ "strings"
"syscall"
"time"
"github.com/StacyOs/stacyvm/internal/api"
+ "github.com/StacyOs/stacyvm/internal/api/middleware"
"github.com/StacyOs/stacyvm/internal/config"
"github.com/StacyOs/stacyvm/internal/environments"
"github.com/StacyOs/stacyvm/internal/orchestrator"
@@ -46,17 +48,37 @@ func runServe() error {
}
// Store
- st, err := store.NewSQLiteStore(cfg.Database.Path)
+ st, err := store.Open(store.Config{
+ Driver: cfg.Database.Driver,
+ Path: cfg.Database.Path,
+ DSN: cfg.Database.DSN,
+ })
if err != nil {
return err
}
defer st.Close()
- // Event bus
+ // Event bus — attach a durable Postgres LISTEN/NOTIFY bridge when running
+ // in Postgres mode so events reach all control-plane replicas in HA setups.
events := orchestrator.NewEventBus()
+ if strings.EqualFold(strings.TrimSpace(cfg.Database.Driver), "postgres") ||
+ strings.EqualFold(strings.TrimSpace(cfg.Database.Driver), "postgresql") {
+ bridge, bridgeErr := orchestrator.NewDurableBridge(context.Background(), cfg.Database.DSN, events, logger)
+ if bridgeErr != nil {
+ logger.Warn().Err(bridgeErr).Msg("durable event bridge unavailable; events will not cross control-plane replicas")
+ } else {
+ defer bridge.Stop()
+ logger.Info().Msg("durable event bridge attached (Postgres LISTEN/NOTIFY)")
+ }
+ }
// Provider registry
registry := providers.NewRegistry()
+ if cfg.Providers.Mock.Enabled {
+ mock := providers.NewMockProvider()
+ registry.Register(mock)
+ logger.Info().Msg("mock provider registered")
+ }
if cfg.Providers.Firecracker.Enabled {
fc := providers.NewFirecrackerProvider(providers.FirecrackerProviderConfig{
FirecrackerPath: cfg.Providers.Firecracker.FirecrackerPath,
@@ -163,6 +185,10 @@ func runServe() error {
// Manager
ttl, _ := time.ParseDuration(cfg.Defaults.TTL)
+ maxTTL, _ := time.ParseDuration(cfg.Defaults.MaxTTL)
+ defaultExecTimeout, _ := time.ParseDuration(cfg.Defaults.DefaultExecTimeout)
+ maxExecTimeout, _ := time.ParseDuration(cfg.Defaults.MaxExecTimeout)
+ spawnQueueTimeout, _ := time.ParseDuration(cfg.Defaults.SpawnQueueTimeout)
mgr := orchestrator.NewManager(registry, st, events, logger, orchestrator.ManagerConfig{
DefaultTTL: ttl,
DefaultImage: cfg.Defaults.Image,
@@ -170,7 +196,24 @@ func runServe() error {
DefaultVCPUs: cfg.Defaults.VCPUs,
Pool: cfg.Pool,
PreviewDomain: cfg.Server.PreviewDomain,
+ Limits: orchestrator.OperationalLimits{
+ MaxSandboxes: cfg.Defaults.MaxSandboxes,
+ MaxSandboxesPerOwner: cfg.Defaults.MaxSandboxesPerOwner,
+ DefaultExecTimeout: defaultExecTimeout,
+ MaxExecTimeout: maxExecTimeout,
+ MaxTTL: maxTTL,
+ SpawnOverflow: cfg.Defaults.SpawnOverflow,
+ SpawnQueueTimeout: spawnQueueTimeout,
+ MaxSpawnQueue: cfg.Defaults.MaxSpawnQueue,
+ },
+ WorkerToken: cfg.Auth.WorkerToken,
+ WorkerSigningKey: cfg.Auth.WorkerSigningKey,
+ WorkerRevokedTokenIDs: cfg.Auth.WorkerRevokedTokenIDs,
+ WorkerRPCTLS: workerTLSConfig(cfg.Worker.RPCTLS),
})
+ if err := mgr.Reconcile(context.Background()); err != nil {
+ return err
+ }
mgr.Start()
mgr.InitVMPool()
defer mgr.Stop()
@@ -194,10 +237,41 @@ func runServe() error {
}
// Server
+ rateLimitBucketTTL, _ := time.ParseDuration(cfg.RateLimit.BucketTTL)
+ rateLimitCleanupInterval, _ := time.ParseDuration(cfg.RateLimit.CleanupInterval)
+ adminAuditRetention, _ := time.ParseDuration(cfg.Auth.AdminAuditRetention)
srv := api.NewServer(api.ServerConfig{
- Addr: cfg.Server.Addr(),
- APIKey: cfg.Auth.APIKey,
- Version: version,
+ Addr: cfg.Server.Addr(),
+ APIKey: cfg.Auth.APIKey,
+ AdminAPIKey: cfg.Auth.AdminAPIKey,
+ AdminFallbackDisabled: !cfg.Auth.AdminFallbackEnabled,
+ AdminAuditRetention: adminAuditRetention,
+ CORSAllowedOrigins: cfg.Server.CORSAllowedOrigins,
+ WorkerToken: cfg.Auth.WorkerToken,
+ WorkerTokens: cfg.Auth.WorkerTokens,
+ WorkerSigningKey: cfg.Auth.WorkerSigningKey,
+ WorkerSigningKeys: cfg.Auth.WorkerSigningKeys,
+ WorkerRevokedTokenIDs: cfg.Auth.WorkerRevokedTokenIDs,
+ Version: version,
+ RateLimit: middleware.RateLimitConfig{
+ Enabled: cfg.RateLimit.Enabled,
+ RequestsPerMinute: cfg.RateLimit.RequestsPerMinute,
+ Burst: cfg.RateLimit.Burst,
+ KeyBy: cfg.RateLimit.KeyBy,
+ BucketTTL: rateLimitBucketTTL,
+ CleanupInterval: rateLimitCleanupInterval,
+ },
+ OIDC: middleware.OIDCConfig{
+ Issuer: cfg.Auth.OIDCIssuer,
+ Audience: cfg.Auth.OIDCAudience,
+ JWKSUrl: cfg.Auth.OIDCJWKSUrl,
+ PublicKeyPEM: cfg.Auth.OIDCPublicKey,
+ GroupsClaim: cfg.Auth.OIDCGroupsClaim,
+ TenantClaim: cfg.Auth.OIDCTenantClaim,
+ AdminGroups: cfg.Auth.OIDCAdminGroups,
+ OperatorGroups: cfg.Auth.OIDCOperatorGroups,
+ ViewerGroups: cfg.Auth.OIDCViewerGroups,
+ },
}, registry, mgr, events, templates, pool, st, envBuilds, logger)
// Graceful shutdown
diff --git a/cmd/stacyvm/cmd_support.go b/cmd/stacyvm/cmd_support.go
new file mode 100644
index 0000000..117a202
--- /dev/null
+++ b/cmd/stacyvm/cmd_support.go
@@ -0,0 +1,221 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/config"
+ "github.com/spf13/cobra"
+)
+
+type supportBundle struct {
+ GeneratedAt string `json:"generated_at"`
+ Version string `json:"version"`
+ Runtime map[string]string `json:"runtime"`
+ Config map[string]interface{} `json:"config,omitempty"`
+ ConfigLint []doctorCheck `json:"config_lint,omitempty"`
+ Doctor []doctorCheck `json:"doctor,omitempty"`
+ ServerDiagnostics map[string]interface{} `json:"server_diagnostics,omitempty"`
+ CollectionErrors []string `json:"collection_errors,omitempty"`
+ Redactions []string `json:"redactions"`
+}
+
+func newSupportCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "support",
+ Short: "Collect redacted support diagnostics",
+ }
+ cmd.AddCommand(newSupportBundleCmd())
+ return cmd
+}
+
+func newSupportBundleCmd() *cobra.Command {
+ var configPath string
+ var includeDoctor bool
+ var includeServer bool
+ cmd := &cobra.Command{
+ Use: "bundle ",
+ Short: "Write a redacted support bundle",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ bundle := collectSupportBundle(cmd.Context(), supportBundleOptions{
+ ConfigPath: configPath,
+ IncludeDoctor: includeDoctor,
+ IncludeServer: includeServer,
+ })
+ return writeSupportBundle(args[0], bundle)
+ },
+ }
+ cmd.Flags().StringVar(&configPath, "config", "", "config file to include; defaults to normal StacyVM config lookup")
+ cmd.Flags().BoolVar(&includeDoctor, "include-doctor", false, "include local doctor checks")
+ cmd.Flags().BoolVar(&includeServer, "include-server", false, "include /api/v1/diagnostics from --server when reachable")
+ return cmd
+}
+
+type supportBundleOptions struct {
+ ConfigPath string
+ IncludeDoctor bool
+ IncludeServer bool
+}
+
+func collectSupportBundle(ctx context.Context, opts supportBundleOptions) supportBundle {
+ bundle := supportBundle{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ Version: version,
+ Runtime: map[string]string{
+ "goos": runtime.GOOS,
+ "goarch": runtime.GOARCH,
+ },
+ Redactions: []string{
+ "secret-like config keys",
+ "API keys",
+ "bearer tokens",
+ "basic auth credentials in URLs",
+ },
+ }
+
+ cfg, err := loadLintConfig(opts.ConfigPath)
+ if err != nil {
+ bundle.CollectionErrors = append(bundle.CollectionErrors, "config: "+err.Error())
+ } else {
+ bundle.Config = redactConfig(cfg)
+ bundle.ConfigLint = redactDoctorChecks(lintConfig(cfg, true))
+ }
+
+ if opts.IncludeDoctor {
+ bundle.Doctor = redactDoctorChecks(runDoctor(ctx, true))
+ }
+ if opts.IncludeServer {
+ diagnostics, err := fetchServerDiagnostics()
+ if err != nil {
+ bundle.CollectionErrors = append(bundle.CollectionErrors, "server_diagnostics: "+err.Error())
+ } else {
+ bundle.ServerDiagnostics = diagnostics
+ }
+ }
+ return bundle
+}
+
+func writeSupportBundle(path string, bundle supportBundle) error {
+ data, err := json.MarshalIndent(bundle, "", " ")
+ if err != nil {
+ return err
+ }
+ data = []byte(redactString(string(data)))
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ return err
+ }
+ fmt.Printf("support bundle written: %s\n", path)
+ return nil
+}
+
+func fetchServerDiagnostics() (map[string]interface{}, error) {
+ client := getClient()
+ resp, err := client.do(http.MethodGet, "/api/v1/diagnostics", nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var diagnostics map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&diagnostics); err != nil {
+ return nil, err
+ }
+ return redactMap(diagnostics).(map[string]interface{}), nil
+}
+
+func redactConfig(cfg *config.Config) map[string]interface{} {
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ return redactMap(raw).(map[string]interface{})
+}
+
+func redactDoctorChecks(checks []doctorCheck) []doctorCheck {
+ redacted := make([]doctorCheck, len(checks))
+ for i, check := range checks {
+ redacted[i] = doctorCheck{
+ Name: check.Name,
+ Status: check.Status,
+ Message: redactString(check.Message),
+ Remediation: redactString(check.Remediation),
+ }
+ }
+ return redacted
+}
+
+func redactMap(value interface{}) interface{} {
+ switch typed := value.(type) {
+ case map[string]interface{}:
+ out := make(map[string]interface{}, len(typed))
+ for key, nested := range typed {
+ if isSecretKey(key) {
+ out[key] = "[REDACTED]"
+ continue
+ }
+ out[key] = redactMap(nested)
+ }
+ return out
+ case []interface{}:
+ out := make([]interface{}, len(typed))
+ for i, nested := range typed {
+ out[i] = redactMap(nested)
+ }
+ return out
+ case string:
+ return redactString(typed)
+ default:
+ return typed
+ }
+}
+
+func isSecretKey(key string) bool {
+ normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
+ for _, marker := range []string{"api_key", "apikey", "signing_key", "private_key", "key_file", "token", "secret", "password", "credential", "authorization", "auth_header"} {
+ if strings.Contains(normalized, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+var redactionPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`stacyvm-worker-v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`),
+ regexp.MustCompile(`(?i)bearer\s+[a-z0-9._~+/=-]{8,}`),
+ regexp.MustCompile(`(?i)(x-api-key|x-admin-api-key|api[_-]?key)\s*[:=]\s*["']?[^"',\s}]+`),
+ regexp.MustCompile(`(?i)(password|token|secret|signing[_-]?key|private[_-]?key)\s*[:=]\s*["']?[^"',\s}]+`),
+ regexp.MustCompile(`([a-z][a-z0-9+.-]*://)[^:/@\s]+:[^/@\s]+@`),
+ regexp.MustCompile(`(?i)sk-[a-z0-9][a-z0-9_-]{8,}`),
+}
+
+func redactString(value string) string {
+ redacted := value
+ for _, pattern := range redactionPatterns {
+ redacted = pattern.ReplaceAllStringFunc(redacted, func(match string) string {
+ if strings.Contains(match, "://") && strings.Contains(match, "@") {
+ parts := pattern.FindStringSubmatch(match)
+ if len(parts) > 1 {
+ return parts[1] + "[REDACTED]@"
+ }
+ }
+ return "[REDACTED]"
+ })
+ }
+ return redacted
+}
diff --git a/cmd/stacyvm/cmd_support_test.go b/cmd/stacyvm/cmd_support_test.go
new file mode 100644
index 0000000..197333a
--- /dev/null
+++ b/cmd/stacyvm/cmd_support_test.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestSupportBundleRedactsSecrets(t *testing.T) {
+ input := map[string]interface{}{
+ "api_key": "sk-super-secret-value",
+ "nested": map[string]interface{}{
+ "token": "Bearer abcdefghijklmnop",
+ "url": "https://user:password@example.com/path",
+ },
+ "message": "X-Admin-API-Key: admin-secret-value",
+ }
+
+ redacted := redactMap(input)
+ data, err := json.Marshal(redacted)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(data)
+ for _, secret := range []string{"sk-super-secret-value", "abcdefghijklmnop", "password", "admin-secret-value"} {
+ if strings.Contains(body, secret) {
+ t.Fatalf("support redaction leaked %q in %s", secret, body)
+ }
+ }
+ if !strings.Contains(body, "[REDACTED]") {
+ t.Fatalf("expected redaction marker in %s", body)
+ }
+}
+
+func TestWriteSupportBundleRedactsFinalJSON(t *testing.T) {
+ path := t.TempDir() + "/support.json"
+ bundle := supportBundle{
+ Version: "test",
+ CollectionErrors: []string{
+ "server returned bearer abcdefghijklmnop",
+ "database url postgres://user:pass@example.com/db",
+ },
+ }
+ if err := writeSupportBundle(path, bundle); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(data)
+ for _, secret := range []string{"abcdefghijklmnop", "user:pass"} {
+ if strings.Contains(body, secret) {
+ t.Fatalf("final support bundle leaked %q in %s", secret, body)
+ }
+ }
+}
diff --git a/cmd/stacyvm/cmd_upgrade.go b/cmd/stacyvm/cmd_upgrade.go
new file mode 100644
index 0000000..7aa6419
--- /dev/null
+++ b/cmd/stacyvm/cmd_upgrade.go
@@ -0,0 +1,194 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+type upgradeRehearsalReport struct {
+ GeneratedAt string `json:"generated_at"`
+ ConfigPath string `json:"config_path,omitempty"`
+ DatabasePath string `json:"database_path"`
+ BackupOutput string `json:"backup_output"`
+ ConfigLint []doctorCheck `json:"config_lint"`
+ DatabaseChecks []doctorCheck `json:"database_checks"`
+ Doctor []doctorCheck `json:"doctor,omitempty"`
+ RecommendedSteps []string `json:"recommended_steps"`
+ ProductionReady bool `json:"production_ready"`
+ RequiresLiveCheck bool `json:"requires_live_check"`
+}
+
+func newUpgradeCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "upgrade",
+ Short: "Run upgrade preparation checks",
+ }
+ cmd.AddCommand(newUpgradeRehearseCmd())
+ return cmd
+}
+
+func newUpgradeRehearseCmd() *cobra.Command {
+ var configPath string
+ var dbPath string
+ var backupOutput string
+ var includeDoctor bool
+ cmd := &cobra.Command{
+ Use: "rehearse",
+ Short: "Rehearse a single-node production upgrade",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ report, err := runUpgradeRehearsal(cmd.Context(), upgradeRehearsalOptions{
+ ConfigPath: configPath,
+ DatabasePath: dbPath,
+ BackupOutput: backupOutput,
+ IncludeDoctor: includeDoctor,
+ })
+ if err != nil {
+ return err
+ }
+ return printUpgradeRehearsal(report)
+ },
+ }
+ cmd.Flags().StringVar(&configPath, "config", "", "config file to rehearse; defaults to normal StacyVM config lookup")
+ cmd.Flags().StringVar(&dbPath, "database", "", "database path; defaults to configured database.path")
+ cmd.Flags().StringVar(&backupOutput, "backup-output", "", "intended backup output path; defaults to a timestamped file next to the database")
+ cmd.Flags().BoolVar(&includeDoctor, "include-doctor", false, "also run live host doctor checks")
+ return cmd
+}
+
+type upgradeRehearsalOptions struct {
+ ConfigPath string
+ DatabasePath string
+ BackupOutput string
+ IncludeDoctor bool
+}
+
+func runUpgradeRehearsal(ctx context.Context, opts upgradeRehearsalOptions) (*upgradeRehearsalReport, error) {
+ cfg, err := loadLintConfig(opts.ConfigPath)
+ if err != nil {
+ return nil, err
+ }
+
+ dbPath := opts.DatabasePath
+ if dbPath == "" {
+ dbPath = cfg.Database.Path
+ }
+ dbAbs, err := filepath.Abs(dbPath)
+ if err != nil {
+ return nil, err
+ }
+
+ backupOutput := opts.BackupOutput
+ if backupOutput == "" {
+ backupOutput = filepath.Join(filepath.Dir(dbAbs), "stacyvm-upgrade-"+time.Now().UTC().Format("20060102T150405Z")+".db")
+ }
+ backupAbs, err := filepath.Abs(backupOutput)
+ if err != nil {
+ return nil, err
+ }
+
+ report := &upgradeRehearsalReport{
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
+ ConfigPath: opts.ConfigPath,
+ DatabasePath: dbAbs,
+ BackupOutput: backupAbs,
+ ConfigLint: lintConfig(cfg, true),
+ DatabaseChecks: rehearseDatabaseChecks(ctx, dbAbs, backupAbs),
+ RecommendedSteps: upgradeSteps(dbAbs, backupAbs, opts.ConfigPath),
+ RequiresLiveCheck: !opts.IncludeDoctor,
+ }
+ if opts.IncludeDoctor {
+ report.Doctor = runDoctor(ctx, true)
+ report.RequiresLiveCheck = false
+ }
+ report.ProductionReady = allChecksPass(report.ConfigLint) && allChecksPass(report.DatabaseChecks) && (!opts.IncludeDoctor || allChecksPass(report.Doctor))
+ return report, nil
+}
+
+func rehearseDatabaseChecks(ctx context.Context, dbPath, backupOutput string) []doctorCheck {
+ var checks []doctorCheck
+ if _, err := os.Stat(dbPath); err != nil {
+ checks = append(checks, doctorCheck{Name: "database.source", Status: doctorFail, Message: err.Error(), Remediation: "Run the rehearsal on the host that owns the SQLite database, or pass --database to the active DB path."})
+ } else if err := checkSQLiteIntegrity(ctx, dbPath); err != nil {
+ checks = append(checks, doctorCheck{Name: "database.integrity", Status: doctorFail, Message: err.Error(), Remediation: "Investigate SQLite integrity before upgrading; do not proceed until integrity_check returns ok."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "database.integrity", Status: doctorPass, Message: "ok"})
+ }
+
+ backupDir := filepath.Dir(backupOutput)
+ if info, err := os.Stat(backupDir); err != nil {
+ checks = append(checks, doctorCheck{Name: "backup.directory", Status: doctorFail, Message: err.Error(), Remediation: "Create the backup directory and ensure the StacyVM operator can write to it."})
+ } else if !info.IsDir() {
+ checks = append(checks, doctorCheck{Name: "backup.directory", Status: doctorFail, Message: "path is not a directory", Remediation: "Choose a backup output path whose parent is a directory."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "backup.directory", Status: doctorPass, Message: backupDir})
+ }
+ if _, err := os.Stat(backupOutput); err == nil {
+ checks = append(checks, doctorCheck{Name: "backup.output", Status: doctorFail, Message: "backup output already exists", Remediation: "Choose a fresh --backup-output path or run stacyvm db backup with --force intentionally."})
+ } else if err != nil && !os.IsNotExist(err) {
+ checks = append(checks, doctorCheck{Name: "backup.output", Status: doctorFail, Message: err.Error(), Remediation: "Choose a backup output path that can be checked by the operator."})
+ } else {
+ checks = append(checks, doctorCheck{Name: "backup.output", Status: doctorPass, Message: backupOutput})
+ }
+ return checks
+}
+
+func upgradeSteps(dbPath, backupOutput, configPath string) []string {
+ if configPath == "" {
+ configPath = "the active StacyVM config"
+ }
+ return []string{
+ fmt.Sprintf("Run stacyvm config lint --production --file %s with the service environment loaded.", configPath),
+ fmt.Sprintf("Run stacyvm db backup %s --database %s before replacing binaries or images.", backupOutput, dbPath),
+ "Replace the stacyvm binary or update STACYVM_IMAGE.",
+ "Restart the service.",
+ "Confirm GET /api/v1/ready succeeds before routing traffic.",
+ "If readiness fails after upgrade, stop StacyVM and run stacyvm db restore against the pre-upgrade backup.",
+ }
+}
+
+func printUpgradeRehearsal(report *upgradeRehearsalReport) error {
+ fmt.Println("Upgrade rehearsal")
+ fmt.Printf(" database: %s\n", report.DatabasePath)
+ fmt.Printf(" backup: %s\n", report.BackupOutput)
+ fmt.Println()
+ fmt.Println("Config lint:")
+ configFailures := printDoctorChecks(report.ConfigLint)
+ fmt.Println()
+ fmt.Println("Database checks:")
+ dbFailures := printDoctorChecks(report.DatabaseChecks)
+ doctorFailures := 0
+ if len(report.Doctor) > 0 {
+ fmt.Println()
+ fmt.Println("Doctor checks:")
+ doctorFailures = printDoctorChecks(report.Doctor)
+ }
+ fmt.Println()
+ fmt.Println("Recommended upgrade flow:")
+ for i, step := range report.RecommendedSteps {
+ fmt.Printf(" %d. %s\n", i+1, step)
+ }
+ if report.RequiresLiveCheck {
+ fmt.Println()
+ fmt.Println("Live host checks were skipped. Run with --include-doctor or run stacyvm doctor --production on the target host before go-live.")
+ }
+ if failures := configFailures + dbFailures + doctorFailures; failures > 0 {
+ return fmt.Errorf("upgrade rehearsal found %d failing check(s)", failures)
+ }
+ fmt.Println()
+ fmt.Println("Upgrade rehearsal passed.")
+ return nil
+}
+
+func allChecksPass(checks []doctorCheck) bool {
+ for _, check := range checks {
+ if check.Status == doctorFail {
+ return false
+ }
+ }
+ return true
+}
diff --git a/cmd/stacyvm/cmd_upgrade_test.go b/cmd/stacyvm/cmd_upgrade_test.go
new file mode 100644
index 0000000..b6c0c36
--- /dev/null
+++ b/cmd/stacyvm/cmd_upgrade_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "os"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestRunUpgradeRehearsalPassesWithValidInputs(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "stacyvm.db")
+ configPath := writeUpgradeTestConfig(t, dir, dbPath)
+ createTestSQLiteDB(t, dbPath)
+
+ report, err := runUpgradeRehearsal(context.Background(), upgradeRehearsalOptions{
+ ConfigPath: configPath,
+ DatabasePath: dbPath,
+ BackupOutput: filepath.Join(dir, "backup.db"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !report.ProductionReady {
+ t.Fatalf("ProductionReady = false, config=%v database=%v", report.ConfigLint, report.DatabaseChecks)
+ }
+ if !report.RequiresLiveCheck {
+ t.Fatalf("RequiresLiveCheck = false, want true when doctor is skipped")
+ }
+}
+
+func TestRunUpgradeRehearsalFailsWhenBackupExists(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "stacyvm.db")
+ backupPath := filepath.Join(dir, "backup.db")
+ configPath := writeUpgradeTestConfig(t, dir, dbPath)
+ createTestSQLiteDB(t, dbPath)
+ if err := os.WriteFile(backupPath, []byte("exists"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ report, err := runUpgradeRehearsal(context.Background(), upgradeRehearsalOptions{
+ ConfigPath: configPath,
+ DatabasePath: dbPath,
+ BackupOutput: backupPath,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.ProductionReady {
+ t.Fatalf("ProductionReady = true, want false")
+ }
+ if got := statusFor(report.DatabaseChecks, "backup.output"); got != doctorFail {
+ t.Fatalf("backup.output status = %s, want %s", got, doctorFail)
+ }
+}
+
+func writeUpgradeTestConfig(t *testing.T, dir, dbPath string) string {
+ t.Helper()
+ path := filepath.Join(dir, "stacyvm.yaml")
+ body := `server:
+ cors_allowed_origins:
+ - "https://console.example.com"
+auth:
+ enabled: true
+ api_key: "regular-api-key-with-at-least-32-bytes"
+ admin_api_key: "admin-api-key-with-at-least-32-bytesxx"
+ admin_fallback_enabled: false
+ admin_audit_retention: "2160h"
+rate_limit:
+ enabled: true
+ requests_per_minute: 120
+ burst: 60
+ key_by: "api_key"
+database:
+ path: "` + dbPath + `"
+defaults:
+ max_sandboxes: 100
+ max_sandboxes_per_owner: 10
+ max_spawn_queue: 100
+ default_exec_timeout: "30s"
+ max_exec_timeout: "10m"
+ max_ttl: "24h"
+logging:
+ format: "json"
+providers:
+ default: "docker"
+ docker:
+ enabled: true
+ runtime: "runc"
+ network_mode: "stacyvm-network"
+ seccomp_profile: "default"
+ memory: "512m"
+ cpus: "1"
+ pids_limit: 256
+ user: "1000:1000"
+ dropped_caps: ["ALL"]
+ added_caps: []
+`
+ if err := os.WriteFile(path, []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func createTestSQLiteDB(t *testing.T, path string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec("CREATE TABLE sandboxes (id TEXT PRIMARY KEY)"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func statusFor(checks []doctorCheck, name string) doctorStatus {
+ for _, check := range checks {
+ if check.Name == name {
+ return check.Status
+ }
+ }
+ return ""
+}
diff --git a/cmd/stacyvm/cmd_worker.go b/cmd/stacyvm/cmd_worker.go
new file mode 100644
index 0000000..dc6a13d
--- /dev/null
+++ b/cmd/stacyvm/cmd_worker.go
@@ -0,0 +1,757 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/api/middleware"
+ "github.com/StacyOs/stacyvm/internal/config"
+ "github.com/StacyOs/stacyvm/internal/providers"
+ "github.com/StacyOs/stacyvm/internal/worker"
+ "github.com/rs/zerolog"
+ "github.com/spf13/cobra"
+)
+
+func newWorkerCmd() *cobra.Command {
+ var id string
+ var controlPlaneURL string
+ var token string
+ var tokenFile string
+ var signingKeyFile string
+ var bootstrapAdminKey string
+ var bootstrapAdminKeyFile string
+ var bootstrapTokenTTL string
+ var heartbeatInterval string
+ var listenAddr string
+ var previewDomain string
+ var once bool
+ cmd := &cobra.Command{
+ Use: "worker",
+ Short: "Start a StacyVM remote worker process",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ if id == "" {
+ id = cfg.Worker.ID
+ }
+ if id == "" {
+ hostname, _ := os.Hostname()
+ id = hostname
+ }
+ if controlPlaneURL == "" {
+ controlPlaneURL = cfg.Worker.ControlPlaneURL
+ }
+ if token == "" {
+ token = cfg.Auth.WorkerToken
+ }
+ var tokenFunc func() (string, error)
+ if tokenFile != "" {
+ if cmd.Flags().Changed("worker-token") {
+ return fmt.Errorf("worker token must be set with either --worker-token or --worker-token-file, not both")
+ }
+ token = ""
+ tokenFunc = fileWorkerTokenFunc(tokenFile)
+ }
+ signingKey := cfg.Auth.WorkerSigningKey
+ if signingKeyFile != "" {
+ signingKey, err = readSecretFile(signingKeyFile)
+ if err != nil {
+ return fmt.Errorf("worker signing key file: %w", err)
+ }
+ }
+ if heartbeatInterval == "" {
+ heartbeatInterval = cfg.Worker.HeartbeatInterval
+ }
+ if listenAddr == "" {
+ listenAddr = cfg.Worker.ListenAddr
+ }
+ if previewDomain == "" {
+ previewDomain = cfg.Worker.PreviewDomain
+ }
+ if previewDomain == "" {
+ previewDomain = cfg.Server.PreviewDomain
+ }
+ interval, err := time.ParseDuration(heartbeatInterval)
+ if err != nil {
+ return fmt.Errorf("worker heartbeat interval: %w", err)
+ }
+ logger := newCommandLogger(cfg)
+ registry := buildWorkerRegistry(cfg, logger, previewDomain)
+ // Resolve bootstrap admin key from flag or file.
+ if bootstrapAdminKeyFile != "" {
+ bootstrapAdminKey, err = readSecretFile(bootstrapAdminKeyFile)
+ if err != nil {
+ return fmt.Errorf("bootstrap admin key file: %w", err)
+ }
+ }
+ // Priority: explicit token > token file > bootstrap issuer > local signing key.
+ if tokenFunc == nil {
+ if bootstrapAdminKey != "" {
+ tokenFunc = worker.NewIssuerTokenFunc(controlPlaneURL, id, bootstrapAdminKey, bootstrapTokenTTL)
+ } else {
+ tokenFunc = signedWorkerTokenFunc(id, signingKey)
+ if token != "" {
+ tokenFunc = nil
+ }
+ }
+ }
+ rt := worker.Runtime{
+ Client: worker.Client{
+ BaseURL: strings.TrimRight(controlPlaneURL, "/"),
+ WorkerID: id,
+ Token: token,
+ TokenFunc: tokenFunc,
+ },
+ HeartbeatInterval: interval,
+ ListenAddr: listenAddr,
+ Logger: logger,
+ Providers: enabledProviderNames(cfg),
+ Capacity: map[string]interface{}{
+ "max_sandboxes": cfg.Defaults.MaxSandboxes,
+ "max_sandboxes_per_owner": cfg.Defaults.MaxSandboxesPerOwner,
+ "preview_domain": previewDomain,
+ },
+ Registry: registry,
+ RPCTLS: workerTLSConfig(cfg.Worker.RPCTLS),
+ SigningKey: signingKey,
+ SigningKeys: cfg.Auth.WorkerSigningKeys,
+ RevokedTokenIDs: cfg.Auth.WorkerRevokedTokenIDs,
+ }
+ if once {
+ return rt.RunOnce(cmd.Context())
+ }
+ ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ logger.Info().Str("worker_id", id).Str("control_plane", controlPlaneURL).Msg("starting StacyVM worker")
+ return rt.Run(ctx)
+ },
+ }
+ cmd.Flags().StringVar(&id, "id", "", "worker ID; defaults to worker.id or hostname")
+ cmd.Flags().StringVar(&controlPlaneURL, "control-plane", "", "control plane URL; defaults to worker.control_plane_url")
+ cmd.Flags().StringVar(&token, "worker-token", os.Getenv("STACYVM_AUTH_WORKER_TOKEN"), "worker bearer token; defaults to auth.worker_token")
+ cmd.Flags().StringVar(&tokenFile, "worker-token-file", "", "file containing the worker bearer token")
+ cmd.Flags().StringVar(&signingKeyFile, "worker-signing-key-file", "", "file containing the worker signing key used to derive short-lived signed tokens")
+ cmd.Flags().StringVar(&bootstrapAdminKey, "bootstrap-admin-key", os.Getenv("STACYVM_WORKER_BOOTSTRAP_ADMIN_KEY"), "admin API key used to fetch signed worker tokens from the control-plane issuer")
+ cmd.Flags().StringVar(&bootstrapAdminKeyFile, "bootstrap-admin-key-file", "", "file containing the admin API key for token issuance")
+ cmd.Flags().StringVar(&bootstrapTokenTTL, "bootstrap-token-ttl", "5m", "TTL for tokens fetched from the control-plane issuer (max 15m)")
+ cmd.Flags().StringVar(&heartbeatInterval, "heartbeat-interval", "", "worker heartbeat interval")
+ cmd.Flags().StringVar(&listenAddr, "listen", "", "worker RPC listen address; defaults to worker.listen_addr")
+ cmd.Flags().StringVar(&previewDomain, "preview-domain", "", "worker preview domain; defaults to worker.preview_domain or server.preview_domain")
+ cmd.Flags().BoolVar(&once, "once", false, "send one heartbeat and exit")
+ cmd.AddCommand(newWorkerTokenCmd())
+ return cmd
+}
+
+func newWorkerTokenCmd() *cobra.Command {
+ var signingKey string
+ var signingKeyFile string
+ var ttl string
+ var scopes []string
+ var audience string
+ var tokenID string
+ var notBefore string
+ var outputFormat string
+ cmd := &cobra.Command{
+ Use: "token ",
+ Short: "Issue a signed worker token",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ if signingKey == "" {
+ signingKey = cfg.Auth.WorkerSigningKey
+ }
+ if signingKeyFile != "" {
+ if cmd.Flags().Changed("signing-key") {
+ return fmt.Errorf("worker signing key must be set with either --signing-key or --signing-key-file, not both")
+ }
+ signingKey, err = readSecretFile(signingKeyFile)
+ if err != nil {
+ return fmt.Errorf("worker signing key file: %w", err)
+ }
+ }
+ result, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: args[0],
+ SigningKey: signingKey,
+ TTL: ttl,
+ Scopes: scopes,
+ Audience: audience,
+ TokenID: tokenID,
+ NotBefore: notBefore,
+ Now: time.Now,
+ })
+ if err != nil {
+ return err
+ }
+ switch strings.ToLower(strings.TrimSpace(outputFormat)) {
+ case "", "token":
+ _, err = fmt.Fprintln(cmd.OutOrStdout(), result.Token)
+ case "json":
+ encoder := json.NewEncoder(cmd.OutOrStdout())
+ encoder.SetIndent("", " ")
+ err = encoder.Encode(result)
+ default:
+ err = fmt.Errorf("worker token output format must be token or json")
+ }
+ return err
+ },
+ }
+ cmd.Flags().StringVar(&signingKey, "signing-key", os.Getenv("STACYVM_AUTH_WORKER_SIGNING_KEY"), "worker signing key; defaults to auth.worker_signing_key")
+ cmd.Flags().StringVar(&signingKeyFile, "signing-key-file", "", "file containing the worker signing key")
+ cmd.Flags().StringVar(&ttl, "ttl", "5m", "token lifetime")
+ cmd.Flags().StringVar(&audience, "audience", middleware.WorkerTokenAudienceControlPlane, "token audience: worker:control-plane or worker:rpc")
+ cmd.Flags().StringVar(&tokenID, "token-id", "", "explicit token id for incident-response tracking; generated when empty")
+ cmd.Flags().StringVar(¬Before, "not-before", "0s", "delay before token becomes valid")
+ cmd.Flags().StringVar(&outputFormat, "format", "token", "output format: token or json")
+ cmd.Flags().StringArrayVar(&scopes, "scope", nil, "worker scope to include; repeatable, defaults to all worker scopes")
+ cmd.AddCommand(newWorkerTokenInspectCmd())
+ cmd.AddCommand(newWorkerTokenVerifyCmd())
+ cmd.AddCommand(newWorkerTokenRotationPlanCmd())
+ return cmd
+}
+
+func newWorkerTokenInspectCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "inspect ",
+ Short: "Inspect signed worker token claims without verifying the signature",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ result, err := inspectWorkerToken(args[0])
+ if err != nil {
+ return err
+ }
+ encoder := json.NewEncoder(cmd.OutOrStdout())
+ encoder.SetIndent("", " ")
+ return encoder.Encode(result)
+ },
+ }
+}
+
+func newWorkerTokenVerifyCmd() *cobra.Command {
+ var signingKey string
+ var signingKeyFile string
+ var verificationKeys []string
+ var verificationKeyFiles []string
+ var audience string
+ var workerID string
+ var revokedTokenIDs []string
+ cmd := &cobra.Command{
+ Use: "verify ",
+ Short: "Verify a signed worker token against signing keys and revocation settings",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ if signingKey == "" {
+ signingKey = cfg.Auth.WorkerSigningKey
+ }
+ if signingKeyFile != "" {
+ if cmd.Flags().Changed("signing-key") {
+ return fmt.Errorf("worker signing key must be set with either --signing-key or --signing-key-file, not both")
+ }
+ signingKey, err = readSecretFile(signingKeyFile)
+ if err != nil {
+ return fmt.Errorf("worker signing key file: %w", err)
+ }
+ }
+ for _, path := range verificationKeyFiles {
+ key, err := readSecretFile(path)
+ if err != nil {
+ return fmt.Errorf("worker verification key file: %w", err)
+ }
+ verificationKeys = append(verificationKeys, key)
+ }
+ verificationKeys = append(append([]string{}, cfg.Auth.WorkerSigningKeys...), verificationKeys...)
+ revokedTokenIDs = append(append([]string{}, cfg.Auth.WorkerRevokedTokenIDs...), revokedTokenIDs...)
+ result, err := verifyWorkerToken(workerTokenVerifyOptions{
+ Token: args[0],
+ SigningKey: signingKey,
+ VerificationKey: verificationKeys,
+ Audience: audience,
+ WorkerID: workerID,
+ RevokedTokenIDs: revokedTokenIDs,
+ Now: time.Now,
+ })
+ if err != nil {
+ return err
+ }
+ encoder := json.NewEncoder(cmd.OutOrStdout())
+ encoder.SetIndent("", " ")
+ return encoder.Encode(result)
+ },
+ }
+ cmd.Flags().StringVar(&signingKey, "signing-key", os.Getenv("STACYVM_AUTH_WORKER_SIGNING_KEY"), "active worker signing key; defaults to auth.worker_signing_key")
+ cmd.Flags().StringVar(&signingKeyFile, "signing-key-file", "", "file containing the active worker signing key")
+ cmd.Flags().StringArrayVar(&verificationKeys, "verification-key", nil, "additional verification key accepted during rotation; repeatable")
+ cmd.Flags().StringArrayVar(&verificationKeyFiles, "verification-key-file", nil, "file containing an additional verification key accepted during rotation; repeatable")
+ cmd.Flags().StringVar(&audience, "audience", "", "expected token audience: worker:control-plane or worker:rpc")
+ cmd.Flags().StringVar(&workerID, "worker-id", "", "expected worker ID")
+ cmd.Flags().StringArrayVar(&revokedTokenIDs, "revoked-token-id", nil, "revoked token ID to reject; repeatable")
+ return cmd
+}
+
+func newWorkerTokenRotationPlanCmd() *cobra.Command {
+ var newKeyRef string
+ var previousKeyRef string
+ var ttl string
+ var outputFormat string
+ cmd := &cobra.Command{
+ Use: "rotation-plan",
+ Short: "Print a no-secret signed worker token rotation plan",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ result, err := workerTokenRotationPlan(workerTokenRotationPlanOptions{
+ NewKeyRef: newKeyRef,
+ PreviousKeyRef: previousKeyRef,
+ TTL: ttl,
+ })
+ if err != nil {
+ return err
+ }
+ switch strings.ToLower(strings.TrimSpace(outputFormat)) {
+ case "", "text":
+ _, err = fmt.Fprint(cmd.OutOrStdout(), result.Text)
+ case "json":
+ encoder := json.NewEncoder(cmd.OutOrStdout())
+ encoder.SetIndent("", " ")
+ err = encoder.Encode(result)
+ default:
+ err = fmt.Errorf("worker token rotation-plan output format must be text or json")
+ }
+ return err
+ },
+ }
+ cmd.Flags().StringVar(&newKeyRef, "new-key-ref", "auth.worker_signing_key", "operator-visible reference for the new active signing key")
+ cmd.Flags().StringVar(&previousKeyRef, "previous-key-ref", "auth.worker_signing_keys[0]", "operator-visible reference for the previous verification key")
+ cmd.Flags().StringVar(&ttl, "ttl", "5m", "maximum signed worker token lifetime to wait before removing the previous key")
+ cmd.Flags().StringVar(&outputFormat, "format", "text", "output format: text or json")
+ return cmd
+}
+
+type workerTokenIssueOptions struct {
+ WorkerID string
+ SigningKey string
+ TTL string
+ Scopes []string
+ Audience string
+ TokenID string
+ NotBefore string
+ Now func() time.Time
+}
+
+type workerTokenIssueResult struct {
+ Token string `json:"token"`
+ TokenID string `json:"token_id"`
+ WorkerID string `json:"worker_id"`
+ Audience string `json:"audience"`
+ Scopes []string `json:"scopes,omitempty"`
+ IssuedAt string `json:"issued_at"`
+ NotBefore string `json:"not_before,omitempty"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+type workerTokenInspectResult struct {
+ SignatureVerified bool `json:"signature_verified"`
+ WorkerID string `json:"worker_id,omitempty"`
+ TokenID string `json:"token_id,omitempty"`
+ Audience string `json:"audience,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+ IssuedAt string `json:"issued_at,omitempty"`
+ NotBefore string `json:"not_before,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+}
+
+type workerTokenVerifyOptions struct {
+ Token string
+ SigningKey string
+ VerificationKey []string
+ Audience string
+ WorkerID string
+ RevokedTokenIDs []string
+ Now func() time.Time
+}
+
+type workerTokenRotationPlanOptions struct {
+ NewKeyRef string
+ PreviousKeyRef string
+ TTL string
+}
+
+type workerTokenRotationPlanResult struct {
+ NewKeyRef string `json:"new_key_ref"`
+ PreviousKeyRef string `json:"previous_key_ref"`
+ MaxTokenTTL string `json:"max_token_ttl"`
+ Steps []string `json:"steps"`
+ ConfigSnippet string `json:"config_snippet"`
+ Validation []string `json:"validation"`
+ Text string `json:"text,omitempty"`
+}
+
+func issueWorkerToken(opts workerTokenIssueOptions) (workerTokenIssueResult, error) {
+ workerID := strings.TrimSpace(opts.WorkerID)
+ signingKey := strings.TrimSpace(opts.SigningKey)
+ if workerID == "" {
+ return workerTokenIssueResult{}, fmt.Errorf("worker id is required")
+ }
+ if signingKey == "" {
+ return workerTokenIssueResult{}, fmt.Errorf("worker signing key is required")
+ }
+ ttl, err := time.ParseDuration(opts.TTL)
+ if err != nil {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token ttl: %w", err)
+ }
+ if ttl <= 0 {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token ttl must be positive")
+ }
+ if ttl > middleware.MaxWorkerTokenTTL {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token ttl must be <= %s", middleware.MaxWorkerTokenTTL)
+ }
+ audience := strings.TrimSpace(opts.Audience)
+ if audience == "" {
+ audience = middleware.WorkerTokenAudienceControlPlane
+ }
+ if audience != middleware.WorkerTokenAudienceControlPlane && audience != middleware.WorkerTokenAudienceRPC {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token audience must be %q or %q", middleware.WorkerTokenAudienceControlPlane, middleware.WorkerTokenAudienceRPC)
+ }
+ notBefore := strings.TrimSpace(opts.NotBefore)
+ if notBefore == "" {
+ notBefore = "0s"
+ }
+ notBeforeDelay, err := time.ParseDuration(notBefore)
+ if err != nil {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token not-before: %w", err)
+ }
+ if notBeforeDelay < 0 {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token not-before must be non-negative")
+ }
+ now := opts.Now
+ if now == nil {
+ now = time.Now
+ }
+ issuedAt := now().UTC()
+ tokenID := strings.TrimSpace(opts.TokenID)
+ if tokenID == "" {
+ tokenID, err = middleware.NewWorkerTokenID()
+ if err != nil {
+ return workerTokenIssueResult{}, fmt.Errorf("worker token id: %w", err)
+ }
+ }
+ notBeforeAt := issuedAt.Add(notBeforeDelay)
+ claims := middleware.WorkerTokenClaims{
+ WorkerID: workerID,
+ TokenID: tokenID,
+ Audience: audience,
+ Scopes: opts.Scopes,
+ IssuedAt: issuedAt.Unix(),
+ ExpiresAt: issuedAt.Add(ttl).Unix(),
+ }
+ result := workerTokenIssueResult{
+ TokenID: tokenID,
+ WorkerID: workerID,
+ Audience: audience,
+ Scopes: opts.Scopes,
+ IssuedAt: issuedAt.Format(time.RFC3339),
+ ExpiresAt: issuedAt.Add(ttl).Format(time.RFC3339),
+ }
+ if notBeforeDelay > 0 {
+ claims.NotBefore = notBeforeAt.Unix()
+ result.NotBefore = notBeforeAt.Format(time.RFC3339)
+ }
+ result.Token, err = middleware.SignWorkerToken(signingKey, claims)
+ if err != nil {
+ return workerTokenIssueResult{}, err
+ }
+ return result, nil
+}
+
+func inspectWorkerToken(token string) (workerTokenInspectResult, error) {
+ claims, ok := middleware.DecodeWorkerTokenClaims(token)
+ if !ok {
+ return workerTokenInspectResult{}, fmt.Errorf("invalid signed worker token format")
+ }
+ return workerTokenInspectResult{
+ SignatureVerified: false,
+ WorkerID: claims.WorkerID,
+ TokenID: claims.TokenID,
+ Audience: claims.Audience,
+ Scopes: claims.Scopes,
+ IssuedAt: unixTimeString(claims.IssuedAt),
+ NotBefore: unixTimeString(claims.NotBefore),
+ ExpiresAt: unixTimeString(claims.ExpiresAt),
+ }, nil
+}
+
+func verifyWorkerToken(opts workerTokenVerifyOptions) (workerTokenInspectResult, error) {
+ keys := cleanStrings(append([]string{opts.SigningKey}, opts.VerificationKey...))
+ if len(keys) == 0 {
+ return workerTokenInspectResult{}, fmt.Errorf("worker signing key is required")
+ }
+ audience := strings.TrimSpace(opts.Audience)
+ if audience != "" && audience != middleware.WorkerTokenAudienceControlPlane && audience != middleware.WorkerTokenAudienceRPC {
+ return workerTokenInspectResult{}, fmt.Errorf("worker token audience must be %q or %q", middleware.WorkerTokenAudienceControlPlane, middleware.WorkerTokenAudienceRPC)
+ }
+ now := opts.Now
+ if now == nil {
+ now = time.Now
+ }
+ var claims middleware.WorkerTokenClaims
+ var ok bool
+ for _, key := range keys {
+ claims, ok = middleware.VerifyWorkerTokenForAudience(key, opts.Token, audience, now().UTC())
+ if ok {
+ break
+ }
+ }
+ if !ok {
+ return workerTokenInspectResult{}, fmt.Errorf("invalid signed worker token")
+ }
+ if expectedWorkerID := strings.TrimSpace(opts.WorkerID); expectedWorkerID != "" && claims.WorkerID != expectedWorkerID {
+ return workerTokenInspectResult{}, fmt.Errorf("worker token worker_id %q does not match expected worker %q", claims.WorkerID, expectedWorkerID)
+ }
+ revoked := map[string]struct{}{}
+ for _, id := range cleanStrings(opts.RevokedTokenIDs) {
+ revoked[id] = struct{}{}
+ }
+ if _, isRevoked := revoked[claims.TokenID]; claims.TokenID != "" && isRevoked {
+ return workerTokenInspectResult{}, fmt.Errorf("worker token %q is revoked", claims.TokenID)
+ }
+ result, _ := inspectWorkerToken(opts.Token)
+ result.SignatureVerified = true
+ return result, nil
+}
+
+func cleanStrings(values []string) []string {
+ clean := make([]string, 0, len(values))
+ for _, value := range values {
+ if value = strings.TrimSpace(value); value != "" {
+ clean = append(clean, value)
+ }
+ }
+ return clean
+}
+
+func readSecretFile(path string) (string, error) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return "", fmt.Errorf("path is required")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ secret := strings.TrimSpace(string(data))
+ if secret == "" {
+ return "", fmt.Errorf("secret file is empty")
+ }
+ return secret, nil
+}
+
+func fileWorkerTokenFunc(path string) func() (string, error) {
+ return func() (string, error) {
+ token, err := readSecretFile(path)
+ if err != nil {
+ return "", fmt.Errorf("worker token file: %w", err)
+ }
+ return token, nil
+ }
+}
+
+func workerTokenRotationPlan(opts workerTokenRotationPlanOptions) (workerTokenRotationPlanResult, error) {
+ newKeyRef := strings.TrimSpace(opts.NewKeyRef)
+ previousKeyRef := strings.TrimSpace(opts.PreviousKeyRef)
+ if newKeyRef == "" {
+ return workerTokenRotationPlanResult{}, fmt.Errorf("new key reference is required")
+ }
+ if previousKeyRef == "" {
+ return workerTokenRotationPlanResult{}, fmt.Errorf("previous key reference is required")
+ }
+ ttl := strings.TrimSpace(opts.TTL)
+ if ttl == "" {
+ ttl = "5m"
+ }
+ duration, err := time.ParseDuration(ttl)
+ if err != nil {
+ return workerTokenRotationPlanResult{}, fmt.Errorf("worker token rotation ttl: %w", err)
+ }
+ if duration <= 0 {
+ return workerTokenRotationPlanResult{}, fmt.Errorf("worker token rotation ttl must be positive")
+ }
+ if duration > middleware.MaxWorkerTokenTTL {
+ return workerTokenRotationPlanResult{}, fmt.Errorf("worker token rotation ttl must be <= %s", middleware.MaxWorkerTokenTTL)
+ }
+ steps := []string{
+ fmt.Sprintf("Put the new active signing key at %s.", newKeyRef),
+ fmt.Sprintf("Move the previous active signing key into %s.", previousKeyRef),
+ "Restart or reload control-plane and worker processes so new tokens are minted with the new key.",
+ fmt.Sprintf("Wait at least %s, plus clock skew, before removing the previous verification key.", duration),
+ "Remove the previous verification key after old signed worker tokens have expired.",
+ }
+ configSnippet := fmt.Sprintf("auth:\n worker_signing_key: \"\"\n worker_signing_keys:\n - \"\"\n", newKeyRef, previousKeyRef)
+ validation := []string{
+ "stacyvm config lint --production",
+ "stacyvm worker token --ttl " + duration.String() + " --format json",
+ "stacyvm worker token verify '' --worker-id --audience worker:control-plane",
+ }
+ result := workerTokenRotationPlanResult{
+ NewKeyRef: newKeyRef,
+ PreviousKeyRef: previousKeyRef,
+ MaxTokenTTL: duration.String(),
+ Steps: steps,
+ ConfigSnippet: configSnippet,
+ Validation: validation,
+ }
+ result.Text = formatWorkerTokenRotationPlan(result)
+ return result, nil
+}
+
+func formatWorkerTokenRotationPlan(result workerTokenRotationPlanResult) string {
+ var b strings.Builder
+ b.WriteString("Signed worker token rotation plan\n\n")
+ b.WriteString("Key references:\n")
+ b.WriteString(fmt.Sprintf("- new active key: %s\n", result.NewKeyRef))
+ b.WriteString(fmt.Sprintf("- previous verification key: %s\n", result.PreviousKeyRef))
+ b.WriteString(fmt.Sprintf("- maximum token TTL: %s\n\n", result.MaxTokenTTL))
+ b.WriteString("Steps:\n")
+ for i, step := range result.Steps {
+ b.WriteString(fmt.Sprintf("%d. %s\n", i+1, step))
+ }
+ b.WriteString("\nConfig sketch:\n")
+ b.WriteString(result.ConfigSnippet)
+ b.WriteString("\nValidation:\n")
+ for _, command := range result.Validation {
+ b.WriteString("- " + command + "\n")
+ }
+ return b.String()
+}
+
+func unixTimeString(sec int64) string {
+ if sec <= 0 {
+ return ""
+ }
+ return time.Unix(sec, 0).UTC().Format(time.RFC3339)
+}
+
+func signedWorkerTokenFunc(workerID, signingKey string) func() (string, error) {
+ signingKey = strings.TrimSpace(signingKey)
+ workerID = strings.TrimSpace(workerID)
+ if signingKey == "" || workerID == "" {
+ return nil
+ }
+ return func() (string, error) {
+ now := time.Now().UTC()
+ tokenID, err := middleware.NewWorkerTokenID()
+ if err != nil {
+ return "", fmt.Errorf("worker token id: %w", err)
+ }
+ return middleware.SignWorkerToken(signingKey, middleware.WorkerTokenClaims{
+ WorkerID: workerID,
+ TokenID: tokenID,
+ Audience: middleware.WorkerTokenAudienceControlPlane,
+ IssuedAt: now.Unix(),
+ ExpiresAt: now.Add(5 * time.Minute).Unix(),
+ })
+ }
+}
+
+func workerTLSConfig(cfg config.WorkerRPCTLSConfig) worker.TLSConfig {
+ return worker.TLSConfig{
+ Enabled: cfg.Enabled,
+ ServerCertFile: cfg.ServerCertFile,
+ ServerKeyFile: cfg.ServerKeyFile,
+ ClientCAFile: cfg.ClientCAFile,
+ CAFile: cfg.CAFile,
+ ClientCertFile: cfg.ClientCertFile,
+ ClientKeyFile: cfg.ClientKeyFile,
+ ServerName: cfg.ServerName,
+ InsecureSkipVerify: cfg.InsecureSkipVerify,
+ }
+}
+
+func newCommandLogger(cfg *config.Config) zerolog.Logger {
+ var logger zerolog.Logger
+ if cfg.Logging.Format == "pretty" {
+ logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger()
+ } else {
+ logger = zerolog.New(os.Stdout).With().Timestamp().Logger()
+ }
+ if level, err := zerolog.ParseLevel(cfg.Logging.Level); err == nil {
+ logger = logger.Level(level)
+ }
+ return logger
+}
+
+func enabledProviderNames(cfg *config.Config) []string {
+ var providers []string
+ if cfg.Providers.Mock.Enabled {
+ providers = append(providers, "mock")
+ }
+ if cfg.Providers.Firecracker.Enabled {
+ providers = append(providers, "firecracker")
+ }
+ if cfg.Providers.Docker.Enabled {
+ providers = append(providers, "docker")
+ }
+ if cfg.Providers.E2B.Enabled {
+ providers = append(providers, "e2b")
+ }
+ if cfg.Providers.Custom.Enabled {
+ providers = append(providers, cfg.Providers.Custom.Name)
+ }
+ if cfg.Providers.PRoot.Enabled {
+ providers = append(providers, "proot")
+ }
+ return providers
+}
+
+func buildWorkerRegistry(cfg *config.Config, logger zerolog.Logger, previewDomain string) *providers.Registry {
+ registry := providers.NewRegistry()
+ if cfg.Providers.Mock.Enabled {
+ registry.Register(providers.NewMockProvider())
+ }
+ if cfg.Providers.Docker.Enabled {
+ docker, err := providers.NewDockerProvider(providers.DockerProviderConfig{
+ Socket: cfg.Providers.Docker.Socket,
+ Runtime: cfg.Providers.Docker.Runtime,
+ DefaultImage: cfg.Providers.Docker.DefaultImage,
+ NetworkMode: cfg.Providers.Docker.NetworkMode,
+ SeccompProfile: cfg.Providers.Docker.SeccompProfile,
+ ReadOnlyRootfs: cfg.Providers.Docker.ReadOnlyRootfs,
+ Memory: cfg.Providers.Docker.Memory,
+ CPUs: cfg.Providers.Docker.CPUs,
+ PidsLimit: cfg.Providers.Docker.PidsLimit,
+ User: cfg.Providers.Docker.User,
+ DroppedCaps: cfg.Providers.Docker.DroppedCaps,
+ AddedCaps: cfg.Providers.Docker.AddedCaps,
+ Tmpfs: cfg.Providers.Docker.Tmpfs,
+ PoolSecurity: providers.PoolSecurityProviderConfig{
+ PerUserUID: cfg.Providers.Docker.PoolSecurity.PerUserUID,
+ PIDNamespace: cfg.Providers.Docker.PoolSecurity.PIDNamespace,
+ WorkspacePermissions: cfg.Providers.Docker.PoolSecurity.WorkspacePermissions,
+ HidePID: cfg.Providers.Docker.PoolSecurity.HidePID,
+ },
+ PreviewDomain: previewDomain,
+ }, logger)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to create worker docker provider")
+ } else {
+ registry.Register(docker)
+ }
+ }
+ if len(registry.List()) > 0 {
+ if err := registry.SetDefault(cfg.Providers.Default); err != nil {
+ _ = registry.SetDefault(registry.List()[0])
+ }
+ }
+ return registry
+}
diff --git a/cmd/stacyvm/cmd_worker_test.go b/cmd/stacyvm/cmd_worker_test.go
new file mode 100644
index 0000000..65d8732
--- /dev/null
+++ b/cmd/stacyvm/cmd_worker_test.go
@@ -0,0 +1,350 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/StacyOs/stacyvm/internal/api/middleware"
+)
+
+func TestIssueWorkerTokenSignsExpectedClaims(t *testing.T) {
+ now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
+ result, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: " worker-a ",
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ TTL: "10m",
+ Scopes: []string{middleware.ScopeWorkerHeartbeat},
+ TokenID: "token-id-1",
+ NotBefore: "2m",
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+
+ if result.TokenID != "token-id-1" || result.NotBefore != now.Add(2*time.Minute).Format(time.RFC3339) {
+ t.Fatalf("unexpected token issue result: %+v", result)
+ }
+ claims, ok := middleware.VerifyWorkerToken("worker-signing-key-with-at-least-32-bytes", result.Token, now.Add(3*time.Minute))
+ if !ok {
+ t.Fatal("issued token did not verify after not-before")
+ }
+ if claims.WorkerID != "worker-a" {
+ t.Fatalf("worker id = %q, want worker-a", claims.WorkerID)
+ }
+ if claims.TokenID != "token-id-1" {
+ t.Fatalf("token id = %q, want token-id-1", claims.TokenID)
+ }
+ if claims.Audience != middleware.WorkerTokenAudienceControlPlane {
+ t.Fatalf("audience = %q, want %q", claims.Audience, middleware.WorkerTokenAudienceControlPlane)
+ }
+ if claims.IssuedAt != now.Unix() {
+ t.Fatalf("issued at = %d, want %d", claims.IssuedAt, now.Unix())
+ }
+ if claims.ExpiresAt != now.Add(10*time.Minute).Unix() {
+ t.Fatalf("expires at = %d, want %d", claims.ExpiresAt, now.Add(10*time.Minute).Unix())
+ }
+ if claims.NotBefore != now.Add(2*time.Minute).Unix() {
+ t.Fatalf("not before = %d, want %d", claims.NotBefore, now.Add(2*time.Minute).Unix())
+ }
+ if len(claims.Scopes) != 1 || claims.Scopes[0] != middleware.ScopeWorkerHeartbeat {
+ t.Fatalf("scopes = %#v, want heartbeat scope", claims.Scopes)
+ }
+}
+
+func TestIssueWorkerTokenRejectsInvalidInputs(t *testing.T) {
+ tests := []struct {
+ name string
+ opts workerTokenIssueOptions
+ }{
+ {name: "missing worker", opts: workerTokenIssueOptions{SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "5m"}},
+ {name: "missing signing key", opts: workerTokenIssueOptions{WorkerID: "worker-a", TTL: "5m"}},
+ {name: "bad ttl", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "soon"}},
+ {name: "zero ttl", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "0s"}},
+ {name: "too long ttl", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: (middleware.MaxWorkerTokenTTL + time.Second).String()}},
+ {name: "bad audience", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "5m", Audience: "admin"}},
+ {name: "bad not before", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "5m", NotBefore: "later"}},
+ {name: "negative not before", opts: workerTokenIssueOptions{WorkerID: "worker-a", SigningKey: "worker-signing-key-with-at-least-32-bytes", TTL: "5m", NotBefore: "-1s"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := issueWorkerToken(tt.opts); err == nil {
+ t.Fatal("expected error")
+ }
+ })
+ }
+}
+
+func TestWorkerTokenCommandReadsSigningKeyFile(t *testing.T) {
+ keyPath := writeTestSecret(t, "worker-signing-key-with-at-least-32-bytes\n")
+ cmd := newWorkerTokenCmd()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetArgs([]string{"worker-a", "--signing-key-file", keyPath, "--token-id", "token-id-1", "--format", "json"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute worker token command: %v", err)
+ }
+ var result workerTokenIssueResult
+ if err := json.Unmarshal(out.Bytes(), &result); err != nil {
+ t.Fatalf("decode worker token json: %v", err)
+ }
+ if result.TokenID != "token-id-1" || result.WorkerID != "worker-a" {
+ t.Fatalf("unexpected token result: %+v", result)
+ }
+ if _, ok := middleware.VerifyWorkerToken("worker-signing-key-with-at-least-32-bytes", result.Token, time.Now().UTC()); !ok {
+ t.Fatal("issued token did not verify with signing key from file")
+ }
+}
+
+func TestInspectWorkerTokenShowsUnverifiedClaims(t *testing.T) {
+ now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
+ issued, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: "worker-a",
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ TTL: "5m",
+ Scopes: []string{middleware.ScopeWorkerHeartbeat},
+ Audience: middleware.WorkerTokenAudienceRPC,
+ TokenID: "token-id-1",
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+
+ inspected, err := inspectWorkerToken(issued.Token)
+ if err != nil {
+ t.Fatalf("inspect worker token: %v", err)
+ }
+ if inspected.SignatureVerified {
+ t.Fatal("inspect result should not report signature verification")
+ }
+ if inspected.WorkerID != "worker-a" || inspected.TokenID != "token-id-1" || inspected.Audience != middleware.WorkerTokenAudienceRPC {
+ t.Fatalf("unexpected inspect result: %+v", inspected)
+ }
+ if inspected.IssuedAt != now.Format(time.RFC3339) || inspected.ExpiresAt != now.Add(5*time.Minute).Format(time.RFC3339) {
+ t.Fatalf("unexpected inspect timestamps: %+v", inspected)
+ }
+ if len(inspected.Scopes) != 1 || inspected.Scopes[0] != middleware.ScopeWorkerHeartbeat {
+ t.Fatalf("scopes = %#v, want heartbeat scope", inspected.Scopes)
+ }
+}
+
+func TestInspectWorkerTokenRejectsInvalidFormat(t *testing.T) {
+ if _, err := inspectWorkerToken("not-a-worker-token"); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestVerifyWorkerTokenAcceptsRotationKey(t *testing.T) {
+ now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
+ issued, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: "worker-a",
+ SigningKey: "old-worker-signing-key-with-at-least-32-bytes",
+ TTL: "5m",
+ Audience: middleware.WorkerTokenAudienceRPC,
+ TokenID: "token-id-1",
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+
+ verified, err := verifyWorkerToken(workerTokenVerifyOptions{
+ Token: issued.Token,
+ SigningKey: "new-worker-signing-key-with-at-least-32-bytes",
+ VerificationKey: []string{"old-worker-signing-key-with-at-least-32-bytes"},
+ Audience: middleware.WorkerTokenAudienceRPC,
+ WorkerID: "worker-a",
+ Now: func() time.Time { return now.Add(time.Minute) },
+ })
+ if err != nil {
+ t.Fatalf("verify worker token: %v", err)
+ }
+ if !verified.SignatureVerified {
+ t.Fatal("verify result should report signature verification")
+ }
+ if verified.TokenID != "token-id-1" || verified.WorkerID != "worker-a" || verified.Audience != middleware.WorkerTokenAudienceRPC {
+ t.Fatalf("unexpected verify result: %+v", verified)
+ }
+}
+
+func TestVerifyWorkerTokenRejectsRevokedTokenID(t *testing.T) {
+ now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
+ issued, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: "worker-a",
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ TTL: "5m",
+ TokenID: "revoked-token-id",
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+
+ if _, err := verifyWorkerToken(workerTokenVerifyOptions{
+ Token: issued.Token,
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ RevokedTokenIDs: []string{"revoked-token-id"},
+ Now: func() time.Time { return now.Add(time.Minute) },
+ }); err == nil {
+ t.Fatal("expected revoked token to fail verification")
+ }
+}
+
+func TestVerifyWorkerTokenRejectsWrongAudienceAndWorker(t *testing.T) {
+ now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
+ issued, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: "worker-a",
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ TTL: "5m",
+ Audience: middleware.WorkerTokenAudienceControlPlane,
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+
+ if _, err := verifyWorkerToken(workerTokenVerifyOptions{
+ Token: issued.Token,
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ Audience: middleware.WorkerTokenAudienceRPC,
+ Now: func() time.Time { return now.Add(time.Minute) },
+ }); err == nil {
+ t.Fatal("expected wrong audience to fail verification")
+ }
+ if _, err := verifyWorkerToken(workerTokenVerifyOptions{
+ Token: issued.Token,
+ SigningKey: "worker-signing-key-with-at-least-32-bytes",
+ WorkerID: "worker-b",
+ Now: func() time.Time { return now.Add(time.Minute) },
+ }); err == nil {
+ t.Fatal("expected wrong worker to fail verification")
+ }
+}
+
+func TestWorkerTokenVerifyCommandReadsSigningKeyFiles(t *testing.T) {
+ now := time.Now().UTC()
+ issued, err := issueWorkerToken(workerTokenIssueOptions{
+ WorkerID: "worker-a",
+ SigningKey: "old-worker-signing-key-with-at-least-32-bytes",
+ TTL: "5m",
+ Audience: middleware.WorkerTokenAudienceRPC,
+ TokenID: "token-id-1",
+ Now: func() time.Time { return now },
+ })
+ if err != nil {
+ t.Fatalf("issue worker token: %v", err)
+ }
+ activeKeyPath := writeTestSecret(t, "new-worker-signing-key-with-at-least-32-bytes")
+ oldKeyPath := writeTestSecret(t, "old-worker-signing-key-with-at-least-32-bytes\n")
+ cmd := newWorkerTokenVerifyCmd()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetArgs([]string{
+ issued.Token,
+ "--signing-key-file", activeKeyPath,
+ "--verification-key-file", oldKeyPath,
+ "--worker-id", "worker-a",
+ "--audience", middleware.WorkerTokenAudienceRPC,
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute worker token verify command: %v", err)
+ }
+ var result workerTokenInspectResult
+ if err := json.Unmarshal(out.Bytes(), &result); err != nil {
+ t.Fatalf("decode worker token verify json: %v", err)
+ }
+ if !result.SignatureVerified || result.TokenID != "token-id-1" {
+ t.Fatalf("unexpected verify result: %+v", result)
+ }
+}
+
+func TestWorkerTokenRotationPlanCommandOutputsJSON(t *testing.T) {
+ cmd := newWorkerTokenRotationPlanCmd()
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetArgs([]string{
+ "--new-key-ref", "/run/secrets/worker-signing-key-new",
+ "--previous-key-ref", "/run/secrets/worker-signing-key-old",
+ "--ttl", "10m",
+ "--format", "json",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute worker token rotation-plan command: %v", err)
+ }
+ var result workerTokenRotationPlanResult
+ if err := json.Unmarshal(out.Bytes(), &result); err != nil {
+ t.Fatalf("decode rotation plan json: %v", err)
+ }
+ if result.NewKeyRef != "/run/secrets/worker-signing-key-new" || result.PreviousKeyRef != "/run/secrets/worker-signing-key-old" {
+ t.Fatalf("unexpected key refs: %+v", result)
+ }
+ if result.MaxTokenTTL != "10m0s" {
+ t.Fatalf("max token ttl = %q, want 10m0s", result.MaxTokenTTL)
+ }
+ if len(result.Steps) != 5 || len(result.Validation) != 3 || result.ConfigSnippet == "" {
+ t.Fatalf("unexpected rotation plan: %+v", result)
+ }
+}
+
+func TestWorkerTokenRotationPlanRejectsInvalidTTL(t *testing.T) {
+ if _, err := workerTokenRotationPlan(workerTokenRotationPlanOptions{
+ NewKeyRef: "new",
+ PreviousKeyRef: "old",
+ TTL: (middleware.MaxWorkerTokenTTL + time.Second).String(),
+ }); err == nil {
+ t.Fatal("expected overlong ttl to fail")
+ }
+}
+
+func TestReadSecretFileRejectsEmptySecret(t *testing.T) {
+ path := writeTestSecret(t, "\n")
+ if _, err := readSecretFile(path); err == nil {
+ t.Fatal("expected empty secret file to fail")
+ }
+}
+
+func TestFileWorkerTokenFuncReloadsTokenFile(t *testing.T) {
+ path := writeTestSecret(t, "first-token\n")
+ tokenFunc := fileWorkerTokenFunc(path)
+
+ got, err := tokenFunc()
+ if err != nil {
+ t.Fatalf("read first token: %v", err)
+ }
+ if got != "first-token" {
+ t.Fatalf("first token = %q, want first-token", got)
+ }
+
+ if err := os.WriteFile(path, []byte("second-token\n"), 0o600); err != nil {
+ t.Fatalf("rotate token file: %v", err)
+ }
+ got, err = tokenFunc()
+ if err != nil {
+ t.Fatalf("read rotated token: %v", err)
+ }
+ if got != "second-token" {
+ t.Fatalf("rotated token = %q, want second-token", got)
+ }
+}
+
+func writeTestSecret(t *testing.T, value string) string {
+ t.Helper()
+ file, err := os.CreateTemp(t.TempDir(), "secret-*")
+ if err != nil {
+ t.Fatalf("create secret file: %v", err)
+ }
+ if _, err := file.WriteString(value); err != nil {
+ t.Fatalf("write secret file: %v", err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatalf("close secret file: %v", err)
+ }
+ return file.Name()
+}
diff --git a/cmd/stacyvm/main.go b/cmd/stacyvm/main.go
index 242a896..2c0894d 100644
--- a/cmd/stacyvm/main.go
+++ b/cmd/stacyvm/main.go
@@ -26,6 +26,7 @@ func main() {
root.AddCommand(
newServeCmd(),
+ newWorkerCmd(),
newSpawnCmd(),
newExecCmd(),
newKillCmd(),
@@ -33,6 +34,11 @@ func main() {
newVersionCmd(),
newTUICmd(),
newBuildImageCmd(),
+ newDoctorCmd(),
+ newConfigCmd(),
+ newDBCmd(),
+ newUpgradeCmd(),
+ newSupportCmd(),
)
if err := root.Execute(); err != nil {
diff --git a/deploy/.env.example b/deploy/.env.example
new file mode 100644
index 0000000..b27e4a8
--- /dev/null
+++ b/deploy/.env.example
@@ -0,0 +1,9 @@
+STACYVM_IMAGE=ghcr.io/stacyos/stacyvm:latest
+STACYVM_HOST_PORT=7423
+STACYVM_TRAEFIK_HOST_PORT=80
+STACYVM_API_KEY=change-me-generate-at-least-32-bytes
+STACYVM_ADMIN_API_KEY=change-me-generate-a-separate-admin-key
+STACYVM_PREVIEW_DOMAIN=localhost
+STACYVM_LOG_LEVEL=info
+STACYVM_DATABASE_PATH=/var/lib/stacyvm/stacyvm.db
+STACYVM_DOCKER_SOCKET=/var/run/docker.sock
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..464096b
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,48 @@
+services:
+ stacyvm:
+ image: ${STACYVM_IMAGE:-ghcr.io/stacyos/stacyvm:latest}
+ restart: unless-stopped
+ working_dir: /etc/stacyvm
+ ports:
+ - "${STACYVM_HOST_PORT:-7423}:7423"
+ volumes:
+ - stacyvm-data:/var/lib/stacyvm
+ - ${STACYVM_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
+ - ./stacyvm.production.yaml:/etc/stacyvm/stacyvm.yaml:ro
+ environment:
+ STACYVM_AUTH_API_KEY: ${STACYVM_API_KEY:?set STACYVM_API_KEY}
+ STACYVM_AUTH_ADMIN_API_KEY: ${STACYVM_ADMIN_API_KEY:-}
+ STACYVM_DATABASE_PATH: ${STACYVM_DATABASE_PATH:-/var/lib/stacyvm/stacyvm.db}
+ STACYVM_LOGGING_LEVEL: ${STACYVM_LOG_LEVEL:-info}
+ STACYVM_SERVER_PREVIEW_DOMAIN: ${STACYVM_PREVIEW_DOMAIN:-localhost}
+ STACYVM_PROVIDERS_DOCKER_NETWORK_MODE: stacyvm-network
+ networks:
+ - stacyvm-network
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- --header=\"X-API-Key: $${STACYVM_AUTH_API_KEY}\" http://127.0.0.1:7423/api/v1/live >/dev/null"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+ traefik:
+ image: traefik:v3.6
+ restart: unless-stopped
+ command:
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+ ports:
+ - "${STACYVM_TRAEFIK_HOST_PORT:-80}:80"
+ volumes:
+ - ${STACYVM_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
+ networks:
+ - stacyvm-network
+
+volumes:
+ stacyvm-data:
+
+networks:
+ stacyvm-network:
+ name: stacyvm-network
+ driver: bridge
diff --git a/deploy/stacyvm.env.example b/deploy/stacyvm.env.example
new file mode 100644
index 0000000..8e756ba
--- /dev/null
+++ b/deploy/stacyvm.env.example
@@ -0,0 +1,10 @@
+STACYVM_AUTH_API_KEY=change-me-generate-at-least-32-bytes
+STACYVM_AUTH_ADMIN_API_KEY=change-me-generate-a-separate-admin-key
+STACYVM_AUTH_ADMIN_FALLBACK_ENABLED=false
+STACYVM_AUTH_ADMIN_AUDIT_RETENTION=2160h
+STACYVM_DATABASE_PATH=/var/lib/stacyvm/stacyvm.db
+STACYVM_LOGGING_LEVEL=info
+STACYVM_LOGGING_FORMAT=json
+STACYVM_SERVER_PREVIEW_DOMAIN=localhost
+STACYVM_PROVIDERS_DEFAULT=docker
+STACYVM_PROVIDERS_DOCKER_NETWORK_MODE=bridge
diff --git a/deploy/stacyvm.production.yaml b/deploy/stacyvm.production.yaml
new file mode 100644
index 0000000..ecad1f6
--- /dev/null
+++ b/deploy/stacyvm.production.yaml
@@ -0,0 +1,105 @@
+server:
+ host: "0.0.0.0"
+ port: 7423
+ preview_domain: "localhost"
+ cors_allowed_origins:
+ - "https://stacyvm.example.com"
+
+providers:
+ default: "docker"
+
+ docker:
+ enabled: true
+ socket: "unix:///var/run/docker.sock"
+ runtime: "runc"
+ default_image: "alpine:latest"
+ network_mode: "stacyvm-network"
+ seccomp_profile: "default"
+ read_only_rootfs: false
+ memory: "512m"
+ cpus: "1"
+ pids_limit: 256
+ user: "1000:1000"
+ dropped_caps: ["ALL"]
+ added_caps: []
+ pool_security:
+ per_user_uid: false
+ pid_namespace: false
+ workspace_permissions: true
+ hidepid: false
+
+ firecracker:
+ enabled: false
+ firecracker_path: "/usr/local/bin/firecracker"
+ kernel_path: "/var/lib/stacyvm/vmlinux.bin"
+ agent_path: "/usr/local/bin/stacyvm-agent"
+ data_dir: "/var/lib/stacyvm"
+
+ e2b:
+ enabled: false
+ api_key: ""
+ base_url: "https://api.e2b.dev"
+
+ custom:
+ enabled: false
+ name: "custom"
+ base_url: ""
+ api_key: ""
+ timeout: "60s"
+
+ proot:
+ enabled: false
+ rootfs_path: "/var/lib/stacyvm/rootfs"
+ proot_binary: "proot"
+ workspace_base: "/var/lib/stacyvm/workspaces"
+ default_timeout: "60s"
+ max_sandboxes: 10
+ max_memory_mb: 512
+ max_disk_mb: 1024
+ languages: ["python3", "node", "bash"]
+
+defaults:
+ ttl: "30m"
+ image: "alpine:latest"
+ memory_mb: 1024
+ vcpus: 1
+ disk_size_mb: 1024
+ max_ttl: "24h"
+ default_exec_timeout: "30s"
+ max_exec_timeout: "10m"
+ max_sandboxes: 100
+ max_sandboxes_per_owner: 10
+ spawn_overflow: "queue"
+ spawn_queue_timeout: "30s"
+ max_spawn_queue: 100
+
+auth:
+ enabled: true
+ api_key: "change-me-generate-at-least-32-bytes"
+ admin_api_key: "change-me-generate-a-separate-admin-key"
+ admin_fallback_enabled: false
+ admin_audit_retention: "2160h"
+
+rate_limit:
+ enabled: true
+ requests_per_minute: 120
+ burst: 60
+ key_by: "api_key"
+ bucket_ttl: "15m"
+ cleanup_interval: "1m"
+
+database:
+ path: "/var/lib/stacyvm/stacyvm.db"
+
+logging:
+ level: "info"
+ format: "json"
+
+pool:
+ enabled: false
+ max_vms: 10
+ max_users_per_vm: 5
+ image: "alpine:latest"
+ memory_mb: 2048
+ vcpus: 2
+ overflow: "reject"
diff --git a/deploy/stacyvm.service b/deploy/stacyvm.service
new file mode 100644
index 0000000..9fda38f
--- /dev/null
+++ b/deploy/stacyvm.service
@@ -0,0 +1,29 @@
+[Unit]
+Description=StacyVM sandbox API
+Documentation=https://github.com/StacyOS/stacyvm
+After=network-online.target docker.service
+Wants=network-online.target
+Requires=docker.service
+
+[Service]
+Type=simple
+User=stacyvm
+Group=stacyvm
+SupplementaryGroups=docker
+WorkingDirectory=/etc/stacyvm
+EnvironmentFile=-/etc/stacyvm/stacyvm.env
+Environment=STACYVM_DATABASE_PATH=/var/lib/stacyvm/stacyvm.db
+ExecStart=/usr/local/bin/stacyvm serve
+Restart=on-failure
+RestartSec=5s
+TimeoutStopSec=30s
+
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/stacyvm
+ReadOnlyPaths=/etc/stacyvm
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs.json b/docs.json
new file mode 100644
index 0000000..5b76b21
--- /dev/null
+++ b/docs.json
@@ -0,0 +1,148 @@
+{
+ "$schema": "https://mintlify.com/docs.json",
+ "theme": "mint",
+ "name": "StacyVM",
+ "description": "Deterministic execution infrastructure for autonomous software systems.",
+ "colors": {
+ "primary": "#FF7038",
+ "light": "#D1C2A5",
+ "dark": "#FF7038"
+ },
+ "logo": {
+ "light": "/assets/stacy-logo-light.png",
+ "dark": "/assets/stacy-logo-dark.png",
+ "href": "https://github.com/StacyOS/stacyvm"
+ },
+ "favicon": "/assets/stacy-mark-orange.png",
+ "appearance": {
+ "default": "system"
+ },
+ "fonts": {
+ "family": "IBM Plex Sans",
+ "heading": {
+ "family": "Libre Baskerville",
+ "weight": 400
+ }
+ },
+ "background": {
+ "color": {
+ "light": "#F0E7DA",
+ "dark": "#262626"
+ }
+ },
+ "styling": {
+ "codeblocks": {
+ "theme": {
+ "light": "github-light",
+ "dark": "github-dark"
+ }
+ }
+ },
+ "icons": {
+ "library": "lucide"
+ },
+ "navigation": {
+ "groups": [
+ {
+ "group": "Get Started",
+ "pages": [
+ "index",
+ "docs/getting-started/what-is-stacyvm",
+ "docs/getting-started/prerequisites",
+ "docs/getting-started/quickstart",
+ "docs/getting-started/installation",
+ "docs/getting-started/core-concepts"
+ ]
+ },
+ {
+ "group": "Build With StacyVM",
+ "pages": [
+ "docs/tutorials/code-runner",
+ "docs/tutorials/typescript-code-runner",
+ "docs/sdks/python",
+ "docs/sdks/typescript",
+ "docs/rest/sandboxes",
+ "docs/rest-api"
+ ]
+ },
+ {
+ "group": "Operate",
+ "pages": [
+ "docs/deployment",
+ "docs/production-readiness",
+ "docs/public-readiness-evidence",
+ "docs/public-support-matrix",
+ "docs/releasing"
+ ]
+ },
+ {
+ "group": "Production Readiness",
+ "pages": [
+ "docs/security-governance",
+ "docs/threat-model"
+ ]
+ },
+ {
+ "group": "Runtime And Conformance",
+ "pages": [
+ "docs/provider-contract",
+ "docs/runtime-certification",
+ "docs/runtime-conformance",
+ "docs/cluster-conformance",
+ "docs/remote-worker-staging",
+ "docs/worker-rpc-contract"
+ ]
+ },
+ {
+ "group": "Architecture",
+ "pages": [
+ "docs/architecture/system-overview",
+ "docs/admin-control-plane",
+ "docs/live-preview-architecture",
+ "docs/snapshot-restore",
+ "docs/enterprise-signoff-runbook"
+ ]
+ },
+ {
+ "group": "Release Notes",
+ "pages": [
+ "docs/releases/phase-14-worker-identity-hardening",
+ "docs/releases/phase-13-cluster-store-and-worker-identity",
+ "docs/releases/phase-12-remote-sandbox-io-routing",
+ "docs/releases/phase-11-remote-worker-runtime",
+ "docs/releases/phase-10-multi-worker-foundation",
+ "docs/releases/phase-9-public-self-serve-release-trust",
+ "docs/releases/phase-8-single-node-production",
+ "docs/releases/phase-7-release-candidate-hardening",
+ "docs/releases/phase-6-security-governance",
+ "docs/releases/phase-5-admin-control-plane",
+ "docs/releases/phase-4-production-deployment",
+ "docs/releases/phase-3-quotas-and-scheduling",
+ "docs/releases/phase-2-observability-and-ops",
+ "docs/releases/phase-1-foundation-hardening"
+ ]
+ }
+ ]
+ },
+ "api": {
+ "openapi": "docs/openapi.json",
+ "playground": {
+ "display": "simple"
+ }
+ },
+ "navbar": {
+ "links": [
+ {
+ "label": "GitHub",
+ "href": "https://github.com/StacyOS/stacyvm"
+ },
+ {
+ "label": "Releases",
+ "href": "https://github.com/StacyOS/stacyvm/releases"
+ }
+ ]
+ },
+ "seo": {
+ "indexing": "navigable"
+ }
+}
diff --git a/docs/admin-control-plane.md b/docs/admin-control-plane.md
new file mode 100644
index 0000000..d184f63
--- /dev/null
+++ b/docs/admin-control-plane.md
@@ -0,0 +1,107 @@
+# Admin Control Plane
+
+This guide covers the operator-facing admin surface added in Phase 5: separate admin authentication, dashboard Operations workflows, owner quota management, diagnostics, and persisted admin audit history.
+
+## Authentication
+
+StacyVM supports a regular API key and an optional separate admin API key:
+
+```yaml
+auth:
+ enabled: true
+ api_key: "sk-client"
+ admin_api_key: "sk-admin"
+ admin_fallback_enabled: false
+ admin_audit_retention: "2160h"
+```
+
+The same values can be supplied with environment variables:
+
+```bash
+STACYVM_AUTH_API_KEY=sk-client
+STACYVM_AUTH_ADMIN_API_KEY=sk-admin
+STACYVM_AUTH_ADMIN_FALLBACK_ENABLED=false
+STACYVM_AUTH_ADMIN_AUDIT_RETENTION=2160h
+```
+
+Use `X-Admin-API-Key` for `/api/v1/admin/*` requests. If `auth.admin_api_key` is configured, the regular `auth.api_key` cannot access admin routes. If no admin key is configured, admin routes fall back to `auth.api_key` for compatibility unless `auth.admin_fallback_enabled` is set to `false`.
+
+Production deployments should set `auth.admin_fallback_enabled: false` and configure a dedicated `auth.admin_api_key`.
+
+Authenticated requests now carry a request-scoped identity with either the `api` or `admin` role. Admin identities receive both `api:*` and `admin:*` scopes; regular API identities receive `api:*`. This keeps the current API-key behavior stable while creating a typed authorization boundary for later RBAC and identity-provider integrations.
+
+When API-key auth is enabled, admin routes also enforce the `admin:*` scope at the route layer. This is intentionally redundant with admin-key authentication today and gives future RBAC/OIDC integrations a single policy hook to satisfy.
+
+For the production hardening checklist and OIDC/SSO integration plan, see [security-governance](/docs/security-governance).
+
+## Dashboard Setup
+
+Open Settings, enable API key sending, and set:
+
+- API Key: sent as `X-API-Key` for regular API requests.
+- Admin API Key: sent as `X-Admin-API-Key` for operator routes.
+
+Settings are stored in browser local storage. Treat operator browsers as privileged clients and avoid sharing screenshots or profiles that may expose local configuration.
+
+## Operations Dashboard
+
+The Operations page has three tabs:
+
+| Tab | Purpose |
+|---|---|
+| Quotas | List, create, update, delete, summarize, and inspect owner quota usage. |
+| Diagnostics | View redacted process, scheduler, rate-limit, build, provider, store, sandbox, and redaction data. |
+| Audit | Review, filter, and export persisted admin route access logs. |
+
+## Owner Quotas
+
+Owner quotas are persisted overrides keyed by owner ID. They apply to requests that carry an owner identity through `X-User-ID` or an `owner_id` field where supported.
+
+Fields:
+
+| Field | Meaning |
+|---|---|
+| `max_sandboxes` | Maximum active sandboxes for the owner. `0` means no override. |
+| `max_ttl` | Maximum sandbox TTL for the owner, such as `30m` or `2h`. |
+| `max_exec_timeout` | Maximum exec timeout for the owner, such as `30s`. |
+
+Quota changes are admin operations and are recorded in the admin audit log.
+
+## Diagnostics
+
+Diagnostics are redacted by design. They include operational state useful for support and production checks while avoiding raw API keys, provider secrets, registry credentials, and environment secrets.
+
+Use diagnostics during incidents to confirm:
+
+- Store health and latency.
+- Provider health and capabilities.
+- Scheduler and spawn queue state.
+- Rate-limit state.
+- Sandbox count by state/provider.
+- Quota coverage summary.
+
+## Admin Audit
+
+Admin route access is persisted in SQLite in the `admin_audit_logs` table. Records include:
+
+- Actor from `X-User-ID`, or `admin` when no actor header is supplied.
+- When `X-User-ID` is missing on an authenticated admin request, the actor falls back to the authenticated role and key header, such as `admin:X-Admin-API-Key`.
+- HTTP method and path.
+- HTTP status and request duration.
+- Request ID.
+- Client address and user agent.
+- Timestamp.
+
+The Audit tab supports filters for actor, method, status, and path substring. It can export the current filtered view as CSV. The API equivalent is:
+
+```bash
+curl \
+ -H "X-Admin-API-Key: $STACYVM_ADMIN_API_KEY" \
+ "https://stacyvm.example.com/api/v1/admin/audit?actor=operator-a&method=PUT&format=csv"
+```
+
+## Storage And Retention
+
+Audit logs live in the main SQLite database. They are included in normal database backups. Native retention is controlled by `auth.admin_audit_retention`.
+
+Set the value to a Go duration such as `720h` for 30 days or `2160h` for 90 days. `0s` disables native pruning. Pruning runs after successful admin audit writes, so records older than the retention window are removed as operators use the admin control plane.
diff --git a/docs/api.md b/docs/api.md
deleted file mode 100644
index 53b25e0..0000000
--- a/docs/api.md
+++ /dev/null
@@ -1,567 +0,0 @@
-# StacyVM REST API Reference
-
-This document is the source of truth for the StacyVM HTTP API. The Python and TypeScript SDKs are thin wrappers over these endpoints — anything they do, you can do with `curl`.
-
-- **Base URL:** `http://localhost:7423/api/v1`
-- **Content type:** `application/json` (request and response, except where noted)
-- **OpenAPI spec:** [swagger.yaml](swagger.yaml) / [swagger.json](swagger.json)
-
----
-
-## Table of contents
-
-- [Authentication](#authentication)
-- [Conventions](#conventions)
-- [Errors](#errors)
-- [Sandboxes](#sandboxes)
-- [Files](#files)
-- [Templates](#templates)
-- [Providers](#providers)
-- [Snapshots](#snapshots)
-- [Pool](#pool)
-- [System](#system)
-- [Events stream](#events-stream)
-- [WebSocket exec](#websocket-exec)
-
----
-
-## Authentication
-
-Two optional headers, both off by default:
-
-| Header | Purpose | Required when |
-|---|---|---|
-| `X-API-Key` | API key authentication | `auth.enabled: true` in `stacyvm.yaml` |
-| `X-User-ID` | Multi-tenant pool mode user identifier | `pool.enabled: true` |
-
-```bash
-curl -H 'X-API-Key: sk-xyz123' \
- -H 'X-User-ID: alice@example.com' \
- http://localhost:7423/api/v1/sandboxes
-```
-
-CORS is permissive by default (`*`). Lock it down via reverse proxy if you expose StacyVM to the open internet.
-
----
-
-## Conventions
-
-- **IDs.** Sandbox IDs look like `sb-a1b2c3d4`. Templates are addressed by `name`.
-- **Durations.** All `ttl` and `timeout` fields use Go duration strings: `30s`, `5m`, `1h30m`.
-- **Timestamps.** ISO 8601 UTC, e.g. `2026-05-04T10:30:00Z`.
-- **File modes.** Octal strings, e.g. `"755"`, `"644"`.
-- **Streaming.** `POST /sandboxes/{id}/exec` switches to NDJSON (`application/x-ndjson`) when `stream: true`.
-
----
-
-## Errors
-
-Errors return a JSON body with HTTP status reflecting the failure class:
-
-```json
-{
- "code": "not_found",
- "message": "sandbox sb-a1b2c3d4 not found"
-}
-```
-
-| Status | Code | When |
-|---|---|---|
-| `400` | `bad_request` | Invalid input — missing field, malformed JSON |
-| `401` | `unauthorized` | Bad / missing API key |
-| `404` | `not_found` | Sandbox / template / provider does not exist |
-| `409` | `conflict` | Template name already exists |
-| `500` | `provider_error` | Provider failed (Docker, Firecracker, etc.) |
-| `503` | `unavailable` | Pool full with `overflow: reject` |
-
----
-
-## Sandboxes
-
-### Spawn a sandbox
-
-```
-POST /api/v1/sandboxes
-```
-
-**Request body** (all fields optional, server defaults apply):
-```json
-{
- "image": "python:3.12",
- "provider": "docker",
- "memory_mb": 1024,
- "vcpus": 2,
- "ttl": "1h",
- "metadata": { "user": "alice" }
-}
-```
-
-**Response** `201 Created`:
-```json
-{
- "id": "sb-a1b2c3d4",
- "state": "running",
- "provider": "docker",
- "image": "python:3.12",
- "memory_mb": 1024,
- "vcpus": 2,
- "created_at": "2026-05-04T10:30:00Z",
- "expires_at": "2026-05-04T11:30:00Z",
- "metadata": { "user": "alice" },
- "preview_domain": "localhost"
-}
-```
-
-### List sandboxes
-
-```
-GET /api/v1/sandboxes
-```
-
-**Response** `200 OK`: array of sandbox objects.
-
-### Get a sandbox
-
-```
-GET /api/v1/sandboxes/{id}
-```
-
-**Response** `200 OK` or `404 Not Found`.
-
-### Destroy a sandbox
-
-```
-DELETE /api/v1/sandboxes/{id}
-```
-
-**Response** `200 OK`:
-```json
-{ "status": "destroyed" }
-```
-
-### Prune expired sandboxes
-
-```
-DELETE /api/v1/sandboxes
-```
-
-**Response** `200 OK`:
-```json
-{ "pruned": 7 }
-```
-
-### Extend TTL
-
-```
-POST /api/v1/sandboxes/{id}/extend
-```
-
-**Request body**:
-```json
-{ "ttl": "1h" }
-```
-
-**Response** `200 OK`: full sandbox object with updated `expires_at`.
-
-### Execute a command
-
-```
-POST /api/v1/sandboxes/{id}/exec
-```
-
-**Request body**:
-```json
-{
- "command": "python3 -c 'print(40+2)'",
- "args": ["--coverage"],
- "env": { "NODE_ENV": "test" },
- "workdir": "/app",
- "timeout": "30s",
- "stream": false
-}
-```
-
-**Response** `200 OK` (non-streaming):
-```json
-{
- "exit_code": 0,
- "stdout": "42\n",
- "stderr": "",
- "duration": "127ms"
-}
-```
-
-**Response** `200 OK` (streaming, `stream: true`): `application/x-ndjson` — one JSON object per line:
-```
-{"stream":"stdout","data":"installing pandas...\n"}
-{"stream":"stdout","data":"done\n"}
-{"stream":"stderr","data":"warning: deprecated flag\n"}
-```
-
-### Console logs
-
-```
-GET /api/v1/sandboxes/{id}/logs?lines=200
-```
-
-`lines` defaults to `100`.
-
-**Response** `200 OK`:
-```json
-["[init] mounting /workspace", "[init] starting agent", "..."]
-```
-
----
-
-## Files
-
-All file paths are absolute inside the sandbox. The endpoints below are scoped under `/sandboxes/{id}/files`.
-
-### Write a file
-
-```
-POST /api/v1/sandboxes/{id}/files
-```
-
-```json
-{ "path": "/app/main.py", "content": "print('hi')", "mode": "644" }
-```
-
-**Response** `200 OK`: `{ "status": "written" }`.
-
-### Read a file
-
-```
-GET /api/v1/sandboxes/{id}/files?path=/app/main.py
-```
-
-**Response** `200 OK`: raw file contents (binary safe). The SDKs decode as UTF-8.
-
-### Delete a file or directory
-
-```
-DELETE /api/v1/sandboxes/{id}/files?path=/app/cache&recursive=true
-```
-
-`recursive` defaults to `false`. **Response** `200 OK`: `{ "status": "deleted" }`.
-
-### List a directory
-
-```
-GET /api/v1/sandboxes/{id}/files/list?path=/app
-```
-
-`path` defaults to `/`.
-
-**Response** `200 OK`:
-```json
-[
- {
- "name": "main.py",
- "path": "/app/main.py",
- "size": 11,
- "is_dir": false,
- "mod_time": "2026-05-04T10:32:14Z",
- "mode": "0644"
- }
-]
-```
-
-### Move / rename
-
-```
-POST /api/v1/sandboxes/{id}/files/move
-```
-
-```json
-{ "old_path": "/app/main.py", "new_path": "/app/entry.py" }
-```
-
-**Response** `200 OK`: `{ "status": "moved" }`.
-
-### Change permissions
-
-```
-POST /api/v1/sandboxes/{id}/files/chmod
-```
-
-```json
-{ "path": "/app/run.sh", "mode": "755" }
-```
-
-**Response** `200 OK`: `{ "status": "chmod applied" }`.
-
-### Stat
-
-```
-GET /api/v1/sandboxes/{id}/files/stat?path=/app/main.py
-```
-
-**Response** `200 OK`: a single `FileInfo` object (same shape as list).
-
-### Glob
-
-```
-GET /api/v1/sandboxes/{id}/files/glob?pattern=/app/**/*.py
-```
-
-**Response** `200 OK`:
-```json
-["/app/main.py", "/app/utils/helpers.py"]
-```
-
----
-
-## Templates
-
-### Create a template
-
-```
-POST /api/v1/templates
-```
-
-```json
-{
- "name": "python-dev",
- "image": "python:3.12-slim",
- "memory_mb": 1024,
- "vcpus": 2,
- "ttl": "1h",
- "provider": "docker",
- "metadata": { "language": "python" }
-}
-```
-
-**Response** `201 Created`: the template object. `409 Conflict` if name is taken.
-
-### List templates
-
-```
-GET /api/v1/templates
-```
-
-**Response** `200 OK`: array of templates.
-
-### Get a template
-
-```
-GET /api/v1/templates/{name}
-```
-
-**Response** `200 OK` or `404 Not Found`.
-
-### Update a template
-
-```
-PUT /api/v1/templates/{name}
-```
-
-Same body as create (without `name`). **Response** `200 OK` or `404`.
-
-### Delete a template
-
-```
-DELETE /api/v1/templates/{name}
-```
-
-**Response** `200 OK`: `{ "status": "deleted" }`.
-
-### Spawn from a template
-
-```
-POST /api/v1/templates/{name}/spawn
-```
-
-Optional override body:
-```json
-{ "ttl": "30m", "provider": "firecracker" }
-```
-
-**Response** `201 Created`: full sandbox object.
-
----
-
-## Providers
-
-### List providers
-
-```
-GET /api/v1/providers
-```
-
-**Response** `200 OK`:
-```json
-[
- { "name": "docker", "healthy": true, "default": true },
- { "name": "firecracker", "healthy": true, "default": false },
- { "name": "mock", "healthy": true, "default": false }
-]
-```
-
-### Get a provider
-
-```
-GET /api/v1/providers/{name}
-```
-
-**Response** `200 OK`:
-```json
-{
- "name": "docker",
- "healthy": true,
- "default": true,
- "sandbox_count": 12,
- "config": { "runtime": "runc", "network_mode": "stacyvm-network" }
-}
-```
-
-### Health-check all providers
-
-```
-POST /api/v1/providers/test
-```
-
-**Response** `200 OK`:
-```json
-{ "docker": true, "firecracker": true, "mock": true }
-```
-
----
-
-## Snapshots
-
-### List Firecracker snapshots
-
-```
-GET /api/v1/snapshots
-```
-
-**Response** `200 OK`: array of snapshot summaries (image name, kernel, size, created_at).
-
----
-
-## Pool
-
-### Pool status
-
-```
-GET /api/v1/pool/status
-```
-
-**Response** `200 OK` (pool enabled):
-```json
-{
- "enabled": true,
- "vms": 3,
- "max_vms": 20,
- "total_users": 14,
- "max_users_per_vm": 5
-}
-```
-
-**Response** `200 OK` (pool disabled):
-```json
-{ "enabled": false }
-```
-
----
-
-## System
-
-### Health
-
-```
-GET /api/v1/health
-```
-
-**Response** `200 OK`:
-```json
-{ "status": "ok", "version": "0.5.1", "uptime": "2h13m" }
-```
-
-### Metrics
-
-```
-GET /api/v1/metrics
-```
-
-**Response** `200 OK`:
-```json
-{
- "goroutines": 42,
- "memory_alloc": 17825792,
- "active_sandboxes": 12,
- "total_sandboxes": 138
-}
-```
-
-For Prometheus-style metrics, scrape this endpoint and parse to your needs (a `/metrics` Prometheus exporter is on the roadmap).
-
----
-
-## Events stream
-
-```
-GET /api/v1/events
-```
-
-**Response** `200 OK` with `Content-Type: text/event-stream`. The server emits orchestrator events as Server-Sent Events:
-
-```
-event: sandbox.spawned
-data: {"id":"sb-a1b2c3d4","provider":"docker","image":"python:3.12"}
-
-event: sandbox.destroyed
-data: {"id":"sb-a1b2c3d4","reason":"ttl_expired"}
-
-event: sandbox.exec
-data: {"id":"sb-a1b2c3d4","command":"python3 main.py","exit_code":0}
-```
-
-Use any SSE client (`EventSource` in browsers, `httpx-sse` in Python, etc.) to consume.
-
----
-
-## WebSocket exec
-
-```
-GET /api/v1/sandboxes/{id}/exec/ws
-```
-
-Upgrades the connection to a WebSocket for interactive command execution. Useful for terminals, REPLs, and any case where you need bi-directional I/O.
-
-**Client → server messages:**
-```json
-{ "type": "start", "command": "python3", "env": { "PYTHONUNBUFFERED": "1" } }
-{ "type": "stdin", "data": "print('hi')\n" }
-{ "type": "resize", "cols": 80, "rows": 24 }
-{ "type": "signal", "signal": "SIGINT" }
-```
-
-**Server → client messages:**
-```json
-{ "type": "stdout", "data": "hi\n" }
-{ "type": "stderr", "data": "..." }
-{ "type": "exit", "exit_code": 0 }
-```
-
-The web dashboard uses this endpoint to power its live terminal — a concrete reference is at [`web/src/`](../web/src/).
-
----
-
-## SDK mapping
-
-If you'd rather write Python or TypeScript than `curl`, every endpoint above maps 1:1 to an SDK method:
-
-| Endpoint | Python | TypeScript |
-|---|---|---|
-| `POST /sandboxes` | `client.spawn(...)` | `client.spawn(...)` |
-| `GET /sandboxes/{id}` | `client.get(id)` | `client.get(id)` |
-| `POST /sandboxes/{id}/exec` | `sb.exec(cmd)` / `sb.exec_stream(cmd)` | `sb.exec(cmd)` / `sb.execStream(cmd)` |
-| `POST /sandboxes/{id}/files` | `sb.write_file(path, content)` | `sb.writeFile(path, content)` |
-| `GET /sandboxes/{id}/files` | `sb.read_file(path)` | `sb.readFile(path)` |
-| `POST /templates/{name}/spawn` | `client.spawn_template(name)` | `client.templates.spawn(name)` |
-| `GET /pool/status` | `client.pool_status()` | `client.poolStatus()` |
-| `GET /health` | `client.health()` | `client.health()` |
-
-Full SDK docs: [Python](../sdk/python/README.md) · [TypeScript](../sdk/js/README.md).
diff --git a/docs/architecture/system-overview.mdx b/docs/architecture/system-overview.mdx
new file mode 100644
index 0000000..1ae042c
--- /dev/null
+++ b/docs/architecture/system-overview.mdx
@@ -0,0 +1,228 @@
+---
+title: "System Architecture"
+description: "Understand StacyVM's control plane, scheduler, workers, providers, persistence model, and sandbox lifecycle with diagrams."
+---
+
+This page explains StacyVM from top to bottom. Use it when you need to understand how a request moves from an SDK call to an isolated runtime and back.
+
+
+
+ Control plane
+ HTTP API, auth, quotas, scheduling, audit events, and status.
+
+
+ Execution plane
+ Local or remote workers translate product requests into provider operations.
+
+
+ Runtime plane
+ Docker, Firecracker, PRoot, and custom providers run the actual sandbox.
+
+
+
+## High-Level Architecture
+
+```mermaid
+flowchart TB
+ subgraph Clients["Clients"]
+ REST["REST API users"]
+ Py["Python SDK"]
+ TS["TypeScript SDK"]
+ Agents["AI agents and tools"]
+ end
+
+ subgraph Control["StacyVM control plane"]
+ API["HTTP API server"]
+ Auth["Auth and tenant identity"]
+ Quota["Quota and admission checks"]
+ Scheduler["Scheduler"]
+ Audit["Audit events"]
+ Store["Store: SQLite or Postgres"]
+ Events["Events and metrics"]
+ end
+
+ subgraph Runtime["Execution plane"]
+ LocalWorker["Local worker"]
+ RemoteWorker["Remote worker"]
+ Provider["Provider contract"]
+ Docker["Docker"]
+ Firecracker["Firecracker"]
+ PRoot["PRoot"]
+ Custom["Custom provider"]
+ end
+
+ REST --> API
+ Py --> API
+ TS --> API
+ Agents --> API
+ API --> Auth
+ Auth --> Quota
+ Quota --> Scheduler
+ Scheduler --> Store
+ Scheduler --> LocalWorker
+ Scheduler --> RemoteWorker
+ LocalWorker --> Provider
+ RemoteWorker --> Provider
+ Provider --> Docker
+ Provider --> Firecracker
+ Provider --> PRoot
+ Provider --> Custom
+ API --> Audit
+ API --> Events
+ Audit --> Store
+ Events --> Store
+```
+
+## Request Flow
+
+When a client creates a sandbox, StacyVM validates identity and policy before touching a runtime provider.
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant API as StacyVM API
+ participant Policy as Auth/Quota
+ participant Scheduler
+ participant Worker
+ participant Provider
+ participant Store
+
+ Client->>API: POST /sandboxes
+ API->>Policy: authenticate and admit
+ Policy-->>API: allowed
+ API->>Scheduler: choose worker/provider
+ Scheduler->>Store: reserve sandbox record
+ Scheduler->>Worker: spawn request
+ Worker->>Provider: provider.Spawn
+ Provider-->>Worker: runtime id
+ Worker-->>Scheduler: sandbox running
+ Scheduler->>Store: persist state
+ API-->>Client: sandbox info
+```
+
+## Sandbox Lifecycle
+
+```mermaid
+stateDiagram-v2
+ [*] --> creating
+ creating --> running: provider spawn succeeds
+ creating --> error: spawn fails
+ running --> unhealthy: health check fails
+ unhealthy --> running: recovers
+ running --> expired: TTL reached
+ running --> destroying: client destroy
+ expired --> destroying: cleanup loop
+ destroying --> destroyed: provider destroy succeeds
+ destroying --> error: provider destroy fails
+ destroyed --> [*]
+ error --> destroying: cleanup retry
+```
+
+## Provider Contract
+
+Providers implement the runtime-specific work behind a stable product API.
+
+| Capability | Purpose |
+| --- | --- |
+| Spawn | Create an isolated runtime from an image or template. |
+| Exec | Run a command and return exit code, stdout, stderr, and duration. |
+| Stream | Send stdout/stderr chunks while a command is running. |
+| Files | Write, read, list, stat, move, chmod, delete, and glob files. |
+| Destroy | Tear down a sandbox safely and idempotently. |
+| Health | Report provider availability and runtime readiness. |
+| Logs | Expose provider and sandbox diagnostics. |
+
+## Persistence Model
+
+StacyVM uses a store abstraction so single-node installs can use SQLite while cluster deployments can use Postgres.
+
+```mermaid
+erDiagram
+ OWNER ||--o{ SANDBOX : owns
+ WORKER ||--o{ SANDBOX : runs
+ PROVIDER ||--o{ SANDBOX : backs
+ TEMPLATE ||--o{ SANDBOX : spawns
+ SANDBOX ||--o{ EXECUTION : records
+ SANDBOX ||--o{ AUDIT_EVENT : emits
+ WORKER ||--o{ LEASE : holds
+
+ OWNER {
+ string id
+ string api_key_hash
+ string quota_policy
+ }
+
+ WORKER {
+ string id
+ string endpoint
+ string status
+ datetime last_heartbeat
+ }
+
+ SANDBOX {
+ string id
+ string owner_id
+ string worker_id
+ string provider
+ string image
+ string state
+ datetime created_at
+ datetime expires_at
+ }
+
+ EXECUTION {
+ string id
+ string sandbox_id
+ int exit_code
+ string duration
+ datetime created_at
+ }
+
+ AUDIT_EVENT {
+ string id
+ string actor
+ string action
+ string target
+ datetime created_at
+ }
+```
+
+## Single-Node Mode
+
+Single-node mode runs the API, scheduler, local worker, provider, and store in one process on one host. This is the right starting point for internal staging and technical users.
+
+```mermaid
+flowchart LR
+ Client["Client"] --> Server["stacyvm serve"]
+ Server --> SQLite["SQLite"]
+ Server --> Docker["Docker provider"]
+ Docker --> Sandbox["Sandbox"]
+```
+
+## Multi-Worker Mode
+
+Multi-worker mode keeps the API/control plane separate from worker nodes. The scheduler assigns sandboxes to workers, and worker RPC plus leases prevent two workers from managing the same runtime.
+
+```mermaid
+flowchart LR
+ Client["Client"] --> Control["Control plane"]
+ Control --> Postgres["Postgres"]
+ Control --> W1["Worker A"]
+ Control --> W2["Worker B"]
+ W1 --> P1["Docker/Firecracker"]
+ W2 --> P2["Docker/PRoot"]
+```
+
+## Operational Boundaries
+
+- Use Docker for the broadest quickstart path.
+- Use Firecracker only on hosts where KVM, kernel, rootfs, agent, networking, and snapshot behavior have passed certification.
+- Use PRoot only after validating the real rootfs/bin setup on the target host.
+- Use remote workers when you need horizontal capacity, runtime isolation by node class, or enterprise deployment boundaries.
+
+## Related
+
+- [Provider contract](/docs/provider-contract)
+- [Runtime certification](/docs/runtime-certification)
+- [Remote worker staging](/docs/remote-worker-staging)
+- [Production readiness](/docs/production-readiness)
diff --git a/docs/cluster-conformance.md b/docs/cluster-conformance.md
new file mode 100644
index 0000000..2af19ad
--- /dev/null
+++ b/docs/cluster-conformance.md
@@ -0,0 +1,126 @@
+# Cluster Conformance Matrix
+
+This matrix defines the minimum checks StacyVM must pass before a branch is considered production-aligned for multi-worker operation. It is intentionally stricter than the single-node deployment smoke tests because cluster mode depends on durable ownership, worker identity, leases, and store behavior remaining consistent across processes.
+
+## CI Coverage
+
+The always-on CI entrypoint is:
+
+```bash
+scripts/ci-cluster-conformance.sh
+```
+
+It currently verifies:
+
+- SQLite passes the reusable store contract harness.
+- Worker route authentication accepts per-worker credentials.
+- Worker-specific credentials override the shared staging token.
+- Worker route authentication accepts short-lived signed worker tokens.
+- Worker route authentication rejects signed tokens scoped to the worker RPC audience.
+- Worker route authentication rejects revoked signed worker token IDs.
+- Worker RPC accepts short-lived signed control-plane-to-worker tokens.
+- Worker RPC rejects signed tokens scoped to the control-plane route audience.
+- Worker RPC rejects revoked signed worker token IDs.
+- Remote spawn can route through worker RPC using signed tokens without a shared worker token.
+- Worker RPC mTLS completes a real client-authenticated request using generated certificates.
+- Worker lease renewal is guarded by `worker:lease`.
+- A production-aligned cluster config with `auth.worker_tokens` or `auth.worker_signing_key` passes `stacyvm config lint --production`.
+- Worker identity certification smoke produces a Markdown report without token values.
+- Signed-token migration lint warns when shared worker tokens or invalid signing-key rotation state remain configured.
+- Postgres configuration with a valid DSN passes `stacyvm config lint --production`.
+- Live Postgres passes the reusable store contract when `STACYVM_POSTGRES_TEST_DSN` is set.
+- Live Postgres proves one active lease holder under concurrent acquire and expired takeover attempts.
+- Live Postgres proves migrations apply idempotently and record every expected schema version.
+- A Postgres-backed remote worker smoke runs control plane plus worker against the mock provider.
+
+For host-level worker identity signoff, run:
+
+```bash
+scripts/certify-worker-identity.sh worker-a
+scripts/certify-worker-identity.sh worker-a --format markdown --output worker-identity-certification.md
+```
+
+The script issues a signed worker token from a secret file, inspects unverified metadata, verifies the signature and audience, confirms revoked token IDs are rejected, and generates a no-secret rotation plan.
+
+## Store Matrix
+
+| Store | Status | Required Checks |
+|---|---|---|
+| SQLite | Supported for single-node and internal staging | `TestSQLiteStoreContract`, migration tests, backup/restore tests |
+| Postgres | Contract-backed cluster store path | `TestPostgresStoreContract`, `TestPostgresMigrationRehearsal`, Postgres migration alignment tests, lease takeover race tests, remote worker smoke, startup reconciliation with multiple workers |
+
+Postgres must not be marked production-ready for a deployment until it runs the same store contract suite as SQLite, passes lease race coverage, and passes the remote worker smoke in that deployment's target topology.
+
+## Worker Identity Matrix
+
+| Mode | Status | Intended Use |
+|---|---|---|
+| `auth.worker_token` | Supported | Local development and internal staging with a shared worker token |
+| `auth.worker_tokens.` | Supported | Production-aligned staging with individually rotatable worker credentials |
+| `auth.worker_signing_key` | Supported | Public or enterprise deployments that need short-lived signed worker credentials |
+| `auth.worker_signing_keys` | Supported | No-downtime signing-key rotation window for old verification keys |
+| Worker RPC mTLS | Supported | Enterprise deployments that require network-level worker transport identity |
+
+When `auth.worker_tokens` contains a worker ID, that worker must authenticate with its own token. The shared token is rejected for that worker ID.
+
+Signed worker tokens must use the `stacyvm-worker-v1` HMAC-SHA256 format. The signed subject must match `X-Worker-ID`, the `exp` claim must be in the future, and only worker scopes are granted.
+
+## Runtime Matrix
+
+| Runtime | Cluster Status | Notes |
+|---|---|---|
+| Mock | CI certified | Used for fast worker routing and control-plane smoke tests |
+| Docker | Host-certified | Requires Docker daemon access and runtime certification outside the sandboxed CI path |
+| gVisor/Kata | Host-certified | Requires configured Docker runtime and host-level certification |
+| Firecracker | Platform-gated | Requires Linux/KVM, kernel, rootfs, and agent assets |
+| PRoot | Platform-gated | Requires real rootfs/bin setup on the host |
+
+## Promotion Gates
+
+Before calling a multi-worker branch production-ready:
+
+1. `scripts/ci-cluster-conformance.sh` passes in CI.
+2. `scripts/smoke-remote-worker.sh` passes against a real control-plane plus worker pair.
+3. Runtime certification passes for every runtime advertised by the deployment.
+4. Postgres passes the store contract harness.
+5. Postgres lease tests prove one active holder per sandbox under concurrent acquisition, renewal, expiry, and takeover.
+6. Startup reconciliation is tested against persisted sandboxes whose owning worker is online, stale, draining, offline, and missing.
+7. Worker credentials are per-worker, rotated, and not shared with user or admin API credentials.
+
+## Current Phase 13 Position
+
+Phase 13 has completed:
+
+- Driver-based store selection.
+- SQLite store contract coverage.
+- Postgres-native migration definitions.
+- Postgres store driver and live contract path.
+- Per-worker token authentication.
+- Cluster conformance CI scaffolding.
+
+Phase 14 starts worker identity hardening on top of that foundation:
+
+- HMAC-signed worker tokens.
+- Signed-token config lint awareness.
+- Worker runtime token derivation for heartbeat and lease renewal.
+- Secret-file inputs for worker tokens and signing keys.
+- No-secret signing-key rotation plan generation.
+- Signed control-plane-to-worker RPC token derivation for remote worker calls.
+- Worker RPC mTLS config, transport wiring, and production lint checks.
+- Worker RPC mTLS conformance using generated CA, server, and client certificates.
+
+Phase 14 hardening additions (implemented):
+
+- OIDC/JWT RS256 bearer token authentication with JWKS and static public key support.
+- RBAC roles: viewer, operator, admin, tenant_admin with scoped permissions.
+- Tenant/project model: tenant CRUD, member role assignment, per-tenant audit log export.
+- Per-tenant policy controls: image/provider/network allow-deny rules with glob pattern matching.
+- Centralized signed worker token issuance via `/api/v1/admin/worker-tokens` (workers no longer need direct signing key access).
+- Postgres backup via `stacyvm db pg-backup` (wraps pg_dump).
+- Postgres migration rehearsal via `stacyvm db pg-rehearse` (schema version check before upgrades).
+- Admin UI: Tenants page with member management and policy controls.
+
+Remaining enterprise production work:
+
+- Extend multi-worker conformance beyond the mock provider into certified Docker, gVisor/Kata, and Firecracker hosts.
+- Run worker RPC mTLS smoke tests with deployment-issued certificates in the target enterprise network.
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..6d1e7f5
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,201 @@
+---
+title: "Production Deployment"
+description: "Deploy StacyVM as a single-node service with Docker, systemd, health checks, metrics, and release validation."
+---
+
+# Production Deployment
+
+This guide covers a single-node StacyVM deployment suitable for an internal service, staging, or a small production installation. The default production path uses the Docker provider because it works on the broadest set of hosts; Firecracker and PRoot require extra host setup and should be validated on the target platform before rollout.
+
+## Requirements
+
+- Linux host with Docker installed when using the Docker provider.
+- A persistent data directory, normally `/var/lib/stacyvm`.
+- A generated API key with at least 32 bytes of entropy.
+- TLS and public ingress handled by a reverse proxy or load balancer in front of StacyVM.
+- Explicit `server.cors_allowed_origins` for every browser origin that may call the API.
+- Health checks wired to the API endpoints listed below.
+
+StacyVM reads config from `./stacyvm.yaml`, then `~/.stacyvm/config.yaml`, then `STACYVM_` environment variables. In production, prefer a checked-in baseline config plus environment variables or secret files for secrets and environment-specific values. Worker credentials can be mounted through `auth.worker_token_file` and `auth.worker_signing_key_file`; the loader rejects configs that set both the inline secret and its file reference.
+
+Before starting a single-node staging or production host, lint the final config with the same environment variables the service will use:
+
+```bash
+STACYVM_AUTH_API_KEY=sk-live \
+STACYVM_AUTH_ADMIN_API_KEY=sk-admin \
+stacyvm config lint --production --file deploy/stacyvm.production.yaml
+```
+
+The lint command is deterministic and does not require Docker or KVM access. In production mode it fails wildcard CORS, missing auth, weak rate limits, relative database paths, missing sandbox caps, unsafe Docker settings, and other public-exposure risks. Use `stacyvm doctor --production` after linting when you also want live host checks for Docker, Firecracker, PRoot, database directories, and installed binaries.
+
+## Health and Metrics
+
+Use these endpoints for load balancers and monitors:
+
+| Endpoint | Purpose |
+|---|---|
+| `GET /api/v1/live` | Process liveness. Use this for simple restart checks. |
+| `GET /api/v1/ready` | Readiness. Use this before routing traffic after deploys. |
+| `GET /api/v1/health` | Dependency and provider health summary. |
+| `GET /api/v1/metrics/prometheus` | Prometheus metrics scrape endpoint. |
+
+Authenticated deployments should send `X-API-Key: ` to protected API endpoints. Keep health probes scoped to your private network if they bypass auth at an upstream proxy.
+
+After a deploy, run the smoke script:
+
+```bash
+STACYVM_SMOKE_URL=https://stacyvm.example.com STACYVM_API_KEY=sk-live scripts/smoke-deployment.sh
+```
+
+## Docker Compose
+
+The files in `deploy/` provide a production-oriented Compose starting point:
+
+- `deploy/docker-compose.yml` starts StacyVM and Traefik for live previews.
+- `deploy/stacyvm.production.yaml` enables auth, explicit CORS origins, rate limiting, sandbox caps, queueing, JSON logs, and persistent SQLite state.
+- `deploy/.env.example` lists the environment variables expected by the Compose file.
+- `deploy/stacyvm.env.example` is the systemd environment file template.
+
+Use separate values for `STACYVM_API_KEY` and `STACYVM_ADMIN_API_KEY` in production. Admin routes live under `/api/v1/admin/*` and should be restricted to operator networks where possible. Replace the example `server.cors_allowed_origins` value with the exact public console/API origins for your deployment; do not expose browser clients with wildcard CORS.
+
+See [admin-control-plane](/docs/admin-control-plane) for admin dashboard setup, quota operations, diagnostics, audit export, and audit retention notes. See [security-governance](/docs/security-governance) for the production admin hardening checklist and OIDC/SSO integration plan. The production config keeps 90 days of admin audit history with `auth.admin_audit_retention: "2160h"` and disables admin fallback with `auth.admin_fallback_enabled: false`.
+
+```bash
+cd deploy
+cp .env.example .env
+# Edit .env and replace STACYVM_API_KEY before starting.
+docker compose up -d
+docker compose logs -f stacyvm
+```
+
+For local image testing before a registry image exists:
+
+```bash
+docker build -t stacyvm:local ..
+STACYVM_IMAGE=stacyvm:local docker compose up -d
+```
+
+For non-invasive smoke runs on a shared host, override the published ports:
+
+```bash
+STACYVM_IMAGE=stacyvm:local STACYVM_HOST_PORT=17426 STACYVM_TRAEFIK_HOST_PORT=18080 docker compose up -d
+```
+
+Then validate the API surface:
+
+```bash
+scripts/smoke-deployment.sh http://127.0.0.1:17426 "$STACYVM_API_KEY"
+```
+
+Live-preview routing can be checked by spawning a sandbox that serves port `3000` and requesting Traefik with `Host: 3000-.`.
+
+## systemd
+
+Use `deploy/stacyvm.service` when running the binary directly on a Linux host.
+
+```bash
+sudo useradd --system --home /var/lib/stacyvm --shell /usr/sbin/nologin stacyvm
+sudo usermod -aG docker stacyvm
+sudo install -d -o stacyvm -g stacyvm /var/lib/stacyvm
+sudo install -d -m 0750 /etc/stacyvm
+sudo install -o root -g stacyvm -m 0640 deploy/stacyvm.production.yaml /etc/stacyvm/stacyvm.yaml
+sudo install -o root -g stacyvm -m 0640 deploy/stacyvm.env.example /etc/stacyvm/stacyvm.env
+sudo install -m 0755 bin/stacyvm /usr/local/bin/stacyvm
+sudo install -m 0755 bin/stacyvm-agent /usr/local/bin/stacyvm-agent
+sudo install -m 0644 deploy/stacyvm.service /etc/systemd/system/stacyvm.service
+```
+
+Edit `/etc/stacyvm/stacyvm.env` and set real `STACYVM_AUTH_API_KEY` and `STACYVM_AUTH_ADMIN_API_KEY` values. Then enable the service:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now stacyvm
+sudo systemctl status stacyvm
+```
+
+The included unit uses `WorkingDirectory=/etc/stacyvm` so StacyVM can load `/etc/stacyvm/stacyvm.yaml` through its current `./stacyvm.yaml` lookup path while keeping persistent database state in `/var/lib/stacyvm`.
+
+## Reverse Proxy
+
+Terminate TLS before StacyVM. A typical proxy should:
+
+- Forward API traffic to `http://127.0.0.1:7423`.
+- Preserve `X-API-Key` headers.
+- Route live preview hostnames such as `3000-sb-.` to Traefik when using Docker live previews.
+- Restrict admin and metrics endpoints to trusted networks.
+
+Set `server.preview_domain` or `STACYVM_SERVER_PREVIEW_DOMAIN` to the domain that resolves preview subdomains to your proxy.
+
+## Backups
+
+The default store is SQLite at `/var/lib/stacyvm/stacyvm.db`. Prefer the built-in backup command because it uses SQLite's online backup path and validates the output:
+
+```bash
+stacyvm db backup /backup/stacyvm-$(date +%Y%m%dT%H%M%SZ).db --database /var/lib/stacyvm/stacyvm.db
+```
+
+Restore requires the service to be stopped. The command validates the backup, creates a pre-restore safety copy of the current database, removes stale WAL/SHM sidecars, and replaces the target database:
+
+```bash
+sudo systemctl stop stacyvm
+stacyvm db restore /backup/stacyvm-20260508T120000Z.db --database /var/lib/stacyvm/stacyvm.db --yes
+sudo systemctl start stacyvm
+```
+
+For a manual fallback:
+
+```bash
+sudo systemctl stop stacyvm
+sudo cp /var/lib/stacyvm/stacyvm.db /backup/stacyvm.db
+sudo cp /var/lib/stacyvm/stacyvm.db-wal /backup/ 2>/dev/null || true
+sudo cp /var/lib/stacyvm/stacyvm.db-shm /backup/ 2>/dev/null || true
+sudo systemctl start stacyvm
+```
+
+If you run with Docker Compose, stop the service or snapshot the backing volume with your volume provider's backup tooling.
+
+## Upgrades
+
+Before changing binaries or images, rehearse the upgrade with the exact config and database path the service uses:
+
+```bash
+stacyvm upgrade rehearse \
+ --config /etc/stacyvm/stacyvm.yaml \
+ --database /var/lib/stacyvm/stacyvm.db \
+ --backup-output /backup/stacyvm-pre-upgrade.db
+```
+
+Use `--include-doctor` on the target host when you also want live provider checks before the upgrade.
+
+Upgrade flow:
+
+1. Check the release notes for config or API changes.
+2. Run `stacyvm upgrade rehearse` and resolve any failing checks.
+3. Back up `/var/lib/stacyvm/stacyvm.db` with `stacyvm db backup`.
+4. Replace the binary or update `STACYVM_IMAGE`.
+5. Restart the service.
+6. Confirm `GET /api/v1/ready` succeeds before routing traffic.
+7. If the upgrade fails, stop StacyVM and restore the pre-upgrade backup with `stacyvm db restore --yes`.
+
+## Support Bundles
+
+For support requests, generate a redacted bundle instead of sharing raw config, logs, or environment output:
+
+```bash
+stacyvm support bundle /tmp/stacyvm-support.json \
+ --config /etc/stacyvm/stacyvm.yaml \
+ --include-doctor \
+ --include-server
+```
+
+The bundle includes version/runtime data, redacted config shape, production config lint results, optional doctor checks, and optional `/api/v1/diagnostics` output. Secret-shaped keys, API keys, bearer tokens, and URLs with embedded credentials are redacted before the file is written.
+
+## Provider Notes
+
+Docker is the safest default for broad deployment compatibility. For stronger isolation, run Docker with gVisor (`runtime: "runsc"`) or Kata after validating the runtime on the host.
+
+Firecracker requires Linux/KVM, a kernel image, rootfs images, networking setup, and the `stacyvm-agent` binary available to the runtime. Keep Firecracker disabled in shared templates until a host conformance check passes.
+
+PRoot requires a real rootfs with the binaries your sandboxes need. Use it for restricted environments where Docker and KVM are unavailable, and validate memory/disk limits against the host because PRoot enforcement is not equivalent to VM isolation.
+
+Use [runtime-conformance](/docs/runtime-conformance) as the signoff checklist for Docker, gVisor, Kata, Firecracker, PRoot, E2B, and custom providers.
diff --git a/docs/docs.go b/docs/docs.go
index c76277f..35eaa94 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -15,6 +15,97 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
+ "/admin/audit": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return recent redacted admin route access records",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "List admin audit logs",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Maximum number of records, capped at 500",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Actor exact match",
+ "name": "actor",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "HTTP method exact match",
+ "name": "method",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "HTTP status exact match",
+ "name": "status",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Path substring match",
+ "name": "path",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Response format: json or csv",
+ "name": "format",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/internal_api_routes.AdminAuditResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/diagnostics": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return redacted build, store, provider, sandbox, event, and operation diagnostics",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get diagnostics",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.DiagnosticsResponse"
+ }
+ }
+ }
+ }
+ },
"/events": {
"get": {
"security": [
@@ -34,7 +125,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Event"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Event"
}
}
}
@@ -65,6 +156,31 @@ const docTemplate = `{
}
}
},
+ "/live": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return whether the StacyVM API process is alive",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Liveness check",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.HealthResponse"
+ }
+ }
+ }
+ }
+ },
"/metrics": {
"get": {
"security": [
@@ -90,6 +206,31 @@ const docTemplate = `{
}
}
},
+ "/metrics/prometheus": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return runtime, provider, sandbox, event, and operation metrics in Prometheus text format",
+ "produces": [
+ "text/plain"
+ ],
+ "tags": [
+ "system"
+ ],
+ "summary": "Get Prometheus metrics",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
"/providers": {
"get": {
"security": [
@@ -180,165 +321,144 @@ const docTemplate = `{
"404": {
"description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
}
},
- "/sandboxes": {
+ "/quotas": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Return all active sandboxes",
+ "description": "Return all persisted owner quota overrides",
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
+ "quotas"
],
- "summary": "List sandboxes",
+ "summary": "List owner quotas",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Sandbox"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
}
}
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
}
}
- },
- "post": {
+ }
+ },
+ "/quotas/summary": {
+ "get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Spawn a new sandbox with the given configuration",
- "consumes": [
- "application/json"
- ],
+ "description": "Return non-identifying counts for persisted owner quota overrides",
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
- ],
- "summary": "Create a sandbox",
- "parameters": [
- {
- "description": "Spawn request",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.SpawnRequest"
- }
- }
+ "quotas"
],
+ "summary": "Get quota summary",
"responses": {
- "201": {
- "description": "Created",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Sandbox"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "500": {
- "description": "Internal Server Error",
+ "200": {
+ "description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary"
}
}
}
- },
- "delete": {
+ }
+ },
+ "/quotas/{ownerID}": {
+ "get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Destroy all expired sandboxes and return the count",
+ "description": "Return the persisted quota override for an owner",
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
+ "quotas"
+ ],
+ "summary": "Get owner quota",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Owner ID",
+ "name": "ownerID",
+ "in": "path",
+ "required": true
+ }
],
- "summary": "Prune sandboxes",
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/internal_api_routes.PruneResponse"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
}
},
- "500": {
- "description": "Internal Server Error",
+ "404": {
+ "description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
- }
- },
- "/sandboxes/{sandboxID}": {
- "get": {
+ },
+ "put": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Return a sandbox by its ID",
+ "description": "Create or update quota overrides for an owner",
+ "consumes": [
+ "application/json"
+ ],
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
+ "quotas"
],
- "summary": "Get a sandbox",
+ "summary": "Save owner quota",
"parameters": [
{
"type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
+ "description": "Owner ID",
+ "name": "ownerID",
"in": "path",
"required": true
+ },
+ {
+ "description": "Quota request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
+ }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Sandbox"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
}
}
}
@@ -349,19 +469,19 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
- "description": "Destroy a sandbox and release its resources",
+ "description": "Delete the quota override for an owner",
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
+ "quotas"
],
- "summary": "Destroy a sandbox",
+ "summary": "Delete owner quota",
"parameters": [
{
"type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
+ "description": "Owner ID",
+ "name": "ownerID",
"in": "path",
"required": true
}
@@ -376,161 +496,106 @@ const docTemplate = `{
"404": {
"description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
}
},
- "/sandboxes/{sandboxID}/exec": {
- "post": {
+ "/quotas/{ownerID}/usage": {
+ "get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Run a command inside a sandbox. Set stream=true for streaming output.",
- "consumes": [
- "application/json"
- ],
+ "description": "Return active sandbox usage and effective quota for an owner",
"produces": [
"application/json"
],
"tags": [
- "sandboxes"
+ "quotas"
],
- "summary": "Execute a command",
+ "summary": "Get owner quota usage",
"parameters": [
{
"type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
+ "description": "Owner ID",
+ "name": "ownerID",
"in": "path",
"required": true
- },
- {
- "description": "Exec request",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.ExecRequest"
- }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.ExecResult"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerUsage"
}
}
}
}
},
- "/sandboxes/{sandboxID}/exec/ws": {
+ "/ready": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Open a WebSocket connection to execute a command with streaming output",
+ "description": "Return whether the API is ready to serve sandbox traffic",
+ "produces": [
+ "application/json"
+ ],
"tags": [
- "sandboxes"
- ],
- "summary": "Execute via WebSocket",
- "parameters": [
- {
- "type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
- "in": "path",
- "required": true
- }
+ "system"
],
+ "summary": "Readiness check",
"responses": {
- "101": {
- "description": "WebSocket upgrade"
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.ReadinessResponse"
+ }
},
- "400": {
- "description": "Bad request"
+ "503": {
+ "description": "Service Unavailable",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.ReadinessResponse"
+ }
}
}
}
},
- "/sandboxes/{sandboxID}/files": {
+ "/sandboxes": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Read file content from a sandbox",
+ "description": "Return all active sandboxes",
"produces": [
- "application/octet-stream"
+ "application/json"
],
"tags": [
"sandboxes"
],
- "summary": "Read a file",
- "parameters": [
- {
- "type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "File path inside the sandbox",
- "name": "path",
- "in": "query",
- "required": true
- }
- ],
+ "summary": "List sandboxes",
"responses": {
"200": {
"description": "OK",
"schema": {
- "type": "file"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
@@ -541,7 +606,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
- "description": "Write content to a file inside a sandbox",
+ "description": "Spawn a new sandbox with the given configuration",
"consumes": [
"application/json"
],
@@ -551,272 +616,190 @@ const docTemplate = `{
"tags": [
"sandboxes"
],
- "summary": "Write a file",
+ "summary": "Create a sandbox",
"parameters": [
{
- "type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
- "in": "path",
- "required": true
- },
- {
- "description": "File write request",
+ "description": "Spawn request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.FileWriteRequest"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest"
}
}
],
"responses": {
- "200": {
- "description": "OK",
+ "201": {
+ "description": "Created",
"schema": {
- "$ref": "#/definitions/internal_api_routes.StatusResponse"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
}
},
"400": {
"description": "Bad Request",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
- "404": {
- "description": "Not Found",
+ "429": {
+ "description": "Too Many Requests",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
- }
- },
- "/sandboxes/{sandboxID}/files/list": {
- "get": {
+ },
+ "delete": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "List files in a directory inside a sandbox",
+ "description": "Destroy all expired sandboxes and return the count",
"produces": [
"application/json"
],
"tags": [
"sandboxes"
],
- "summary": "List files",
- "parameters": [
- {
- "type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
- "in": "path",
- "required": true
- },
- {
- "type": "string",
- "description": "Directory path (default: /)",
- "name": "path",
- "in": "query"
- }
- ],
+ "summary": "Prune sandboxes",
"responses": {
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.FileInfo"
- }
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/internal_api_routes.PruneResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
}
},
- "/sandboxes/{sandboxID}/logs": {
- "get": {
+ "/sandboxes/admission": {
+ "post": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Retrieve console log lines from a sandbox",
+ "description": "Return whether a spawn request would be allowed, queued, or denied by quota and scheduler limits",
+ "consumes": [
+ "application/json"
+ ],
"produces": [
"application/json"
],
"tags": [
"sandboxes"
],
- "summary": "Get console logs",
+ "summary": "Evaluate spawn admission",
"parameters": [
{
- "type": "string",
- "description": "Sandbox ID",
- "name": "sandboxID",
- "in": "path",
- "required": true
- },
- {
- "type": "integer",
- "description": "Number of lines to retrieve (default: 100)",
- "name": "lines",
- "in": "query"
+ "description": "Spawn request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest"
+ }
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
- "type": "array",
- "items": {
- "type": "string"
- }
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnAdmissionDecision"
}
},
- "404": {
- "description": "Not Found",
+ "400": {
+ "description": "Bad Request",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
}
},
- "/templates": {
+ "/sandboxes/{sandboxID}": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Return all registered templates",
- "produces": [
- "application/json"
- ],
- "tags": [
- "templates"
- ],
- "summary": "List templates",
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
- }
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- }
- }
- },
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Register a new sandbox template",
- "consumes": [
- "application/json"
- ],
+ "description": "Return a sandbox by its ID",
"produces": [
"application/json"
],
"tags": [
- "templates"
+ "sandboxes"
],
- "summary": "Create a template",
+ "summary": "Get a sandbox",
"parameters": [
{
- "description": "Template definition",
- "name": "request",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
- }
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
}
],
"responses": {
- "201": {
- "description": "Created",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
- }
- },
- "400": {
- "description": "Bad Request",
+ "200": {
+ "description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
}
},
- "409": {
- "description": "Conflict",
+ "404": {
+ "description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
- }
- },
- "/templates/{name}": {
- "get": {
+ },
+ "delete": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Return a template by its name",
+ "description": "Destroy a sandbox and release its resources",
"produces": [
"application/json"
],
"tags": [
- "templates"
+ "sandboxes"
],
- "summary": "Get a template",
+ "summary": "Destroy a sandbox",
"parameters": [
{
"type": "string",
- "description": "Template name",
- "name": "name",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
"in": "path",
"required": true
}
@@ -825,30 +808,32 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
+ "$ref": "#/definitions/internal_api_routes.StatusResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
- },
- "put": {
+ }
+ },
+ "/sandboxes/{sandboxID}/exec": {
+ "post": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Update an existing template by name",
+ "description": "Run a command inside a sandbox. Set stream=true for streaming output.",
"consumes": [
"application/json"
],
@@ -856,24 +841,24 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "templates"
+ "sandboxes"
],
- "summary": "Update a template",
+ "summary": "Execute a command",
"parameters": [
{
"type": "string",
- "description": "Template name",
- "name": "name",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
"in": "path",
"required": true
},
{
- "description": "Updated template",
+ "description": "Exec request",
"name": "request",
"in": "body",
"required": true,
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.ExecRequest"
}
}
],
@@ -881,37 +866,587 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.ExecResult"
}
},
"404": {
"description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
- },
- "delete": {
+ }
+ },
+ "/sandboxes/{sandboxID}/exec/ws": {
+ "get": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Delete a template by name",
- "produces": [
+ "description": "Open a WebSocket connection to execute a command with streaming output",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Execute via WebSocket",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "101": {
+ "description": "WebSocket upgrade"
+ },
+ "400": {
+ "description": "Bad request"
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/extend": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Add additional time to a sandbox's expiration",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Extend sandbox TTL",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "TTL extension",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ttl": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/files": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Read file content from a sandbox",
+ "produces": [
+ "application/octet-stream"
+ ],
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Read a file",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "File path inside the sandbox",
+ "name": "path",
+ "in": "query",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Write content to a file inside a sandbox",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Write a file",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "File write request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.FileWriteRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.StatusResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/files/list": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "List files in a directory inside a sandbox",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "List files",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Directory path (default: /)",
+ "name": "path",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.FileInfo"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/logs": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Retrieve console log lines from a sandbox",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Get console logs",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "integer",
+ "description": "Number of lines to retrieve (default: 100)",
+ "name": "lines",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/snapshots": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all pre-built VM snapshots available for fast restore",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "snapshots"
+ ],
+ "summary": "List snapshots",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_providers.SnapshotSummary"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/templates": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all registered templates",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "templates"
+ ],
+ "summary": "List templates",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Register a new sandbox template",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "templates"
+ ],
+ "summary": "Create a template",
+ "parameters": [
+ {
+ "description": "Template definition",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/templates/{name}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return a template by its name",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "templates"
+ ],
+ "summary": "Get a template",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update an existing template by name",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "templates"
+ ],
+ "summary": "Update a template",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated template",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a template by name",
+ "produces": [
"application/json"
],
"tags": [
@@ -937,26 +1472,186 @@ const docTemplate = `{
"404": {
"description": "Not Found",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/templates/{name}/spawn": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Create a new sandbox using a template's configuration, with optional overrides",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "templates"
+ ],
+ "summary": "Spawn from template",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Optional overrides",
+ "name": "request",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.TemplateSpawnOverrides"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ },
+ "/workers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return worker registry records and heartbeat state",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workers"
+ ],
+ "summary": "List workers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/internal_api_routes.WorkerResponse"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/workers/{workerID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return one worker registry record",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "workers"
+ ],
+ "summary": "Get worker",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Worker ID",
+ "name": "workerID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.WorkerResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Remove a worker registry record",
+ "tags": [
+ "workers"
+ ],
+ "summary": "Delete worker",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Worker ID",
+ "name": "workerID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/internal_api_routes.StatusResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.APIError"
}
}
}
}
},
- "/templates/{name}/spawn": {
+ "/workers/{workerID}/heartbeat": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
- "description": "Create a new sandbox using a template's configuration, with optional overrides",
+ "description": "Create or update worker registry state for a worker",
"consumes": [
"application/json"
],
@@ -964,43 +1659,32 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "templates"
+ "workers"
],
- "summary": "Spawn from template",
+ "summary": "Heartbeat worker",
"parameters": [
{
"type": "string",
- "description": "Template name",
- "name": "name",
+ "description": "Worker ID",
+ "name": "workerID",
"in": "path",
"required": true
},
{
- "description": "Optional overrides",
+ "description": "Worker heartbeat",
"name": "request",
"in": "body",
+ "required": true,
"schema": {
- "$ref": "#/definitions/internal_api_routes.TemplateSpawnOverrides"
+ "$ref": "#/definitions/internal_api_routes.WorkerHeartbeatRequest"
}
}
],
"responses": {
- "201": {
- "description": "Created",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.Sandbox"
- }
- },
- "404": {
- "description": "Not Found",
- "schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
- }
- },
- "500": {
- "description": "Internal Server Error",
+ "200": {
+ "description": "OK",
"schema": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.APIError"
+ "$ref": "#/definitions/internal_api_routes.WorkerResponse"
}
}
}
@@ -1008,18 +1692,53 @@ const docTemplate = `{
}
},
"definitions": {
- "github_com_stacyvm-dev_stacyvm_internal_httputil.APIError": {
+ "github_com_StacyOs_stacyvm_internal_api_middleware.RateLimitStats": {
+ "type": "object",
+ "properties": {
+ "active_buckets": {
+ "type": "integer"
+ },
+ "allowed_total": {
+ "type": "integer"
+ },
+ "bucket_ttl": {
+ "type": "string"
+ },
+ "burst": {
+ "type": "integer"
+ },
+ "cleanup_interval": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "evicted_total": {
+ "type": "integer"
+ },
+ "key_by": {
+ "type": "string"
+ },
+ "limited_total": {
+ "type": "integer"
+ },
+ "requests_per_minute": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_httputil.APIError": {
"type": "object",
"properties": {
"code": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_httputil.ErrorCode"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_httputil.ErrorCode"
},
"message": {
"type": "string"
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_httputil.ErrorCode": {
+ "github_com_StacyOs_stacyvm_internal_httputil.ErrorCode": {
"type": "string",
"enum": [
"NOT_FOUND",
@@ -1027,7 +1746,9 @@ const docTemplate = `{
"INTERNAL_ERROR",
"UNAUTHORIZED",
"CONFLICT",
- "UNAVAILABLE"
+ "UNAVAILABLE",
+ "TIMEOUT",
+ "RESOURCE_LIMIT"
],
"x-enum-varnames": [
"CodeNotFound",
@@ -1035,10 +1756,12 @@ const docTemplate = `{
"CodeInternal",
"CodeUnauth",
"CodeConflict",
- "CodeUnavailable"
+ "CodeUnavailable",
+ "CodeTimeout",
+ "CodeResourceLimit"
]
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.Event": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Event": {
"type": "object",
"properties": {
"data": {
@@ -1057,11 +1780,25 @@ const docTemplate = `{
"type": "string"
},
"type": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.EventType"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.EventType"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.EventBusStats": {
+ "type": "object",
+ "properties": {
+ "events_total": {
+ "type": "integer"
+ },
+ "history_size": {
+ "type": "integer"
+ },
+ "subscribers": {
+ "type": "integer"
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.EventType": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.EventType": {
"type": "string",
"enum": [
"sandbox.created",
@@ -1070,8 +1807,19 @@ const docTemplate = `{
"sandbox.error",
"exec.started",
"exec.completed",
+ "exec.failed",
+ "exec.timeout",
"file.written",
- "file.read"
+ "file.read",
+ "operation.failed",
+ "resource.limit",
+ "provider.failed",
+ "reconcile.action",
+ "spawn.queued",
+ "spawn.dequeued",
+ "spawn.queue_timeout",
+ "quota.saved",
+ "quota.deleted"
],
"x-enum-varnames": [
"EventSandboxCreated",
@@ -1080,11 +1828,22 @@ const docTemplate = `{
"EventSandboxError",
"EventExecStarted",
"EventExecCompleted",
+ "EventExecFailed",
+ "EventExecTimeout",
"EventFileWritten",
- "EventFileRead"
+ "EventFileRead",
+ "EventOperationFailed",
+ "EventResourceLimit",
+ "EventProviderFailed",
+ "EventReconcileAction",
+ "EventSpawnQueued",
+ "EventSpawnDequeued",
+ "EventSpawnQueueTimeout",
+ "EventQuotaSaved",
+ "EventQuotaDeleted"
]
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.ExecRequest": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.ExecRequest": {
"type": "object",
"properties": {
"args": {
@@ -1102,6 +1861,9 @@ const docTemplate = `{
"type": "string"
}
},
+ "mode": {
+ "type": "string"
+ },
"stream": {
"type": "boolean"
},
@@ -1113,7 +1875,7 @@ const docTemplate = `{
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.ExecResult": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.ExecResult": {
"type": "object",
"properties": {
"duration": {
@@ -1130,7 +1892,7 @@ const docTemplate = `{
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.FileInfo": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.FileInfo": {
"type": "object",
"properties": {
"is_dir": {
@@ -1150,7 +1912,7 @@ const docTemplate = `{
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.FileWriteRequest": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.FileWriteRequest": {
"type": "object",
"properties": {
"content": {
@@ -1164,7 +1926,137 @@ const docTemplate = `{
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.Sandbox": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OperationMetrics": {
+ "type": "object",
+ "properties": {
+ "failure_total": {
+ "type": "integer"
+ },
+ "last_error": {
+ "type": "string"
+ },
+ "last_observed_unix": {
+ "type": "integer"
+ },
+ "latency_avg_ms": {
+ "type": "integer"
+ },
+ "latency_count": {
+ "type": "integer"
+ },
+ "latency_max_ms": {
+ "type": "integer"
+ },
+ "latency_min_ms": {
+ "type": "integer"
+ },
+ "latency_total_ms": {
+ "type": "integer"
+ },
+ "operation": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "success_total": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OperationalLimitsInfo": {
+ "type": "object",
+ "properties": {
+ "default_exec_timeout": {
+ "type": "string"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_sandboxes_per_owner": {
+ "type": "integer"
+ },
+ "max_spawn_queue": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "spawn_overflow": {
+ "type": "string"
+ },
+ "spawn_queue_timeout": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OwnerUsage": {
+ "type": "object",
+ "properties": {
+ "active_sandboxes": {
+ "type": "integer"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "quota_configured": {
+ "type": "boolean"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer"
+ },
+ "with_max_exec_timeout": {
+ "type": "integer"
+ },
+ "with_max_sandboxes": {
+ "type": "integer"
+ },
+ "with_max_ttl": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox": {
"type": "object",
"properties": {
"created_at": {
@@ -1179,55 +2071,171 @@ const docTemplate = `{
"image": {
"type": "string"
},
- "memory_mb": {
+ "memory_mb": {
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "preview_domain": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "state": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SandboxState"
+ },
+ "tenant_id": {
+ "type": "string"
+ },
+ "vcpus": {
+ "type": "integer"
+ },
+ "vm_id": {
+ "type": "string"
+ },
+ "worker_id": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SandboxState": {
+ "type": "string",
+ "enum": [
+ "creating",
+ "running",
+ "idle",
+ "unhealthy",
+ "expired",
+ "destroyed",
+ "error"
+ ],
+ "x-enum-varnames": [
+ "StateCreating",
+ "StateRunning",
+ "StateIdle",
+ "StateUnhealthy",
+ "StateExpired",
+ "StateDestroyed",
+ "StateError"
+ ]
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SchedulerStatus": {
+ "type": "object",
+ "properties": {
+ "admission_control": {
+ "type": "string"
+ },
+ "eligible_workers": {
+ "type": "integer"
+ },
+ "max_spawn_queue": {
+ "type": "integer"
+ },
+ "selected_worker_id": {
+ "type": "string"
+ },
+ "spawn_dequeued_total": {
+ "type": "integer"
+ },
+ "spawn_overflow": {
+ "type": "string"
+ },
+ "spawn_queue_depth": {
+ "type": "integer"
+ },
+ "spawn_queue_timeout": {
+ "type": "string"
+ },
+ "spawn_queue_timeouts": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_avg": {
+ "type": "string"
+ },
+ "spawn_queue_wait_avg_ms": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_count": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_max": {
+ "type": "string"
+ },
+ "spawn_queue_wait_max_ms": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_total": {
+ "type": "string"
+ },
+ "spawn_queue_wait_total_ms": {
+ "type": "integer"
+ },
+ "spawn_queued_total": {
+ "type": "integer"
+ },
+ "worker_id": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SecretConfig": {
+ "type": "object",
+ "properties": {
+ "inject_at": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SpawnAdmissionDecision": {
+ "type": "object",
+ "properties": {
+ "active_owner_sandboxes": {
+ "type": "integer"
+ },
+ "active_sandboxes": {
+ "type": "integer"
+ },
+ "allowed": {
+ "type": "boolean"
+ },
+ "eligible_workers": {
"type": "integer"
},
- "metadata": {
- "type": "object",
- "additionalProperties": {
- "type": "string"
- }
+ "max_owner_sandboxes": {
+ "type": "integer"
},
- "provider": {
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
"type": "string"
},
- "state": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.SandboxState"
+ "queueable": {
+ "type": "boolean"
},
- "vcpus": {
- "type": "integer"
- }
- }
- },
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.SandboxState": {
- "type": "string",
- "enum": [
- "creating",
- "running",
- "idle",
- "destroyed",
- "error"
- ],
- "x-enum-varnames": [
- "StateCreating",
- "StateRunning",
- "StateIdle",
- "StateDestroyed",
- "StateError"
- ]
- },
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.SecretConfig": {
- "type": "object",
- "properties": {
- "inject_at": {
+ "reason": {
"type": "string"
},
- "name": {
+ "selected_worker_id": {
+ "type": "string"
+ },
+ "worker_reason": {
"type": "string"
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.SpawnRequest": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest": {
"type": "object",
"properties": {
"image": {
@@ -1242,12 +2250,18 @@ const docTemplate = `{
"type": "string"
}
},
+ "owner_id": {
+ "type": "string"
+ },
"provider": {
"type": "string"
},
"template": {
"type": "string"
},
+ "tenant_id": {
+ "type": "string"
+ },
"ttl": {
"type": "string"
},
@@ -1256,7 +2270,7 @@ const docTemplate = `{
}
}
},
- "github_com_stacyvm-dev_stacyvm_internal_orchestrator.Template": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Template": {
"type": "object",
"properties": {
"allowed_hosts": {
@@ -1295,7 +2309,7 @@ const docTemplate = `{
"secrets": {
"type": "array",
"items": {
- "$ref": "#/definitions/github_com_stacyvm-dev_stacyvm_internal_orchestrator.SecretConfig"
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SecretConfig"
}
},
"setup": {
@@ -1315,6 +2329,141 @@ const docTemplate = `{
}
}
},
+ "github_com_StacyOs_stacyvm_internal_providers.SnapshotSummary": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ }
+ }
+ },
+ "internal_api_routes.AdminAuditResponse": {
+ "type": "object",
+ "properties": {
+ "actor": {
+ "type": "string",
+ "example": "admin"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "duration_ms": {
+ "type": "integer",
+ "example": 4
+ },
+ "id": {
+ "type": "integer",
+ "example": 42
+ },
+ "method": {
+ "type": "string",
+ "example": "PUT"
+ },
+ "path": {
+ "type": "string",
+ "example": "/api/v1/admin/quotas/owner-a"
+ },
+ "remote_addr": {
+ "type": "string",
+ "example": "127.0.0.1"
+ },
+ "request_id": {
+ "type": "string",
+ "example": "req-abc123"
+ },
+ "status": {
+ "type": "integer",
+ "example": 200
+ },
+ "tenant_id": {
+ "type": "string",
+ "example": "tenant-acme"
+ },
+ "user_agent": {
+ "type": "string",
+ "example": "stacyvm-web"
+ }
+ }
+ },
+ "internal_api_routes.DiagnosticsResponse": {
+ "type": "object",
+ "properties": {
+ "build": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "events": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.EventBusStats"
+ },
+ "generated_at": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "leases": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "limits": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OperationalLimitsInfo"
+ },
+ "operations": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.OperationMetrics"
+ }
+ },
+ "process": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/internal_api_routes.ProviderHealth"
+ }
+ },
+ "quotas": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary"
+ },
+ "rate_limit": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_api_middleware.RateLimitStats"
+ },
+ "redactions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "remediation": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "sandboxes": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "scheduler": {
+ "$ref": "#/definitions/github_com_StacyOs_stacyvm_internal_orchestrator.SchedulerStatus"
+ },
+ "store": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "workers": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
"internal_api_routes.HealthResponse": {
"type": "object",
"properties": {
@@ -1370,6 +2519,9 @@ const docTemplate = `{
"type": "boolean",
"example": true
},
+ "health": {
+ "$ref": "#/definitions/internal_api_routes.ProviderHealth"
+ },
"healthy": {
"type": "boolean",
"example": true
@@ -1384,20 +2536,86 @@ const docTemplate = `{
}
}
},
+ "internal_api_routes.ProviderHealth": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "spawn",
+ "exec",
+ "files"
+ ]
+ },
+ "default": {
+ "type": "boolean",
+ "example": true
+ },
+ "error": {
+ "type": "string",
+ "example": "health check returned false"
+ },
+ "healthy": {
+ "type": "boolean",
+ "example": true
+ },
+ "last_checked": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "latency_ms": {
+ "type": "integer",
+ "example": 3
+ },
+ "name": {
+ "type": "string",
+ "example": "docker"
+ },
+ "runtime_count": {
+ "type": "integer",
+ "example": 2
+ }
+ }
+ },
"internal_api_routes.ProviderInfo": {
"type": "object",
"properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"default": {
"type": "boolean",
"example": true
},
+ "error": {
+ "type": "string",
+ "example": "health check returned false"
+ },
"healthy": {
"type": "boolean",
"example": true
},
+ "last_checked": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "latency_ms": {
+ "type": "integer",
+ "example": 3
+ },
"name": {
"type": "string",
"example": "firecracker"
+ },
+ "runtime_count": {
+ "type": "integer",
+ "example": 2
}
}
},
@@ -1410,6 +2628,37 @@ const docTemplate = `{
}
}
},
+ "internal_api_routes.ReadinessResponse": {
+ "type": "object",
+ "properties": {
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/internal_api_routes.ProviderHealth"
+ }
+ },
+ "ready_providers": {
+ "type": "integer",
+ "example": 1
+ },
+ "status": {
+ "type": "string",
+ "example": "ready"
+ },
+ "total_providers": {
+ "type": "integer",
+ "example": 2
+ },
+ "uptime": {
+ "type": "string",
+ "example": "2h30m15s"
+ },
+ "version": {
+ "type": "string",
+ "example": "1.0.0"
+ }
+ }
+ },
"internal_api_routes.StatusResponse": {
"type": "object",
"properties": {
@@ -1431,6 +2680,84 @@ const docTemplate = `{
"example": "30m"
}
}
+ },
+ "internal_api_routes.WorkerHeartbeatRequest": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "capacity": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "hostname": {
+ "type": "string",
+ "example": "stacyvm-host-1"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string",
+ "example": "online"
+ }
+ }
+ },
+ "internal_api_routes.WorkerResponse": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "capacity": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2026-05-09T10:00:00Z"
+ },
+ "hostname": {
+ "type": "string",
+ "example": "stacyvm-host-1"
+ },
+ "id": {
+ "type": "string",
+ "example": "worker-local"
+ },
+ "last_heartbeat": {
+ "type": "string",
+ "example": "2026-05-09T10:30:00Z"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "stale": {
+ "type": "boolean",
+ "example": false
+ },
+ "status": {
+ "type": "string",
+ "example": "online"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2026-05-09T10:30:00Z"
+ }
+ }
}
},
"securityDefinitions": {
diff --git a/docs/enterprise-signoff-runbook.md b/docs/enterprise-signoff-runbook.md
new file mode 100644
index 0000000..36556ca
--- /dev/null
+++ b/docs/enterprise-signoff-runbook.md
@@ -0,0 +1,230 @@
+# Enterprise Production Signoff Runbook
+
+This runbook covers the evidence-collection steps that operators must run on
+their own infrastructure before a StacyVM enterprise multi-worker deployment is
+considered signed off for production. All automated CI gates (cluster
+conformance, mTLS smoke, runtime certification) must pass first.
+
+## Prerequisites
+
+- StacyVM binary built for the target platform (`make build` or release artifact).
+- A Postgres cluster accessible from the control plane.
+- At least one worker host with the target runtime installed (Docker, Firecracker, etc.).
+- A PKI that can issue TLS certificates (corporate CA, Vault, cert-manager, etc.).
+
+---
+
+## 1. Worker RPC mTLS smoke with deployment-issued certificates
+
+CI validates mTLS with ephemeral certificates. This step proves the same path
+works with your actual PKI.
+
+### Prerequisites
+
+Issue three certificates from your deployment CA:
+
+| File | Subject | SAN |
+|---|---|---|
+| `ca.crt` | Your CA certificate | — |
+| `worker.crt` / `worker.key` | Worker RPC server cert | `IP:` or `DNS:` |
+| `cp.crt` / `cp.key` | Control-plane client cert | `CN=stacyvm-control-plane` |
+
+### Run
+
+```bash
+scripts/smoke-remote-worker.sh ./stacyvm --mtls \
+ --ca-cert /path/to/ca.crt \
+ --server-cert /path/to/worker.crt \
+ --server-key /path/to/worker.key \
+ --client-cert /path/to/cp.crt \
+ --client-key /path/to/cp.key
+```
+
+### Expected output
+
+```
+==> Remote worker smoke PASSED [mTLS]
+ mTLS certs used:
+ CA: /path/to/ca.crt
+ server: /path/to/worker.crt
+ client: /path/to/cp.crt
+```
+
+### What it proves
+
+- Control plane authenticates to worker RPC over TLS (mutual auth).
+- Worker presents a valid server cert signed by the deployment CA.
+- Sandbox spawn, status, exec, and destroy all succeed over the mTLS channel.
+
+### Record
+
+Retain the script output (or a screenshot) as evidence. Reference it in your
+change-management ticket using the format:
+
+```
+mTLS smoke: PASSED
+Binary: stacyvm
+CA:
+Date:
+Operator:
+```
+
+---
+
+## 2. Runtime certification on each worker host
+
+Run this on **every** worker host for **every** runtime it will serve. The
+report becomes the durable evidence artifact.
+
+### Docker / gVisor / Kata
+
+```bash
+# Host-level checks + StacyVM integration smoke.
+scripts/certify-runtime.sh docker \
+ --stacyvm-url https://:7423 \
+ --stacyvm-api-key "$STACYVM_API_KEY" \
+ --format markdown \
+ --output $(hostname)-docker-certification.md
+
+# Review the report.
+cat $(hostname)-docker-certification.md
+```
+
+For gVisor or Kata, replace `docker` with `gvisor` or `kata`. The script will
+check for the runtime in `docker info` and attempt `docker run --runtime=runsc`.
+
+### Firecracker
+
+```bash
+export STACYVM_FIRECRACKER_KERNEL=/var/lib/stacyvm/vmlinux.bin
+
+scripts/certify-runtime.sh firecracker \
+ --stacyvm-url https://:7423 \
+ --stacyvm-api-key "$STACYVM_API_KEY" \
+ --format markdown \
+ --output $(hostname)-firecracker-certification.md
+```
+
+### Auto-start mode (no external server needed)
+
+If you want to certify the binary itself rather than a running cluster:
+
+```bash
+scripts/certify-runtime.sh docker \
+ --stacyvm-bin ./stacyvm \
+ --format markdown \
+ --output $(hostname)-docker-certification.md
+```
+
+### What the report covers
+
+| Check | Meaning |
+|---|---|
+| `docker.cli` | Docker CLI found in PATH |
+| `docker.daemon` | Docker daemon reachable |
+| `docker.seccomp` | seccomp advertised by docker info |
+| `docker.run` | `docker run alpine echo ok` succeeds |
+| `stacyvm.ready` | StacyVM API responds to `/api/v1/ready` |
+| `stacyvm.provider_health` | Provider health endpoint returns healthy |
+| `stacyvm.spawn` | Sandbox spawned via target runtime |
+| `stacyvm.exec` | Command executed in sandbox (exit 0) |
+| `stacyvm.destroy` | Sandbox destroyed |
+
+### Record
+
+Retain the Markdown report. Every worker host that serves production traffic
+must have a report on file before go-live. Reference them in your
+change-management ticket:
+
+```
+Runtime certification:
+ Host: worker-01.prod.example.com
+ Runtime: docker (gVisor)
+ Report: worker-01-docker-certification.md
+ Status: PASS
+ Date:
+ Operator:
+```
+
+---
+
+## 3. Postgres migration rehearsal
+
+Run before every binary upgrade that includes a database schema change.
+
+```bash
+stacyvm db pg-rehearse --dsn "$STACYVM_DATABASE_DSN"
+```
+
+### Expected output
+
+```
+connection: OK
+schema_migrations: N applied — versions [1 2 3 ... N]
+tables: all 16 expected tables present
+pg-rehearse: PASS — schema is production-aligned
+```
+
+If any tables are missing, run the new binary once with
+`STACYVM_DATABASE_DSN` set and the server will apply migrations automatically
+on startup. Then re-run `pg-rehearse` to confirm.
+
+---
+
+## 4. OIDC/SSO sign-off
+
+For deployments using `auth.oidc_enabled`, validate the configuration before
+exposing to users.
+
+```bash
+stacyvm config lint --production --file stacyvm.yaml
+```
+
+All `auth.oidc_*` checks must be `[PASS]`. Common failure modes:
+
+| Lint output | Fix |
+|---|---|
+| `OIDC issuer is not set` | Add `auth.oidc_issuer` pointing to your IdP |
+| `no OIDC verification key configured` | Add `auth.oidc_jwks_url` or `auth.oidc_public_key_file` |
+| `OIDC audience not set` | Add `auth.oidc_audience` matching your IdP's client audience |
+| `no OIDC group-to-role mappings` | Add at least `auth.oidc_admin_groups` |
+
+Then mint a test token from your IdP and verify it is accepted:
+
+```bash
+TOKEN=""
+curl -H "Authorization: Bearer $TOKEN" https://:7423/api/v1/sandboxes
+# Expected: 200 with sandbox list (empty is fine)
+```
+
+---
+
+## 5. Worker identity certification
+
+```bash
+scripts/certify-worker-identity.sh \
+ --format markdown \
+ --output worker-identity-certification.md
+```
+
+This verifies signed token issuance, inspection, verification, and revocation
+without writing token values to the report. Retain the report alongside the
+runtime certification.
+
+---
+
+## Signoff checklist
+
+Copy this into your change-management ticket before go-live:
+
+```
+[ ] stacyvm config lint --production passes with no FAILs
+[ ] stacyvm upgrade rehearse passes (binary + config + database)
+[ ] stacyvm db pg-rehearse passes (if Postgres)
+[ ] Worker RPC mTLS smoke with deployment-issued certs: PASSED
+[ ] Runtime certification report on file for every worker host
+[ ] Worker identity certification report on file for every worker ID
+[ ] OIDC test token accepted by the production control plane
+[ ] stacyvm doctor --production passes on the control-plane host
+[ ] stacyvm support bundle generates without token/key leakage
+```
diff --git a/docs/getting-started/core-concepts.mdx b/docs/getting-started/core-concepts.mdx
new file mode 100644
index 0000000..3edd354
--- /dev/null
+++ b/docs/getting-started/core-concepts.mdx
@@ -0,0 +1,59 @@
+---
+title: "Core Concepts"
+description: "Understand StacyVM sandboxes, providers, templates, files, exec sessions, previews, quotas, and cleanup."
+---
+
+StacyVM is small at the API boundary: you create a sandbox, run work inside it, move files in and out, and destroy it. Production behavior comes from the provider, scheduler, quotas, audit logs, and host configuration behind that boundary.
+
+## Sandbox
+
+A sandbox is an isolated runtime instance. It has an ID, state, image, provider, resource request, TTL, metadata, and optional preview domain.
+
+Typical states include `creating`, `running`, `unhealthy`, `expired`, `destroying`, `destroyed`, and `error`.
+
+## Provider
+
+A provider owns the actual runtime. StacyVM includes support for Docker, Firecracker, PRoot, custom providers, mock providers, and remote workers. Docker is the broadest quickstart path. Firecracker and PRoot require host-specific certification before you claim production support.
+
+## Image
+
+An image describes the filesystem and runtime available inside the sandbox. For Docker, this is a container image such as `python:3.12` or `node:20`.
+
+## Exec
+
+Exec runs a command inside an existing sandbox. Use timeouts for generated or untrusted code so a process cannot run forever.
+
+```json
+{
+ "command": "python3 /app/main.py",
+ "timeout": "30s"
+}
+```
+
+## Files
+
+The file API lets you write source code, read outputs, list directories, move files, change modes, and delete paths inside the sandbox. Use absolute paths such as `/app/main.py`.
+
+## TTL
+
+TTL is automatic cleanup. Set a short TTL for agent tasks and still call destroy when the task completes.
+
+## Templates
+
+Templates define reusable sandbox settings such as image, provider, memory, vCPU, TTL, and metadata. Use templates when many developers or agents need the same environment.
+
+## Live Previews
+
+Live previews expose web applications running inside a sandbox through a preview URL. This is useful for coding agents that need to build and inspect web apps.
+
+## Quotas
+
+Quotas keep one user or workflow from consuming the whole host. StacyVM can track owner identity through `X-User-ID`, API key identity, and configured limits.
+
+## Audit Logs
+
+Production deployments should keep audit logs for sandbox creation, exec, file writes, destroy operations, admin changes, and registry actions.
+
+## Production Claims
+
+Do not claim a runtime is production-ready until you have run certification on the actual host class. Start with the [public support matrix](/docs/public-support-matrix), then run [runtime certification](/docs/runtime-certification).
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
new file mode 100644
index 0000000..63db2bd
--- /dev/null
+++ b/docs/getting-started/installation.mdx
@@ -0,0 +1,120 @@
+---
+title: "Installation"
+description: "Install and run StacyVM locally from a release binary, Docker image, or source checkout."
+---
+
+Use this page to get a StacyVM server running before you follow the quickstart.
+
+## Prerequisites
+
+- Linux is recommended for runtime work. macOS is useful for SDK and docs development, but Docker provider behavior should be certified on Linux before public claims.
+- Docker is required for the default local provider path.
+- Go is required only when building from source.
+
+See [Prerequisites](/docs/getting-started/prerequisites) for the full local, SDK, and production checklist.
+
+## OS-Specific Setup
+
+Use the [Prerequisites OS setup](/docs/getting-started/prerequisites#os-setup) before choosing an installation mode:
+
+- macOS: Docker Desktop plus Go for `make serve`.
+- Windows: WSL 2 with Ubuntu and Docker Desktop WSL integration.
+- Linux: Docker and Go from your distribution package manager.
+- Ubuntu: Docker Engine, Compose plugin, Go, Git, curl, and make.
+
+For individual local development from a source checkout, use the one-command setup:
+
+```bash
+make dev
+```
+
+This checks the host, builds StacyVM, and starts the API server.
+
+You can also use the npm/npx bootstrapper:
+
+```bash
+npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening
+```
+
+That command can clone the repo, install Node package dependencies, download Go modules, build StacyVM, and start the server. It does not install Docker Desktop or Go for you.
+
+To test the branch directly from GitHub instead of npm, use:
+
+```bash
+npx github:StacyOS/stacyvm#phase-14-worker-identity-hardening stacyvm-setup \
+ --branch phase-14-worker-identity-hardening
+```
+
+## Option 1: Release Binary
+
+Use a signed GitHub release for production-like installs.
+
+```bash
+curl -L -o stacyvm.tar.gz \
+ https://github.com/StacyOS/stacyvm/releases/latest/download/stacyvm_linux_amd64.tar.gz
+tar -xzf stacyvm.tar.gz
+sudo install -m 0755 stacyvm /usr/local/bin/stacyvm
+```
+
+Validate the release artifacts before you install them in production:
+
+```bash
+scripts/post-release-validate.sh v0.14.4
+```
+
+Replace `v0.14.4` with the release tag you plan to deploy.
+
+## Option 2: Docker Compose
+
+The repository includes a production-oriented Docker Compose starting point.
+
+```bash
+cd deploy
+cp .env.example .env
+docker compose up -d
+docker compose logs -f stacyvm
+```
+
+Edit `.env` before exposing the service. Use strong API keys, exact CORS origins, and a persistent data directory.
+
+## Option 3: Source Checkout
+
+Build from source when you are contributing to StacyVM itself.
+
+```bash
+git clone https://github.com/StacyOS/stacyvm.git
+cd stacyvm
+make dev
+```
+
+## Configure Auth
+
+For local experiments you can run without auth. For any shared host, enable auth and pass `X-API-Key` from clients.
+
+```yaml
+auth:
+ enabled: true
+ api_key: "sk_live_REPLACE_ME"
+server:
+ cors_allowed_origins:
+ - "https://your-app.example.com"
+```
+
+## Verify The Host
+
+Run config validation before a deploy:
+
+```bash
+stacyvm config lint --production --file deploy/stacyvm.production.yaml
+```
+
+Run live host diagnostics after the service is installed:
+
+```bash
+stacyvm doctor --production
+```
+
+## Continue
+
+Once the server responds, follow the [quickstart](/docs/getting-started/quickstart). If setup fails, use the [troubleshooting guide](/docs/getting-started/prerequisites#troubleshooting).
diff --git a/docs/getting-started/prerequisites.mdx b/docs/getting-started/prerequisites.mdx
new file mode 100644
index 0000000..fd53593
--- /dev/null
+++ b/docs/getting-started/prerequisites.mdx
@@ -0,0 +1,453 @@
+---
+title: "Prerequisites"
+description: "Check the operating system, runtime, SDK, network, and production requirements before you install StacyVM."
+---
+
+Use this checklist before you run StacyVM locally or deploy it for other developers.
+
+## Quick Checklist
+
+| Area | Required for local quickstart | Required for production |
+| --- | --- | --- |
+| Host OS | Linux recommended, macOS acceptable for SDK/docs work | Linux host certified for the runtime you claim |
+| Runtime | Docker for the default provider path | Docker, Firecracker, PRoot, or remote workers certified on target hosts |
+| CLI tools | `curl`, `git`, Docker CLI | `curl`, `git`, Docker CLI, service manager, backup tooling |
+| Build tools | Go only when building from source | Go only for source builds; release binaries are preferred |
+| SDK tools | Python 3.9+ or Node.js 18+ | Match your application language runtime |
+| Network | Local port `7423` available | TLS, reverse proxy, exact CORS origins, private admin access |
+| Secrets | Optional local API key | Strong API key, admin key, worker credentials where applicable |
+| Persistence | Local SQLite file is fine | Persistent data directory and backup plan |
+
+## Local Development
+
+For the simplest local path, you need:
+
+- Docker installed and reachable by the StacyVM process.
+- Port `7423` available on localhost.
+- `curl` for API smoke checks.
+- Python 3.9+ if you want to use the Python SDK or examples.
+- Node.js 18+ if you want to use the TypeScript SDK or examples.
+- Go if you want to run `make serve`, `go test ./...`, or build from source.
+
+If you do not have Go installed, use a release binary or Docker Compose instead of `make serve`.
+
+Once the repository is cloned, the one-command local setup path is:
+
+```bash
+make dev
+```
+
+`make dev` checks Go, Docker, Docker daemon access, and port `7423`, then builds StacyVM and starts `./stacyvm serve`. It does not install Docker Desktop or OS packages for you; when something is missing, it prints the right OS-specific fix.
+
+If you prefer an npm/npx-driven setup, use:
+
+```bash
+npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening
+```
+
+The npm setup command can clone StacyVM, run `npm install` for the web, SDK, and TypeScript example packages, download Go modules, build the StacyVM binary, and start the server. It still expects Docker and Go to be installed on the host.
+
+To test the branch directly from GitHub instead of npm, use:
+
+```bash
+npx github:StacyOS/stacyvm#phase-14-worker-identity-hardening stacyvm-setup \
+ --branch phase-14-worker-identity-hardening
+```
+
+## OS Setup
+
+Choose the setup path for the machine where you will run StacyVM.
+
+### macOS
+
+macOS is a good local development environment for the Docker provider, SDKs, and docs. Use Docker Desktop as the runtime. Firecracker is not a macOS starter path because it requires Linux/KVM.
+
+Install tools:
+
+```bash
+brew install go git curl make
+```
+
+Install and start Docker Desktop, then verify Docker works:
+
+```bash
+docker version
+docker run --rm hello-world
+```
+
+Run StacyVM from source:
+
+```bash
+git clone https://github.com/StacyOS/stacyvm.git
+cd stacyvm
+make dev
+```
+
+Check the API:
+
+```bash
+curl http://localhost:7423/api/v1/live
+```
+
+Use multi-architecture images such as `python:3.12`, `node:20`, or `alpine:latest` on Apple Silicon.
+
+### Windows
+
+Use Windows with WSL 2. Run StacyVM commands inside Ubuntu on WSL, not in PowerShell, so paths, shell behavior, and Docker integration match Linux more closely.
+
+Install:
+
+- WSL 2 with Ubuntu.
+- Docker Desktop with WSL integration enabled for your Ubuntu distro.
+- Go, Git, curl, and make inside Ubuntu.
+
+Inside Ubuntu on WSL:
+
+```bash
+sudo apt update
+sudo apt install -y git curl make golang-go
+docker version
+docker run --rm hello-world
+```
+
+Run StacyVM:
+
+```bash
+git clone https://github.com/StacyOS/stacyvm.git
+cd stacyvm
+make dev
+```
+
+If Docker commands fail inside WSL, open Docker Desktop settings and enable WSL integration for the Ubuntu distro you are using.
+
+### Linux
+
+Linux is the recommended host family for runtime work and production certification. Docker is the easiest first provider; Firecracker and PRoot require additional host-specific setup.
+
+Install basic tools with your distribution package manager:
+
+```bash
+sudo apt update
+sudo apt install -y git curl make golang-go docker.io
+sudo systemctl enable --now docker
+sudo usermod -aG docker "$USER"
+```
+
+Log out and back in after adding your user to the Docker group, then verify:
+
+```bash
+docker run --rm hello-world
+```
+
+Run StacyVM:
+
+```bash
+git clone https://github.com/StacyOS/stacyvm.git
+cd stacyvm
+make dev
+```
+
+### Ubuntu
+
+Ubuntu is the most straightforward Linux path for individual setup and single-node staging.
+
+Install Docker and build tools:
+
+```bash
+sudo apt update
+sudo apt install -y ca-certificates curl git make golang-go
+sudo install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc >/dev/null
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
+sudo apt update
+sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+sudo usermod -aG docker "$USER"
+```
+
+Log out and back in, then verify:
+
+```bash
+docker run --rm hello-world
+docker compose version
+```
+
+Run StacyVM:
+
+```bash
+git clone https://github.com/StacyOS/stacyvm.git
+cd stacyvm
+make dev
+```
+
+For production-like Ubuntu hosts, continue with [Production Deployment](/docs/deployment) after local verification.
+
+## Runtime Requirements
+
+### Docker
+
+Docker is the recommended first runtime because it is easiest to install and validate.
+
+- Docker daemon running on the host.
+- Permission for the StacyVM process to create and destroy containers.
+- Resource limits configured for memory, CPU, TTL, and concurrent sandboxes.
+- Production config that disables unsafe Docker options.
+
+### Firecracker
+
+Use Firecracker only after host certification.
+
+- Linux host with KVM access.
+- Firecracker binary installed.
+- Kernel, rootfs, and StacyVM agent configured.
+- Networking configured for your deployment model.
+- Runtime certification evidence for the exact host class.
+
+### PRoot
+
+Use PRoot only after validating the real rootfs and binaries on the target host.
+
+- PRoot installed.
+- Rootfs path configured.
+- Required shell and runtime binaries available inside the rootfs.
+- File and exec conformance passing for your target workload.
+
+## Production Requirements
+
+Before exposing StacyVM to other users, prepare:
+
+- `auth.enabled: true` with a strong API key.
+- Separate admin API key for admin routes.
+- Exact `server.cors_allowed_origins`; do not use wildcard CORS for public browser clients.
+- Rate limits, sandbox caps, TTLs, and quotas.
+- Persistent store path with backups.
+- Health checks for `/api/v1/live`, `/api/v1/ready`, and `/api/v1/health`.
+- Metrics scraping for `/api/v1/metrics/prometheus`.
+- Audit log retention policy.
+- Runtime certification for every provider you publicly claim.
+
+## Verify Your Setup
+
+### Verify The Npm Bootstrapper
+
+Run this flow in a fresh terminal when you want to confirm the published `stacyvm-setup` package, clone path, build path, server, and sandbox execution are all working.
+
+Verify the npm package:
+
+```bash
+npm view stacyvm-setup name version bin --json
+npx stacyvm-setup@latest --help
+```
+
+Run setup without starting the server:
+
+```bash
+mkdir -p /tmp/stacyvm-npx-test
+cd /tmp/stacyvm-npx-test
+
+npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening \
+ --dir ./stacyvm \
+ --no-start
+```
+
+Expected result:
+
+```bash
+./stacyvm/stacyvm
+```
+
+Start StacyVM:
+
+```bash
+cd /tmp/stacyvm-npx-test/stacyvm
+./stacyvm serve
+```
+
+In a second terminal, check health:
+
+```bash
+curl http://localhost:7423/api/v1/live
+curl http://localhost:7423/api/v1/ready
+```
+
+Create a sandbox:
+
+```bash
+curl -sS -X POST http://localhost:7423/api/v1/sandboxes \
+ -H "Content-Type: application/json" \
+ -d '{"image":"python:3.12","ttl":"10m"}'
+```
+
+Copy the returned sandbox ID, then run code inside it:
+
+```bash
+export SANDBOX_ID="PASTE_ID_HERE"
+
+curl -sS -X POST "http://localhost:7423/api/v1/sandboxes/${SANDBOX_ID}/exec" \
+ -H "Content-Type: application/json" \
+ -d '{"command":"python3 -c \"print(40 + 2)\"","timeout":"10s"}'
+```
+
+The response should include:
+
+```json
+{
+ "stdout": "42\n"
+}
+```
+
+Destroy the sandbox:
+
+```bash
+curl -sS -X DELETE "http://localhost:7423/api/v1/sandboxes/${SANDBOX_ID}"
+```
+
+After that passes, you can test the full one-command path:
+
+```bash
+cd /tmp
+npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening \
+ --dir ./stacyvm-full-test
+```
+
+### Troubleshooting
+
+
+
+ Make sure you are using the published package name:
+
+ ```bash
+ npm view stacyvm-setup name version bin --json
+ npx stacyvm-setup@latest --help
+ ```
+
+ If you are testing an unpublished branch, use the GitHub fallback:
+
+ ```bash
+ npx github:StacyOS/stacyvm#phase-14-worker-identity-hardening stacyvm-setup \
+ --branch phase-14-worker-identity-hardening
+ ```
+
+
+
+ On macOS, start Docker Desktop and wait until it is fully running:
+
+ ```bash
+ open -a Docker
+ docker info
+ docker run --rm hello-world
+ ```
+
+ On Windows, run setup inside WSL 2 Ubuntu and enable Docker Desktop WSL integration for that distro.
+
+ On Linux, start Docker and make sure your user can access it:
+
+ ```bash
+ sudo systemctl enable --now docker
+ sudo usermod -aG docker "$USER"
+ ```
+
+ Log out and back in after changing groups.
+
+
+
+ Source setup requires Go because the npm bootstrapper builds the StacyVM binary.
+
+ macOS:
+
+ ```bash
+ brew install go
+ ```
+
+ Ubuntu or Debian:
+
+ ```bash
+ sudo apt update
+ sudo apt install -y golang-go
+ ```
+
+
+
+ Stop the process using port `7423`, then rerun setup.
+
+ macOS or Linux:
+
+ ```bash
+ lsof -iTCP:7423 -sTCP:LISTEN
+ ```
+
+ If you already have StacyVM running, use the existing server and continue with the [quickstart](/docs/getting-started/quickstart).
+
+
+
+ Reuse the existing checkout after fixing the host issue:
+
+ ```bash
+ cd /tmp/stacyvm-npx-test
+ npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening \
+ --dir ./stacyvm \
+ --no-start
+ ```
+
+ The setup command detects an existing StacyVM checkout and continues from there.
+
+
+
+ You can skip Docker daemon validation:
+
+ ```bash
+ npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening \
+ --skip-docker-check \
+ --no-start
+ ```
+
+ This only verifies setup and build behavior. Docker must be running before you create real sandboxes with the Docker provider.
+
+
+
+ Use a clean npm cache directory:
+
+ ```bash
+ npm_config_cache=/tmp/stacyvm-npm-cache npx stacyvm-setup@latest --help
+ ```
+
+ If npm asks for auth unexpectedly, verify your registry:
+
+ ```bash
+ npm config get registry
+ ```
+
+ It should usually be `https://registry.npmjs.org/`.
+
+
+
+Run config lint before production:
+
+```bash
+stacyvm config lint --production --file deploy/stacyvm.production.yaml
+```
+
+Run host diagnostics after installation:
+
+```bash
+stacyvm doctor --production
+```
+
+Run a smoke check against the deployed API:
+
+```bash
+STACYVM_SMOKE_URL=https://stacyvm.example.com \
+STACYVM_API_KEY=sk_live_REPLACE_ME \
+scripts/smoke-deployment.sh
+```
+
+## Continue
+
+- Install StacyVM with the [installation guide](/docs/getting-started/installation).
+- Create your first sandbox with the [quickstart](/docs/getting-started/quickstart).
+- Review public runtime claims in the [support matrix](/docs/public-support-matrix).
diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx
new file mode 100644
index 0000000..7d4f56a
--- /dev/null
+++ b/docs/getting-started/quickstart.mdx
@@ -0,0 +1,174 @@
+---
+title: "Quickstart"
+description: "Create your first StacyVM sandbox, run code, move files, and clean up in a few minutes."
+---
+
+This guide gets you from a running StacyVM server to a verified sandbox workflow.
+
+## Prerequisites
+
+- A StacyVM server listening on `http://localhost:7423`.
+- Docker installed and available to the StacyVM process when using the default Docker provider.
+- An API key if `auth.enabled` is set in your config.
+- Python 3.9+ or Node.js 18+ if you want to use an SDK.
+
+If you have not set up a host yet, start with the [Prerequisites](/docs/getting-started/prerequisites) and [Installation](/docs/getting-started/installation) guides.
+
+From a source checkout, the simplest local start command is:
+
+```bash
+make dev
+```
+
+From npm/npx, use:
+
+```bash
+npx stacyvm-setup@latest \
+ --branch phase-14-worker-identity-hardening
+```
+
+To test the branch directly from GitHub instead of npm, use:
+
+```bash
+npx github:StacyOS/stacyvm#phase-14-worker-identity-hardening stacyvm-setup \
+ --branch phase-14-worker-identity-hardening
+```
+
+## 1. Check The Server
+
+```bash
+curl -sS http://localhost:7423/api/v1/live
+```
+
+A healthy local server returns a success response. If auth is enabled, send your API key on protected routes:
+
+```bash
+export STACYVM_API_KEY="sk_test_YOUR_API_KEY"
+```
+
+## 2. Create A Sandbox
+
+
+```bash cURL
+curl -sS -X POST http://localhost:7423/api/v1/sandboxes \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: ${STACYVM_API_KEY}" \
+ -d '{"image":"python:3.12","ttl":"10m","memory_mb":512,"vcpus":1}'
+```
+
+```python Python
+from stacyvm import Client
+
+client = Client(
+ base_url="http://localhost:7423",
+ api_key="sk_test_YOUR_API_KEY",
+)
+
+sandbox = client.spawn(
+ image="python:3.12",
+ ttl="10m",
+ memory_mb=512,
+ vcpus=1,
+)
+print(sandbox.id)
+```
+
+```typescript TypeScript
+import { Client } from "stacyvm";
+
+const client = new Client({
+ baseUrl: "http://localhost:7423",
+ apiKey: "sk_test_YOUR_API_KEY",
+});
+
+const sandbox = await client.spawn({
+ image: "python:3.12",
+ ttl: "10m",
+ memory_mb: 512,
+ vcpus: 1,
+});
+console.log(sandbox.id);
+```
+
+
+Save the returned sandbox ID:
+
+```bash
+export SANDBOX_ID="sb_YOUR_SANDBOX_ID"
+```
+
+## 3. Run Code
+
+
+```bash cURL
+curl -sS -X POST "http://localhost:7423/api/v1/sandboxes/${SANDBOX_ID}/exec" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: ${STACYVM_API_KEY}" \
+ -d '{"command":"python3 -c \"print(40 + 2)\"","timeout":"10s"}'
+```
+
+```python Python
+result = sandbox.exec("python3 -c 'print(40 + 2)'", timeout="10s")
+print(result.stdout)
+```
+
+```typescript TypeScript
+const result = await sandbox.exec("python3 -c 'print(40 + 2)'", {
+ timeout: "10s",
+});
+console.log(result.stdout);
+```
+
+
+## 4. Move A File Into The Sandbox
+
+
+```bash cURL
+curl -sS -X PUT "http://localhost:7423/api/v1/sandboxes/${SANDBOX_ID}/files/app/main.py" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: ${STACYVM_API_KEY}" \
+ -d '{"content":"name = \"StacyVM\"\\nprint(f\"hello from {name}\")\\n","mode":"644"}'
+```
+
+```python Python
+sandbox.write_file(
+ "/app/main.py",
+ 'name = "StacyVM"\nprint(f"hello from {name}")\n',
+)
+print(sandbox.exec("python3 /app/main.py").stdout)
+```
+
+```typescript TypeScript
+await sandbox.writeFile(
+ "/app/main.py",
+ 'name = "StacyVM"\nprint(f"hello from {name}")\n',
+);
+console.log((await sandbox.exec("python3 /app/main.py")).stdout);
+```
+
+
+## 5. Destroy The Sandbox
+
+Always destroy sandboxes when work is complete. TTL cleanup is a fallback, not the primary lifecycle control.
+
+
+```bash cURL
+curl -sS -X DELETE "http://localhost:7423/api/v1/sandboxes/${SANDBOX_ID}" \
+ -H "X-API-Key: ${STACYVM_API_KEY}"
+```
+
+```python Python
+sandbox.destroy()
+```
+
+```typescript TypeScript
+await sandbox.destroy();
+```
+
+
+## Next Steps
+
+- Build the [example code runner](/docs/tutorials/code-runner).
+- Learn the [core concepts](/docs/getting-started/core-concepts).
+- Use the [Python SDK](/docs/sdks/python) or [TypeScript SDK](/docs/sdks/typescript).
+- Prepare a host with the [production deployment guide](/docs/deployment).
diff --git a/docs/getting-started/what-is-stacyvm.mdx b/docs/getting-started/what-is-stacyvm.mdx
new file mode 100644
index 0000000..1bce4a9
--- /dev/null
+++ b/docs/getting-started/what-is-stacyvm.mdx
@@ -0,0 +1,105 @@
+---
+title: "What Is StacyVM?"
+description: "Learn what StacyVM does, why teams use it, how it compares to alternatives, and where it fits in agent infrastructure."
+---
+
+StacyVM is self-hosted sandbox infrastructure for applications that need to run code safely, repeatedly, and observably. It gives agents and developer tools a disposable machine-like workspace with shell access, files, timeouts, quotas, previews, and audit trails.
+
+## The Short Version
+
+Your application calls StacyVM when it needs a clean runtime. StacyVM creates a sandbox, runs work inside it, streams output back, exposes files and previews, and destroys the environment when the task is done.
+
+```mermaid
+flowchart LR
+ App["Your app or agent"] --> API["StacyVM API"]
+ API --> Sandbox["Disposable sandbox"]
+ Sandbox --> Result["Logs, files, exit code, preview URL"]
+ Result --> App
+```
+
+## Why StacyVM Exists
+
+AI coding agents, workflow engines, and developer tools increasingly need to execute generated code. Running that code directly on your host is risky. Outsourcing every execution to a cloud sandbox can be expensive, slower, or difficult to govern.
+
+StacyVM sits in the middle: you keep the execution plane under your control while developers get a simple API.
+
+## Advantages
+
+
+
+ Control
+ Your runtime, network, logs, credentials, and certification boundary stay with you.
+
+
+ Speed
+ Agents get a simple API for shell, files, streaming output, and previews.
+
+
+ Trust
+ TTL cleanup, quotas, audit logs, typed errors, and conformance gates are built in.
+
+
+
+| Advantage | What it means |
+| --- | --- |
+| Self-hosted control | You decide where code runs, what network it can reach, and how logs are retained. |
+| Provider flexibility | Start with Docker, then certify Firecracker, PRoot, custom providers, or remote workers when needed. |
+| Developer-friendly API | Use REST, Python, or TypeScript without learning the underlying runtime provider first. |
+| Production guardrails | Use TTLs, quotas, typed errors, health checks, audit logs, config linting, and runtime conformance. |
+| Agent-ready workflows | Run commands, stream output, move files, and expose live previews for generated web apps. |
+
+## Common Use Cases
+
+
+
+ Give agents a real shell and filesystem without giving them your host.
+
+
+ Run submitted snippets, scripts, tests, and generated code in short-lived environments.
+
+
+ Let users inspect web apps built inside sandboxes through preview URLs.
+
+
+ Run build, migration, analysis, or validation jobs with consistent cleanup.
+
+
+
+## Comparison
+
+| Approach | Best for | Tradeoff |
+| --- | --- | --- |
+| Direct host execution | Trusted internal scripts | Unsafe for generated or user-provided code. |
+| Docker-only wrapper | Fast local prototypes | Usually lacks a stable API, quotas, audit trails, and multi-worker routing. |
+| Cloud sandbox APIs | Teams that do not want to operate infrastructure | External dependency, data egress concerns, cost, and less host-level control. |
+| Kubernetes jobs | Long-running platform teams | Strong orchestration, but heavier developer ergonomics for per-agent interactive execution. |
+| StacyVM | Self-hosted, agent-facing sandbox infrastructure | You operate the control plane and certify the runtimes you publicly claim. |
+
+## Mental Model
+
+Think of StacyVM as a control plane in front of many possible sandbox runtimes.
+
+```mermaid
+flowchart TB
+ User["Developer or agent"] --> SDK["SDK or REST client"]
+ SDK --> Control["StacyVM control plane"]
+ Control --> Policy["Auth, quotas, audit, scheduling"]
+ Policy --> Runtime["Runtime provider"]
+ Runtime --> Docker["Docker"]
+ Runtime --> Firecracker["Firecracker"]
+ Runtime --> PRoot["PRoot"]
+ Runtime --> Remote["Remote worker"]
+```
+
+## What StacyVM Is Not
+
+- It is not a promise that every provider is safe on every host. You must certify runtime claims.
+- It is not a replacement for application-level authentication.
+- It is not a general Kubernetes distribution.
+- It is not a reason to run unbounded user code without TTLs, quotas, and audit logging.
+
+## Next Steps
+
+- Start with the [quickstart](/docs/getting-started/quickstart).
+- Review the [system architecture](/docs/architecture/system-overview).
+- Build the [Python example app](/docs/tutorials/code-runner) or [TypeScript example app](/docs/tutorials/typescript-code-runner).
diff --git a/docs/openapi.json b/docs/openapi.json
new file mode 100644
index 0000000..0400f51
--- /dev/null
+++ b/docs/openapi.json
@@ -0,0 +1,3034 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "description": "StacyVM microVM sandbox orchestrator API",
+ "title": "StacyVM API",
+ "contact": {},
+ "version": "1.0"
+ },
+ "paths": {
+ "/admin/audit": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return recent redacted admin route access records",
+ "tags": [
+ "admin"
+ ],
+ "summary": "List admin audit logs",
+ "parameters": [
+ {
+ "description": "Maximum number of records, capped at 500",
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Actor exact match",
+ "name": "actor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "HTTP method exact match",
+ "name": "method",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "HTTP status exact match",
+ "name": "status",
+ "in": "query",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "description": "Path substring match",
+ "name": "path",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Response format: json or csv",
+ "name": "format",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/internal_api_routes.AdminAuditResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/diagnostics": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return redacted build, store, provider, sandbox, event, and operation diagnostics",
+ "tags": [
+ "system"
+ ],
+ "summary": "Get diagnostics",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.DiagnosticsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/events": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Open an SSE stream for real-time sandbox and system events",
+ "tags": [
+ "system"
+ ],
+ "summary": "Subscribe to events",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Event"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return the health status, version, and uptime",
+ "tags": [
+ "system"
+ ],
+ "summary": "Health check",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/live": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return whether the StacyVM API process is alive",
+ "tags": [
+ "system"
+ ],
+ "summary": "Liveness check",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/metrics": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return runtime metrics including sandbox count, goroutines, and memory usage",
+ "tags": [
+ "system"
+ ],
+ "summary": "Get metrics",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.MetricsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/metrics/prometheus": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return runtime, provider, sandbox, event, and operation metrics in Prometheus text format",
+ "tags": [
+ "system"
+ ],
+ "summary": "Get Prometheus metrics",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/providers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all registered providers with health status",
+ "tags": [
+ "providers"
+ ],
+ "summary": "List providers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/internal_api_routes.ProviderInfo"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/providers/test": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Run health checks on all registered providers",
+ "tags": [
+ "providers"
+ ],
+ "summary": "Test providers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/providers/{providerName}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return detailed information about a specific provider",
+ "tags": [
+ "providers"
+ ],
+ "summary": "Get provider details",
+ "parameters": [
+ {
+ "description": "Provider name",
+ "name": "providerName",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.ProviderDetail"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/quotas": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all persisted owner quota overrides",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "List owner quotas",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/quotas/summary": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return non-identifying counts for persisted owner quota overrides",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "Get quota summary",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/quotas/{ownerID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return the persisted quota override for an owner",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "Get owner quota",
+ "parameters": [
+ {
+ "description": "Owner ID",
+ "name": "ownerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Create or update quota overrides for an owner",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "Save owner quota",
+ "parameters": [
+ {
+ "description": "Owner ID",
+ "name": "ownerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
+ }
+ }
+ },
+ "description": "Quota request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete the quota override for an owner",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "Delete owner quota",
+ "parameters": [
+ {
+ "description": "Owner ID",
+ "name": "ownerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.StatusResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/quotas/{ownerID}/usage": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return active sandbox usage and effective quota for an owner",
+ "tags": [
+ "quotas"
+ ],
+ "summary": "Get owner quota usage",
+ "parameters": [
+ {
+ "description": "Owner ID",
+ "name": "ownerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OwnerUsage"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ready": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return whether the API is ready to serve sandbox traffic",
+ "tags": [
+ "system"
+ ],
+ "summary": "Readiness check",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.ReadinessResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.ReadinessResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all active sandboxes",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "List sandboxes",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Spawn a new sandbox with the given configuration",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Create a sandbox",
+ "requestBody": {
+ "$ref": "#/components/requestBodies/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest"
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Destroy all expired sandboxes and return the count",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Prune sandboxes",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.PruneResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/admission": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return whether a spawn request would be allowed, queued, or denied by quota and scheduler limits",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Evaluate spawn admission",
+ "requestBody": {
+ "$ref": "#/components/requestBodies/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest"
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnAdmissionDecision"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return a sandbox by its ID",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Get a sandbox",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Destroy a sandbox and release its resources",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Destroy a sandbox",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.StatusResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/exec": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Run a command inside a sandbox. Set stream=true for streaming output.",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Execute a command",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.ExecRequest"
+ }
+ }
+ },
+ "description": "Exec request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.ExecResult"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/exec/ws": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Open a WebSocket connection to execute a command with streaming output",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Execute via WebSocket",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "101": {
+ "description": "WebSocket upgrade"
+ },
+ "400": {
+ "description": "Bad request"
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/extend": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Add additional time to a sandbox's expiration",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Extend sandbox TTL",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ttl": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "description": "TTL extension",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/files": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Read file content from a sandbox",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Read a file",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "File path inside the sandbox",
+ "name": "path",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Write content to a file inside a sandbox",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Write a file",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.FileWriteRequest"
+ }
+ }
+ },
+ "description": "File write request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.StatusResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/files/list": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "List files in a directory inside a sandbox",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "List files",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Directory path (default: /)",
+ "name": "path",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.FileInfo"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/sandboxes/{sandboxID}/logs": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Retrieve console log lines from a sandbox",
+ "tags": [
+ "sandboxes"
+ ],
+ "summary": "Get console logs",
+ "parameters": [
+ {
+ "description": "Sandbox ID",
+ "name": "sandboxID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Number of lines to retrieve (default: 100)",
+ "name": "lines",
+ "in": "query",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/snapshots": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all pre-built VM snapshots available for fast restore",
+ "tags": [
+ "snapshots"
+ ],
+ "summary": "List snapshots",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_providers.SnapshotSummary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/templates": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return all registered templates",
+ "tags": [
+ "templates"
+ ],
+ "summary": "List templates",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Register a new sandbox template",
+ "tags": [
+ "templates"
+ ],
+ "summary": "Create a template",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ },
+ "description": "Template definition",
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/templates/{name}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return a template by its name",
+ "tags": [
+ "templates"
+ ],
+ "summary": "Get a template",
+ "parameters": [
+ {
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Update an existing template by name",
+ "tags": [
+ "templates"
+ ],
+ "summary": "Update a template",
+ "parameters": [
+ {
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ },
+ "description": "Updated template",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Template"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Delete a template by name",
+ "tags": [
+ "templates"
+ ],
+ "summary": "Delete a template",
+ "parameters": [
+ {
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.StatusResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/templates/{name}/spawn": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Create a new sandbox using a template's configuration, with optional overrides",
+ "tags": [
+ "templates"
+ ],
+ "summary": "Spawn from template",
+ "parameters": [
+ {
+ "description": "Template name",
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.TemplateSpawnOverrides"
+ }
+ }
+ },
+ "description": "Optional overrides"
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/workers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return worker registry records and heartbeat state",
+ "tags": [
+ "workers"
+ ],
+ "summary": "List workers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/internal_api_routes.WorkerResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/workers/{workerID}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Return one worker registry record",
+ "tags": [
+ "workers"
+ ],
+ "summary": "Get worker",
+ "parameters": [
+ {
+ "description": "Worker ID",
+ "name": "workerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.WorkerResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Remove a worker registry record",
+ "tags": [
+ "workers"
+ ],
+ "summary": "Delete worker",
+ "parameters": [
+ {
+ "description": "Worker ID",
+ "name": "workerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "*/*": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.StatusResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "content": {
+ "*/*": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/workers/{workerID}/heartbeat": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Create or update worker registry state for a worker",
+ "tags": [
+ "workers"
+ ],
+ "summary": "Heartbeat worker",
+ "parameters": [
+ {
+ "description": "Worker ID",
+ "name": "workerID",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.WorkerHeartbeatRequest"
+ }
+ }
+ },
+ "description": "Worker heartbeat",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/internal_api_routes.WorkerResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "servers": [
+ {
+ "url": "//localhost:7423/api/v1"
+ }
+ ],
+ "components": {
+ "requestBodies": {
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest"
+ }
+ }
+ },
+ "description": "Spawn request",
+ "required": true
+ }
+ },
+ "securitySchemes": {
+ "ApiKeyAuth": {
+ "type": "apiKey",
+ "name": "X-API-Key",
+ "in": "header"
+ }
+ },
+ "schemas": {
+ "github_com_StacyOs_stacyvm_internal_api_middleware.RateLimitStats": {
+ "type": "object",
+ "properties": {
+ "active_buckets": {
+ "type": "integer"
+ },
+ "allowed_total": {
+ "type": "integer"
+ },
+ "bucket_ttl": {
+ "type": "string"
+ },
+ "burst": {
+ "type": "integer"
+ },
+ "cleanup_interval": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "evicted_total": {
+ "type": "integer"
+ },
+ "key_by": {
+ "type": "string"
+ },
+ "limited_total": {
+ "type": "integer"
+ },
+ "requests_per_minute": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_httputil.APIError": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_httputil.ErrorCode"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_httputil.ErrorCode": {
+ "type": "string",
+ "enum": [
+ "NOT_FOUND",
+ "BAD_REQUEST",
+ "INTERNAL_ERROR",
+ "UNAUTHORIZED",
+ "CONFLICT",
+ "UNAVAILABLE",
+ "TIMEOUT",
+ "RESOURCE_LIMIT"
+ ],
+ "x-enum-varnames": [
+ "CodeNotFound",
+ "CodeBadRequest",
+ "CodeInternal",
+ "CodeUnauth",
+ "CodeConflict",
+ "CodeUnavailable",
+ "CodeTimeout",
+ "CodeResourceLimit"
+ ]
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Event": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "id": {
+ "type": "string"
+ },
+ "sandbox_id": {
+ "type": "string"
+ },
+ "timestamp": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.EventType"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.EventBusStats": {
+ "type": "object",
+ "properties": {
+ "events_total": {
+ "type": "integer"
+ },
+ "history_size": {
+ "type": "integer"
+ },
+ "subscribers": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.EventType": {
+ "type": "string",
+ "enum": [
+ "sandbox.created",
+ "sandbox.running",
+ "sandbox.destroyed",
+ "sandbox.error",
+ "exec.started",
+ "exec.completed",
+ "exec.failed",
+ "exec.timeout",
+ "file.written",
+ "file.read",
+ "operation.failed",
+ "resource.limit",
+ "provider.failed",
+ "reconcile.action",
+ "spawn.queued",
+ "spawn.dequeued",
+ "spawn.queue_timeout",
+ "quota.saved",
+ "quota.deleted"
+ ],
+ "x-enum-varnames": [
+ "EventSandboxCreated",
+ "EventSandboxRunning",
+ "EventSandboxDestroyed",
+ "EventSandboxError",
+ "EventExecStarted",
+ "EventExecCompleted",
+ "EventExecFailed",
+ "EventExecTimeout",
+ "EventFileWritten",
+ "EventFileRead",
+ "EventOperationFailed",
+ "EventResourceLimit",
+ "EventProviderFailed",
+ "EventReconcileAction",
+ "EventSpawnQueued",
+ "EventSpawnDequeued",
+ "EventSpawnQueueTimeout",
+ "EventQuotaSaved",
+ "EventQuotaDeleted"
+ ]
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.ExecRequest": {
+ "type": "object",
+ "properties": {
+ "args": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "string"
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "mode": {
+ "type": "string"
+ },
+ "stream": {
+ "type": "boolean"
+ },
+ "timeout": {
+ "type": "string"
+ },
+ "workdir": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.ExecResult": {
+ "type": "object",
+ "properties": {
+ "duration": {
+ "type": "string"
+ },
+ "exit_code": {
+ "type": "integer"
+ },
+ "stderr": {
+ "type": "string"
+ },
+ "stdout": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.FileInfo": {
+ "type": "object",
+ "properties": {
+ "is_dir": {
+ "type": "boolean"
+ },
+ "mod_time": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "size": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.FileWriteRequest": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OperationMetrics": {
+ "type": "object",
+ "properties": {
+ "failure_total": {
+ "type": "integer"
+ },
+ "last_error": {
+ "type": "string"
+ },
+ "last_observed_unix": {
+ "type": "integer"
+ },
+ "latency_avg_ms": {
+ "type": "integer"
+ },
+ "latency_count": {
+ "type": "integer"
+ },
+ "latency_max_ms": {
+ "type": "integer"
+ },
+ "latency_min_ms": {
+ "type": "integer"
+ },
+ "latency_total_ms": {
+ "type": "integer"
+ },
+ "operation": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "success_total": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OperationalLimitsInfo": {
+ "type": "object",
+ "properties": {
+ "default_exec_timeout": {
+ "type": "string"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_sandboxes_per_owner": {
+ "type": "integer"
+ },
+ "max_spawn_queue": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "spawn_overflow": {
+ "type": "string"
+ },
+ "spawn_queue_timeout": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OwnerQuota": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.OwnerUsage": {
+ "type": "object",
+ "properties": {
+ "active_sandboxes": {
+ "type": "integer"
+ },
+ "max_exec_timeout": {
+ "type": "string"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "quota_configured": {
+ "type": "boolean"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer"
+ },
+ "with_max_exec_timeout": {
+ "type": "integer"
+ },
+ "with_max_sandboxes": {
+ "type": "integer"
+ },
+ "with_max_ttl": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Sandbox": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "expires_at": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "memory_mb": {
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "preview_domain": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "state": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.SandboxState"
+ },
+ "tenant_id": {
+ "type": "string"
+ },
+ "vcpus": {
+ "type": "integer"
+ },
+ "vm_id": {
+ "type": "string"
+ },
+ "worker_id": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SandboxState": {
+ "type": "string",
+ "enum": [
+ "creating",
+ "running",
+ "idle",
+ "unhealthy",
+ "expired",
+ "destroyed",
+ "error"
+ ],
+ "x-enum-varnames": [
+ "StateCreating",
+ "StateRunning",
+ "StateIdle",
+ "StateUnhealthy",
+ "StateExpired",
+ "StateDestroyed",
+ "StateError"
+ ]
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SchedulerStatus": {
+ "type": "object",
+ "properties": {
+ "admission_control": {
+ "type": "string"
+ },
+ "eligible_workers": {
+ "type": "integer"
+ },
+ "max_spawn_queue": {
+ "type": "integer"
+ },
+ "selected_worker_id": {
+ "type": "string"
+ },
+ "spawn_dequeued_total": {
+ "type": "integer"
+ },
+ "spawn_overflow": {
+ "type": "string"
+ },
+ "spawn_queue_depth": {
+ "type": "integer"
+ },
+ "spawn_queue_timeout": {
+ "type": "string"
+ },
+ "spawn_queue_timeouts": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_avg": {
+ "type": "string"
+ },
+ "spawn_queue_wait_avg_ms": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_count": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_max": {
+ "type": "string"
+ },
+ "spawn_queue_wait_max_ms": {
+ "type": "integer"
+ },
+ "spawn_queue_wait_total": {
+ "type": "string"
+ },
+ "spawn_queue_wait_total_ms": {
+ "type": "integer"
+ },
+ "spawn_queued_total": {
+ "type": "integer"
+ },
+ "worker_id": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SecretConfig": {
+ "type": "object",
+ "properties": {
+ "inject_at": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SpawnAdmissionDecision": {
+ "type": "object",
+ "properties": {
+ "active_owner_sandboxes": {
+ "type": "integer"
+ },
+ "active_sandboxes": {
+ "type": "integer"
+ },
+ "allowed": {
+ "type": "boolean"
+ },
+ "eligible_workers": {
+ "type": "integer"
+ },
+ "max_owner_sandboxes": {
+ "type": "integer"
+ },
+ "max_sandboxes": {
+ "type": "integer"
+ },
+ "max_ttl": {
+ "type": "string"
+ },
+ "queueable": {
+ "type": "boolean"
+ },
+ "reason": {
+ "type": "string"
+ },
+ "selected_worker_id": {
+ "type": "string"
+ },
+ "worker_reason": {
+ "type": "string"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.SpawnRequest": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string"
+ },
+ "memory_mb": {
+ "type": "integer"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "owner_id": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "template": {
+ "type": "string"
+ },
+ "tenant_id": {
+ "type": "string"
+ },
+ "ttl": {
+ "type": "string"
+ },
+ "vcpus": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_orchestrator.Template": {
+ "type": "object",
+ "properties": {
+ "allowed_hosts": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "cpu_cores": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "env": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "image": {
+ "type": "string"
+ },
+ "memory_mb": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "pool_size": {
+ "type": "integer"
+ },
+ "secrets": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.SecretConfig"
+ }
+ },
+ "setup": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "ttl_seconds": {
+ "type": "integer"
+ },
+ "updated_at": {
+ "type": "string"
+ },
+ "version": {
+ "type": "integer"
+ }
+ }
+ },
+ "github_com_StacyOs_stacyvm_internal_providers.SnapshotSummary": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ }
+ }
+ },
+ "internal_api_routes.AdminAuditResponse": {
+ "type": "object",
+ "properties": {
+ "actor": {
+ "type": "string",
+ "example": "admin"
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "duration_ms": {
+ "type": "integer",
+ "example": 4
+ },
+ "id": {
+ "type": "integer",
+ "example": 42
+ },
+ "method": {
+ "type": "string",
+ "example": "PUT"
+ },
+ "path": {
+ "type": "string",
+ "example": "/api/v1/admin/quotas/owner-a"
+ },
+ "remote_addr": {
+ "type": "string",
+ "example": "127.0.0.1"
+ },
+ "request_id": {
+ "type": "string",
+ "example": "req-abc123"
+ },
+ "status": {
+ "type": "integer",
+ "example": 200
+ },
+ "tenant_id": {
+ "type": "string",
+ "example": "tenant-acme"
+ },
+ "user_agent": {
+ "type": "string",
+ "example": "stacyvm-web"
+ }
+ }
+ },
+ "internal_api_routes.DiagnosticsResponse": {
+ "type": "object",
+ "properties": {
+ "build": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "events": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.EventBusStats"
+ },
+ "generated_at": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "leases": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "limits": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OperationalLimitsInfo"
+ },
+ "operations": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.OperationMetrics"
+ }
+ },
+ "process": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/internal_api_routes.ProviderHealth"
+ }
+ },
+ "quotas": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.QuotaSummary"
+ },
+ "rate_limit": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_api_middleware.RateLimitStats"
+ },
+ "redactions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "remediation": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "sandboxes": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "scheduler": {
+ "$ref": "#/components/schemas/github_com_StacyOs_stacyvm_internal_orchestrator.SchedulerStatus"
+ },
+ "store": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "workers": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "internal_api_routes.HealthResponse": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "uptime": {
+ "type": "string",
+ "example": "2h30m15s"
+ },
+ "version": {
+ "type": "string",
+ "example": "1.0.0"
+ }
+ }
+ },
+ "internal_api_routes.MetricsResponse": {
+ "type": "object",
+ "properties": {
+ "goroutines": {
+ "type": "integer",
+ "example": 12
+ },
+ "memory_alloc_mb": {
+ "type": "integer",
+ "example": 64
+ },
+ "providers": {
+ "type": "integer",
+ "example": 2
+ },
+ "sandboxes_active": {
+ "type": "integer",
+ "example": 5
+ },
+ "uptime": {
+ "type": "string",
+ "example": "2h30m15s"
+ }
+ }
+ },
+ "internal_api_routes.ProviderDetail": {
+ "type": "object",
+ "properties": {
+ "config": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "type": "boolean",
+ "example": true
+ },
+ "health": {
+ "$ref": "#/components/schemas/internal_api_routes.ProviderHealth"
+ },
+ "healthy": {
+ "type": "boolean",
+ "example": true
+ },
+ "name": {
+ "type": "string",
+ "example": "firecracker"
+ },
+ "sandbox_count": {
+ "type": "integer",
+ "example": 3
+ }
+ }
+ },
+ "internal_api_routes.ProviderHealth": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "spawn",
+ "exec",
+ "files"
+ ]
+ },
+ "default": {
+ "type": "boolean",
+ "example": true
+ },
+ "error": {
+ "type": "string",
+ "example": "health check returned false"
+ },
+ "healthy": {
+ "type": "boolean",
+ "example": true
+ },
+ "last_checked": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "latency_ms": {
+ "type": "integer",
+ "example": 3
+ },
+ "name": {
+ "type": "string",
+ "example": "docker"
+ },
+ "runtime_count": {
+ "type": "integer",
+ "example": 2
+ }
+ }
+ },
+ "internal_api_routes.ProviderInfo": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "default": {
+ "type": "boolean",
+ "example": true
+ },
+ "error": {
+ "type": "string",
+ "example": "health check returned false"
+ },
+ "healthy": {
+ "type": "boolean",
+ "example": true
+ },
+ "last_checked": {
+ "type": "string",
+ "example": "2026-05-08T10:30:00Z"
+ },
+ "latency_ms": {
+ "type": "integer",
+ "example": 3
+ },
+ "name": {
+ "type": "string",
+ "example": "firecracker"
+ },
+ "runtime_count": {
+ "type": "integer",
+ "example": 2
+ }
+ }
+ },
+ "internal_api_routes.PruneResponse": {
+ "type": "object",
+ "properties": {
+ "pruned": {
+ "type": "integer",
+ "example": 3
+ }
+ }
+ },
+ "internal_api_routes.ReadinessResponse": {
+ "type": "object",
+ "properties": {
+ "providers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/internal_api_routes.ProviderHealth"
+ }
+ },
+ "ready_providers": {
+ "type": "integer",
+ "example": 1
+ },
+ "status": {
+ "type": "string",
+ "example": "ready"
+ },
+ "total_providers": {
+ "type": "integer",
+ "example": 2
+ },
+ "uptime": {
+ "type": "string",
+ "example": "2h30m15s"
+ },
+ "version": {
+ "type": "string",
+ "example": "1.0.0"
+ }
+ }
+ },
+ "internal_api_routes.StatusResponse": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "destroyed"
+ }
+ }
+ },
+ "internal_api_routes.TemplateSpawnOverrides": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "type": "string",
+ "example": "firecracker"
+ },
+ "ttl": {
+ "type": "string",
+ "example": "30m"
+ }
+ }
+ },
+ "internal_api_routes.WorkerHeartbeatRequest": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "capacity": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "hostname": {
+ "type": "string",
+ "example": "stacyvm-host-1"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string",
+ "example": "online"
+ }
+ }
+ },
+ "internal_api_routes.WorkerResponse": {
+ "type": "object",
+ "properties": {
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "capacity": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "created_at": {
+ "type": "string",
+ "example": "2026-05-09T10:00:00Z"
+ },
+ "hostname": {
+ "type": "string",
+ "example": "stacyvm-host-1"
+ },
+ "id": {
+ "type": "string",
+ "example": "worker-local"
+ },
+ "last_heartbeat": {
+ "type": "string",
+ "example": "2026-05-09T10:30:00Z"
+ },
+ "providers": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "stale": {
+ "type": "boolean",
+ "example": false
+ },
+ "status": {
+ "type": "string",
+ "example": "online"
+ },
+ "updated_at": {
+ "type": "string",
+ "example": "2026-05-09T10:30:00Z"
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/production-readiness.md b/docs/production-readiness.md
new file mode 100644
index 0000000..3eb5bda
--- /dev/null
+++ b/docs/production-readiness.md
@@ -0,0 +1,124 @@
+# Production Readiness Checklist
+
+This checklist tracks the Phase 7 release-candidate hardening work needed before StacyVM is marketed as production-ready.
+
+## Readiness Levels
+
+| Level | Target user | Current gate |
+|---|---|---|
+| Internal staging | StacyOS team and trusted operators | `stacyvm doctor`, CI, mock deployment smoke, documented rollback |
+| Single-node production | Technical self-hosters | Docker/gVisor or Firecracker conformance, hardened auth, backup/restore drill |
+| Public self-serve | Users without handholding | Signed releases, upgrade tests, support bundle, clear failure remediation |
+| Enterprise/multi-worker | Infrastructure teams | Postgres, workers, durable scheduler, leases, OIDC/RBAC |
+
+## Phase 7 Acceptance Criteria
+
+- `stacyvm doctor` reports actionable local and production diagnostics.
+- Docker command execution has explicit shell and argv semantics. Done in Phase 7 slice 2.
+- File APIs have path traversal tests across manager scoping and provider boundaries. Done in Phase 7 final cleanup.
+- Sensitive operations are covered by persisted operation audit records. Done in Phase 7 final cleanup.
+- Runtime certification scripts exist for Docker, gVisor, Kata, Firecracker, and PRoot host checks. Done in Phase 7 final cleanup.
+- Threat model is documented for runtime, API, admin, live-preview, pool, and registry surfaces.
+- Release notes describe verified CI and known platform caveats.
+
+## Phase 8 Acceptance Criteria
+
+- SQLite backup and restore are available through the CLI with integrity checks and restore safety copies. Done in Phase 8 slice 1.
+- Production config linting is available through `stacyvm config lint --production` and can run against explicit config files without requiring Docker/KVM host access. Done in Phase 8 slice 2.
+- Upgrade rehearsal checks document backup, config lint, service restart, readiness validation, and rollback. Done in Phase 8 slice 3.
+- Support bundle export exists and redacts secrets before sharing with maintainers. Done in Phase 8 slice 3.
+
+## Phase 9 Acceptance Criteria
+
+- Release binaries and checksums are signed through the GitHub Actions release workflow. Done in Phase 9 slice 1.
+- Published container image digests are signed through the GitHub Actions release workflow. Done in Phase 9 slice 1.
+- A public verification script exists for release signatures and checksums. Done in Phase 9 slice 1.
+- Installer supports Sigstore verification and a fail-closed mode. Done in Phase 9 slice 1.
+- Upgrade and config migration tests run in CI. Done in Phase 9 slice 2.
+- Public docs expose known limitations and exact remediation paths. Done in Phase 9 slice 2.
+- Public release sanity builds and checksum verification run in CI. Done in Phase 9 final polish.
+- SDK parity smoke tests run in CI without requiring a live runtime. Done in Phase 9 final polish.
+- GitHub issue templates request support bundle, config lint, upgrade rehearsal, runtime certification, and release verification evidence. Done in Phase 9 final polish.
+
+## Phase 10 Acceptance Criteria
+
+- Worker registration and heartbeat records are stored durably. Done in Phase 10 slice 1.
+- Single-node servers self-register as the `local` worker with provider and capacity metadata. Done in Phase 10 slice 1.
+- Single-node servers refresh the `local` worker heartbeat while running. Done in Phase 10 heartbeat slice.
+- Read-only worker discovery is available through the normal API. Done in Phase 10 slice 1.
+- Worker heartbeat and deletion are protected by the admin namespace. Done in Phase 10 slice 1.
+- Diagnostics and Prometheus expose worker registry state. Done in Phase 10 slice 1.
+- Sandbox records persist their owning worker ID and diagnostics expose sandbox counts by worker. Done in Phase 10 slice 2.
+- Scheduler placement policy is worker-aware. Remote spawn, status, destroy, live exec streaming, files, logs, preview metadata, and conservative drain/offline ownership policy are available for workers that advertise `rpc_url`.
+- Sandbox ownership is tied to worker IDs. Remote spawn/status/destroy ownership is enforced through worker RPC and persisted runtime IDs.
+- Distributed leases prevent duplicate worker ownership. Remote spawn, renew, and destroy now carry lease tokens; persistence now has SQLite and Postgres store paths with Postgres lease race coverage.
+- Remote worker authentication and RPC contract are implemented for heartbeat, lease renewal, spawn, status, destroy, exec, files, logs, preview metadata, and drain/offline ownership reconciliation. Shared worker tokens remain available for staging, and per-worker token mapping now supports individually rotatable worker credentials.
+
+## Current Release-Candidate Gates
+
+| Gate | Status | Notes |
+|---|---|---|
+| Full Go test suite | Passing | CI runs `make test`. |
+| Web build | Passing | CI runs `npm run build`. |
+| SDK checks | Passing | TypeScript builds, Python imports, and mock-based SDK parity smoke tests run in CI. |
+| Deployment smoke | Passing | Mock-provider smoke is in CI. Docker live host certification remains external. |
+| Cluster conformance | Partial | Always-on CI covers SQLite store contract, live Postgres store contract, Postgres migration rehearsal, Postgres lease concurrency, per-worker and signed worker identity, worker identity certification reporting, production cluster config lint, and Postgres-backed remote worker smoke. See `docs/cluster-conformance.md`. |
+| Runtime conformance | Partial | Harness and host certification script exist; Firecracker/PRoot remain platform-gated. |
+| Security posture | Strong | Admin governance, operation audit, path traversal checks, explicit exec modes, OIDC/JWT RS256+ES256 auth with RBAC, real SHA256 hash in RS256 verification, admin routes protected in OIDC-only mode, tenant/project model, per-tenant audit, policy enforcement on spawn, policy controls for providers/images/networks, and hardened centralized worker token issuer are implemented. |
+| Release automation | Passing | Release workflow signs binaries, checksums, and GHCR image digests; public verifier and installer verification exist. |
+| Worker registry | Near-complete | Durable worker registration, heartbeat, diagnostics, metrics, placement, ownership, leases, per-worker token auth, signed worker identity, centralized token issuance, worker RPC routing, and worker RPC mTLS wiring exist. Remaining: target-network mTLS smoke with deployment-issued certificates. |
+| Enterprise/OIDC | Passing | OIDC/JWT RS256 verification, RBAC roles (viewer/operator/admin/tenant_admin), OIDC group→role mapping, tenant model, per-tenant audit, and policy enforcement are implemented. |
+| Public API exposure | Passing | CORS origins are configurable through `server.cors_allowed_origins`; production config lint fails wildcard or empty CORS before public exposure. |
+
+## Required Before Single-Node Production
+
+- Production config uses distinct API and admin keys.
+- `server.cors_allowed_origins` contains only exact trusted `https://` origins for public browser clients.
+- `auth.admin_fallback_enabled` is `false`.
+- `auth.admin_audit_retention` is set to a production window.
+- Docker provider runs with explicit runtime, network mode, dropped caps, pid limit, memory, CPU, and seccomp settings.
+- Firecracker hosts pass Linux/KVM conformance before being marked production.
+- Backup and restore are tested against the SQLite database.
+- `stacyvm config lint --production` passes with the same config and environment variables the service will use.
+- `stacyvm upgrade rehearse` passes before binary/image replacement.
+- Operators can generate `stacyvm support bundle` output without exposing API keys or provider secrets.
+- Runtime certification artifacts are generated on the actual host with `scripts/certify-runtime.sh --format markdown --output -certification.md`.
+- Operators run `stacyvm doctor --production` before go-live.
+
+## Required Before Public Self-Serve
+
+- Release artifacts are signed and checksummed.
+- Upgrade and config migration tests run in CI.
+- `stacyvm doctor` includes remediation links for every failure.
+- Support bundle export exists and redacts secrets.
+- Threat model is reviewed for each release candidate.
+- Known limitations are visible in README, docs, and release notes.
+- Public support expectations are documented in [public-support-matrix](/docs/public-support-matrix).
+- Bug and production support issue templates ask for the same evidence required by the public support matrix.
+- Public release sanity CI builds release binaries and validates checksums; real GitHub release asset verification must be repeated after each version tag is published.
+- Public browser clients use explicit CORS origins; wildcard CORS must fail `stacyvm config lint --production`.
+- Final public evidence is generated with `scripts/public-readiness-evidence.sh`; announcement requires a **PUBLIC SELF-SERVE READY** verdict for the release tag, target host runtime, and deployment network.
+
+## Required Before Enterprise/Multi-Worker
+
+- Postgres store implementation. Driver, migrations, contract path, migration rehearsal, lease race coverage, and mock-provider remote worker smoke exist. Backup rehearsal is available via `stacyvm db pg-rehearse` and `stacyvm db pg-backup`. Done in Phase 14.
+- Worker registration and heartbeat model. Durable registry, per-worker token auth, signed worker tokens, issuer/rotation workflow, centralized token issuance via `/api/v1/admin/worker-tokens`, worker identity certification reporting, and worker RPC mTLS wiring exist. Target-network mTLS smoke with deployment-issued certificates remains pending for specific enterprise networks.
+- Scheduler abstraction with placement policy. Done in Phase 10.
+- Durable queue/pub-sub for lifecycle events. Covered by EventBus and persisted lease model.
+- Distributed leases to prevent double ownership. Done in Phase 10.
+- OIDC/SSO and RBAC implemented. RS256 JWT Bearer token validation with configurable OIDC issuer, JWKS URL, audience, and group-to-role mapping is implemented. Roles: `viewer`, `operator`, `admin`, `tenant_admin`, `worker`. Done in Phase 14.
+- Tenant/project model implemented. Tenants, tenant members with RBAC roles, policy controls (image/provider/network allow-deny lists), per-tenant audit export, and admin UI management are implemented. Done in Phase 14.
+- Worker RPC transport enforces [worker-rpc-contract](/docs/worker-rpc-contract). Done in Phase 11-13.
+
+## Phase 14 Acceptance Criteria
+
+- OIDC/JWT RS256 Bearer token validation with configurable issuer, JWKS URL, audience, groups claim, and group-to-role mapping. Done.
+- RBAC roles beyond admin/api: viewer, operator, tenant_admin with scoped permissions. Done.
+- Tenant/project model: tenant CRUD, member RBAC, per-tenant resource scoping. Done.
+- Per-tenant audit export: admin and operation audit logs scoped by tenant_id. Done.
+- Policy controls: per-tenant allow/deny policies for image, provider, and network resources. Done.
+- Centralized worker token issuer: admin API endpoint mints signed worker tokens so workers do not need direct signing key access. Done.
+- Postgres backup: `stacyvm db pg-backup` wraps pg_dump for production cluster snapshots. Done.
+- Postgres migration rehearsal: `stacyvm db pg-rehearse` verifies schema state before upgrades. Done.
+- Admin UI tenant management: Tenants page with member RBAC and policy management. Done.
+- Worker RPC mTLS smoke with deployment-issued certificates in target enterprise network: Pending (external to code).
diff --git a/docs/provider-contract.md b/docs/provider-contract.md
new file mode 100644
index 0000000..850ad23
--- /dev/null
+++ b/docs/provider-contract.md
@@ -0,0 +1,85 @@
+# StacyVM Provider Contract
+
+Providers are the runtime boundary for StacyVM. The orchestrator, API, SDKs, and
+dashboard must be able to treat every provider the same way, whether the runtime
+is Docker, Firecracker, PRoot, E2B, a custom HTTP service, or a test double.
+
+The Go source of truth is `internal/providers/provider.go`. This document
+spells out the behavioral contract expected by the shared provider conformance
+tests.
+
+## Lifecycle
+
+- `Name` returns a stable unique identifier used in config, API responses, and
+ persisted sandbox records.
+- `Spawn` creates a running sandbox and returns a non-empty sandbox ID.
+- `Status` returns `running` for an active sandbox.
+- `Destroy` tears down runtime resources. Providers may return `ErrSandboxNotFound`
+ if the runtime object is already gone.
+- After destroy, exec and file operations must fail with either
+ `ErrSandboxDestroyed` or `ErrSandboxNotFound`.
+- Provider implementations should be best-effort idempotent around external
+ cleanup. The orchestrator may call destroy during TTL reaping, explicit API
+ requests, or recovery flows.
+
+## Exec
+
+- `Exec` runs the requested command and returns stdout, stderr, and exit code.
+- Exec mode is explicit:
+ - Empty mode and `shell` run `Command` through `/bin/sh -c`; `Args` are
+ shell-quoted and appended for backwards compatibility.
+ - `argv` runs `Command` directly with `Args` as literal process arguments.
+ Providers must not invoke a shell in this mode.
+- A nonzero command exit is not a provider error. It must return an `ExecResult`
+ with the nonzero exit code.
+- Provider errors are reserved for runtime failures: missing sandbox, provider
+ unavailable, transport failure, timeout, or invalid provider state.
+- Context cancellation and deadlines should be honored. Deadline expiration
+ should map to `ErrExecTimeout` where the provider can detect it.
+- `ExecStream` emits stdout/stderr chunks and closes its channel when the command
+ finishes or the stream fails.
+
+## Files
+
+- File paths are interpreted inside the sandbox filesystem.
+- Providers must support write, read, list, delete, move, chmod, stat, and glob.
+- `WriteFile` should create missing parent directories when the runtime can do so.
+- File reads return an `io.ReadCloser`; callers own closing it.
+- Missing sandbox errors should use `ErrSandboxNotFound` or `ErrSandboxDestroyed`.
+- Missing file behavior can remain provider-specific unless an API route maps it
+ into a user-facing error.
+
+## Health
+
+- `Healthy` should be fast and side-effect free.
+- It should return false when the runtime dependency is unreachable, for example
+ Docker daemon unavailable, PRoot binary missing, Firecracker binary missing, or
+ a custom HTTP backend failing its health endpoint.
+
+## Typed Errors
+
+Providers should use these sentinel errors from `internal/providers/errors.go`:
+
+- `ErrSandboxNotFound`
+- `ErrSandboxDestroyed`
+- `ErrProviderNotFound`
+- `ErrProviderUnavailable`
+- `ErrExecTimeout`
+- `ErrResourceLimit`
+
+Wrapping is encouraged with `fmt.Errorf("context: %w", err)` so callers can use
+`errors.Is`.
+
+## Conformance Tests
+
+Shared conformance tests live in
+`internal/providers/provider_conformance_test.go`.
+
+Current coverage:
+
+- Mock provider
+- Docker provider, when Docker is available
+- Custom provider through an in-process fake HTTP backend
+
+Future providers should be wired into the same harness whenever their runtime
+dependencies are available.
diff --git a/docs/public-readiness-evidence.md b/docs/public-readiness-evidence.md
new file mode 100644
index 0000000..245eb1d
--- /dev/null
+++ b/docs/public-readiness-evidence.md
@@ -0,0 +1,88 @@
+# Public Readiness Evidence
+
+Use this runbook to create the final evidence bundle before announcing StacyVM as public self-serve production-ready.
+
+The codebase can be public-readiness complete before a release is public-launch complete. Public launch also needs proof from the actual tag, host runtimes, network, and staging environment.
+
+## Candidate Evidence
+
+For a branch or release candidate, run:
+
+```bash
+STACYVM_AUTH_API_KEY=replace-with-32-byte-key \
+STACYVM_AUTH_ADMIN_API_KEY=replace-with-different-32-byte-key \
+scripts/public-readiness-evidence.sh --output public-readiness-evidence.md
+```
+
+This verifies:
+
+- shell syntax for public install, release, readiness, upgrade, cluster, and runtime scripts
+- `stacyvm config lint --production` against the production config
+- full Go test suite
+- web production build
+- public release sanity build and checksum verification
+- upgrade and migration sanity
+
+The report verdict is **PUBLIC SELF-SERVE CANDIDATE** when local gates pass but tag/host-gated evidence is skipped.
+
+## Announcement Evidence
+
+After publishing a real GitHub release tag and choosing the runtime claims for launch, run the full gate:
+
+```bash
+STACYVM_AUTH_API_KEY=replace-with-32-byte-key \
+STACYVM_AUTH_ADMIN_API_KEY=replace-with-different-32-byte-key \
+STACYVM_POST_RELEASE_VERSION=v0.0.0 \
+STACYVM_VALIDATE_INSTALLER=true \
+STACYVM_RUNTIME_CERTIFY=docker \
+STACYVM_RUN_CLUSTER_CONFORMANCE=true \
+scripts/public-readiness-evidence.sh --output public-readiness-evidence.md
+```
+
+Add runtimes only when the target host can certify them:
+
+```bash
+STACYVM_RUNTIME_CERTIFY=docker,gvisor,kata,firecracker
+```
+
+Do not claim Firecracker production readiness from a non-Linux or non-KVM host. Do not claim PRoot as a production isolation boundary.
+
+The report verdict is **PUBLIC SELF-SERVE READY** only when all required local, tag, runtime, and cluster gates pass without skips.
+
+## GitHub-Hosted Certification Gate
+
+After a tag is published, maintainers can run the manual **Public Readiness Certification** workflow from GitHub Actions. Use the published tag, for example `v0.14.3`, and the runtime claim to validate on the GitHub-hosted Linux runner.
+
+The workflow performs:
+
+- `scripts/post-release-validate.sh `
+- `STACYVM_VALIDATE_INSTALLER=true` installer verify-only on Linux
+- `scripts/certify-runtime.sh docker --stacyvm-bin ./stacyvm-linux-amd64`
+- evidence upload as a workflow artifact
+
+This is acceptable proof for the GitHub-hosted Docker runtime claim. It does not replace certification on the actual production host, because Docker daemon settings, seccomp/AppArmor, image cache behavior, KVM, gVisor/Kata, and network policy are host-specific.
+
+## Required Attachments
+
+Keep these artifacts with the release:
+
+- `public-readiness-evidence.md`
+- output from `scripts/post-release-validate.sh `
+- runtime certification Markdown for every runtime claimed publicly
+- live Postgres contract evidence for cluster or multi-worker claims
+- target-network worker RPC mTLS smoke output when enterprise/multi-worker support is claimed
+- staging install rehearsal notes from published artifacts
+- redacted support bundle generated from the staging deployment
+
+## Final Approval Rule
+
+Public announcement is allowed only when:
+
+- the readiness evidence verdict is **PUBLIC SELF-SERVE READY**
+- release assets are signed and checksum-verified
+- every public runtime claim has host certification evidence
+- production config lint passes with real secrets supplied through environment or secret files
+- `server.cors_allowed_origins` contains exact trusted origins, not `*`
+- rollback, backup/restore, and upgrade rehearsal have been exercised in staging
+
+If any of those are missing, announce the build as a release candidate or technical preview instead.
diff --git a/docs/public-support-matrix.md b/docs/public-support-matrix.md
new file mode 100644
index 0000000..693eb7d
--- /dev/null
+++ b/docs/public-support-matrix.md
@@ -0,0 +1,81 @@
+# Public Support Matrix
+
+This matrix sets expectations for public self-serve StacyVM installs. It separates generally supported workflows from host-gated runtime certification so operators know what can be used immediately, what must be validated on their own infrastructure, and what remains experimental.
+
+## Support Levels
+
+| Level | Meaning |
+|---|---|
+| Supported | Covered by CI, documented setup, release verification, and support-bundle workflows. |
+| Host-certified | Supported after `scripts/certify-runtime.sh` passes on the target host and the report is retained. |
+| Preview | Usable for evaluation, but not recommended for production workloads without maintainer review. |
+| Experimental | Available for development or constrained environments; not a production isolation boundary. |
+| Planned | Production design exists, but implementation is not complete enough for self-serve users. |
+
+## Runtime And Deployment Matrix
+
+| Mode | Support level | Public self-serve status | Required evidence | Limitations |
+|---|---|---|---|---|
+| Local mock provider | Supported | Development only | `go test ./...` or CI pass | No sandbox isolation; not a production runtime. |
+| Single-node Docker/runc | Supported | Technical production with hardened config | `stacyvm config lint --production`, `stacyvm doctor --production`, support bundle | Isolation is container-based; operators must keep Docker, kernel, and seccomp policy patched. |
+| Single-node Docker with gVisor | Host-certified | Recommended container hardening path | Runtime certification report for `gvisor` | Requires host runtime installation and Docker runtime wiring outside StacyVM. |
+| Single-node Docker with Kata | Host-certified | VM-backed container path | Runtime certification report for `kata` | Requires host runtime installation, VM support, and capacity planning. |
+| Firecracker | Host-certified | VM isolation path for Linux/KVM hosts | Runtime certification report for `firecracker` | Requires Linux, KVM, kernel/rootfs/agent assets, and host networking setup. |
+| PRoot | Experimental | Development and restricted hosts only | Runtime certification report for `proot` if used | Not a VM or container isolation boundary; production use is not recommended. |
+| E2B/custom provider | Preview | Integration-specific | Provider health, conformance results, and provider-specific logs | External provider availability, auth, and isolation guarantees are outside StacyVM's direct control. |
+| Multi-worker cluster | Preview | Enterprise self-serve with OIDC + tenant model | Cluster conformance output, Postgres contract output, worker identity certification report, runtime certification for every worker runtime, OIDC provider configuration, and tenant policy review | Worker registry, placement, leases, Postgres store path, signed worker identity, centralized token issuance, worker RPC routing, mTLS wiring, OIDC/JWT RS256 auth, RBAC roles (viewer/operator/admin/tenant_admin), tenant model, policy controls, and per-tenant audit export are implemented. Remaining: target-network mTLS smoke with deployment-issued certificates for specific enterprise networks. |
+
+## Public Install Requirements
+
+Before treating a self-serve install as supported, operators should capture:
+
+- Release verification output from `scripts/verify-release.sh ` or installer output with Sigstore verification.
+- `stacyvm config lint --production --file ` output with production environment variables loaded.
+- Confirmation that `server.cors_allowed_origins` contains exact trusted origins, not `*`.
+- `stacyvm upgrade rehearse --config --database ` output before binary or image replacement.
+- `stacyvm doctor --production` output from the target host.
+- `stacyvm support bundle --output support.json` when opening a support issue.
+- Runtime certification output for gVisor, Kata, Firecracker, or PRoot hosts.
+- Worker identity certification output from `scripts/certify-worker-identity.sh --format markdown --output worker-identity-certification.md` for multi-worker previews.
+- Evidence that externally issued worker token files refresh before expiry when workers rely on `stacyvm worker --worker-token-file` instead of local signing keys.
+- Public readiness evidence from `scripts/public-readiness-evidence.sh --output public-readiness-evidence.md`.
+
+GitHub bug and production support issue templates ask for this same evidence. Reports without the relevant artifacts may need an extra triage round before maintainers can reproduce or classify the issue.
+
+## Known Public Limitations
+
+- SQLite is the supported single-node store. Postgres-backed cluster semantics exist, but multi-worker production still requires deployment-specific Postgres contract evidence and backup/restore rehearsal.
+- API keys and admin keys are the primary auth path for single-node deployments. OIDC/JWT RS256 Bearer token auth with JWKS is implemented for enterprise multi-tenant deployments (`auth.oidc_enabled`, `auth.oidc_issuer`, `auth.oidc_jwks_url`). OIDC group-to-role mapping covers viewer, operator, admin, and tenant_admin roles.
+- Docker/runc is convenient and supported with hardened settings, but it is not equivalent to VM isolation.
+- Firecracker production readiness is host-gated because KVM, kernel, rootfs, agent, and networking setup vary by host.
+- PRoot is useful where Docker/KVM are unavailable, but it should not be presented as a production isolation boundary.
+- Release signatures prove artifact provenance from the StacyVM GitHub Actions release workflow; they do not certify a host runtime.
+- CORS is intentionally wildcard by default for local development, but public browser/API deployments must configure explicit trusted origins and pass production config lint.
+
+## Support Triage Links
+
+| Symptom | First remediation path |
+|---|---|
+| Install verification fails | [releasing](/docs/releasing) |
+| Production config lint fails | [deployment](/docs/deployment) |
+| Upgrade rehearsal fails | [deployment#upgrades](/docs/deployment#upgrades) |
+| Runtime health fails | [runtime-certification](/docs/runtime-certification) |
+| Runtime behavior differs across providers | [runtime-conformance](/docs/runtime-conformance) |
+| Admin or auth hardening question | [security-governance](/docs/security-governance) |
+| Operator diagnostics needed | [deployment#support-bundles](/docs/deployment#support-bundles) |
+
+## Post-Tag Release Verification
+
+CI builds release binaries and validates checksums before code is merged into a release branch. After a real version tag is published, maintainers should run the public verifier against the GitHub release assets:
+
+```bash
+scripts/post-release-validate.sh
+```
+
+On a clean Linux host, also run the installer in verify-only mode with signatures required:
+
+```bash
+STACYVM_VALIDATE_INSTALLER=true scripts/post-release-validate.sh
+```
+
+For a final install smoke, run `scripts/install.sh` once with default checksum verification and once with `STACYVM_REQUIRE_SIGNATURES=true` plus `cosign` installed.
diff --git a/docs/releases/phase-1-foundation-hardening.md b/docs/releases/phase-1-foundation-hardening.md
new file mode 100644
index 0000000..ed98ff0
--- /dev/null
+++ b/docs/releases/phase-1-foundation-hardening.md
@@ -0,0 +1,161 @@
+# Phase 1 Foundation Hardening Release Notes
+
+Date: 2026-05-08
+Branch: `feat/phase-1-foundation-hardening`
+Commit: `194267e`
+
+## Summary
+
+Phase 1 focused on turning StacyVM's early provider/orchestrator foundation into a more production-ready base. The work improves error consistency, provider contracts, startup recovery, platform-aware conformance coverage, and local developer build reliability.
+
+This phase does not introduce a new user-facing sandbox feature. Instead, it strengthens the foundation that future phases will build on: predictable errors, safer reconciliation after restarts, clearer provider expectations, and stronger regression coverage.
+
+## What Changed
+
+### Provider Contract And Conformance
+
+- Added `docs/provider-contract.md` to document the required behavior for every sandbox provider.
+- Added a reusable provider conformance test harness covering:
+ - spawn, status, and destroy lifecycle
+ - command execution success and non-zero exits
+ - streaming command output
+ - file write, read, stat, glob, move, chmod, and delete
+- Wired conformance coverage for Mock, Docker, Custom, PRoot, and Firecracker providers.
+- PRoot and Firecracker conformance tests are platform-gated so they skip locally unless the required runtime dependencies are available.
+
+### Typed Error Taxonomy
+
+- Added typed provider errors for:
+ - sandbox not found
+ - sandbox destroyed
+ - provider not found
+ - provider unavailable
+ - exec timeout
+ - resource limit
+- Added typed store errors for:
+ - not found
+ - conflict
+- Re-exported provider errors through the orchestrator package where API routes need stable domain-level matching.
+
+### API Error Handling
+
+- Added shared route error mapping in `internal/api/routes/errors.go`.
+- Replaced string-matching error handling in sandbox, template, environment, and provider routes with typed error checks.
+- Added response codes for timeout and resource-limit failures.
+- API responses now map important failure classes consistently:
+ - `404` for missing resources and sandbox lifecycle misses
+ - `408` for exec timeout
+ - `429` for resource limits
+ - `503` for provider unavailability
+
+### Startup Reconciliation
+
+- Added `Manager.Reconcile(ctx)` to refresh persisted sandbox state from provider runtime state at server startup.
+- Server startup now runs reconciliation before starting the manager reaper.
+- Persisted sandboxes whose runtime no longer exists are marked `destroyed`.
+- Persisted sandboxes whose provider is unavailable are marked `error`.
+- Live provider runtimes can be restored into the manager's in-memory cache.
+
+### Docker Runtime Adoption
+
+- Docker sandboxes now include richer `stacyvm.*` labels for runtime discovery.
+- Added Docker runtime inventory support through `ListRuntimeSandboxes`.
+- Startup reconciliation can adopt StacyVM Docker containers that still exist but are missing from SQLite after a process restart.
+- Docker missing-container cases now map to typed `ErrSandboxNotFound`.
+
+### Streaming Timeout Semantics
+
+- `Manager.ExecStream` now honors request-level timeout values.
+- Streaming timeout paths emit an explicit stderr timeout chunk instead of silently closing.
+- Docker, Custom, and Firecracker streaming paths now propagate timeout state more clearly.
+
+### macOS Build Reliability
+
+- The Linux-only `stacyvm-agent` entrypoint now has a Linux build tag.
+- Added a non-Linux stub so `make build` and `make test` work on macOS while preserving the real Linux agent behavior.
+
+## Code Changes By Area
+
+### New Files
+
+- `CHANGELOG.md`
+- `cmd/stacyvm-agent/main_unsupported.go`
+- `docs/provider-contract.md`
+- `internal/api/routes/errors.go`
+- `internal/api/routes/templates_test.go`
+- `internal/orchestrator/errors.go`
+- `internal/providers/custom_conformance_test.go`
+- `internal/providers/errors.go`
+- `internal/providers/provider_conformance_test.go`
+- `internal/store/errors.go`
+
+### Core Orchestrator
+
+- `internal/orchestrator/manager.go`
+ - Added startup reconciliation.
+ - Added provider runtime adoption.
+ - Added streaming timeout handling.
+- `internal/orchestrator/manager_test.go`
+ - Added tests for reconciliation, runtime adoption, and streaming timeout behavior.
+- `cmd/stacyvm/cmd_serve.go`
+ - Runs reconciliation during server startup.
+
+### Providers
+
+- `internal/providers/provider.go`
+ - Documented provider contract expectations.
+ - Added optional runtime inventory interfaces.
+- `internal/providers/docker.go`
+ - Added StacyVM labels.
+ - Added runtime listing.
+ - Added typed not-found handling.
+ - Improved streaming timeout propagation.
+- `internal/providers/custom.go`
+ - Added typed HTTP error mapping.
+ - Improved streaming timeout propagation.
+- `internal/providers/firecracker.go`
+ - Added typed lifecycle behavior and streaming read deadlines.
+- `internal/providers/proot.go`
+ - Added typed lifecycle, timeout, and resource-limit behavior.
+- `internal/providers/mock.go`
+ - Added typed lifecycle and timeout behavior for tests.
+- `internal/providers/registry.go`
+ - Uses typed provider-not-found errors.
+
+### Store
+
+- `internal/store/sqlite.go`
+ - Maps missing rows and constraint conflicts to typed store errors.
+- `internal/store/sqlite_test.go`
+ - Adds coverage for typed store errors.
+
+### API Routes
+
+- `internal/api/routes/sandboxes.go`
+- `internal/api/routes/templates.go`
+- `internal/api/routes/environments.go`
+- `internal/api/routes/providers.go`
+
+These routes now use shared typed error handling instead of string comparisons.
+
+## Verification
+
+The following checks passed:
+
+```sh
+make test
+make build
+cd web && npm run build
+```
+
+Additional Docker provider conformance and runtime inventory checks passed with Docker daemon access.
+
+## Platform Notes
+
+- Firecracker conformance requires Linux, `/dev/kvm`, Firecracker, kernel, rootfs, and agent paths.
+- PRoot conformance requires `proot` and a usable rootfs.
+- The full Go integration suite uses `httptest`; local sandboxed runs need permission to bind local test sockets.
+
+## Impact
+
+Phase 1 leaves the codebase ready for Phase 2 work by making provider behavior explicit, testable, and recoverable. The next phase can focus on scalability and production operations without first untangling provider lifecycle ambiguity or inconsistent API failure behavior.
diff --git a/docs/releases/phase-10-multi-worker-foundation.md b/docs/releases/phase-10-multi-worker-foundation.md
new file mode 100644
index 0000000..f7826d6
--- /dev/null
+++ b/docs/releases/phase-10-multi-worker-foundation.md
@@ -0,0 +1,115 @@
+# Phase 10 Multi-Worker Foundation Release Notes
+
+Date: 2026-05-09
+Branch: `phase-10-multi-worker-foundation`
+
+## Summary
+
+Phase 10 starts the enterprise and multi-worker production track. This slice adds the durable worker registry foundation StacyVM needs before scheduler placement, worker ownership, leases, and remote worker RPC can be made production-grade.
+
+This is not a full distributed runtime yet. It is the first production-aligned control-plane layer for observing workers, recording heartbeats, and exposing that state through APIs, diagnostics, and metrics.
+
+## What Changed
+
+### Worker Registry Storage
+
+- Added a SQLite migration for the `workers` table.
+- Added durable worker fields for ID, hostname, status, providers, capabilities, capacity, heartbeat timestamp, and lifecycle timestamps.
+- Added store methods for saving, fetching, listing, and deleting worker records.
+
+### Local Worker Registration
+
+- The API server now registers the current process as the `local` worker at startup.
+- The API server now refreshes the `local` worker heartbeat periodically while running.
+- Server shutdown stops the heartbeat loop cleanly.
+- The local record includes configured providers, single-node capabilities, and manager capacity limits.
+- Single-node deployments now appear in the same worker registry surface that future multi-worker deployments will use.
+
+### Worker API
+
+- Added read-only worker discovery:
+ - `GET /api/v1/workers`
+ - `GET /api/v1/workers/{workerID}`
+- Added admin-only worker mutations:
+ - `POST /api/v1/admin/workers/{workerID}/heartbeat`
+ - `DELETE /api/v1/admin/workers/{workerID}`
+- Worker responses include a computed `stale` flag when the last heartbeat is older than the freshness window.
+
+### Sandbox Worker Ownership
+
+- Added persisted `worker_id` ownership to sandbox records.
+- New and adopted local sandboxes are stamped with the active worker ID.
+- Scheduler status now reports the current worker ID.
+- Sandbox API responses now include `worker_id` when ownership is known.
+
+### Worker-Aware Scheduler Placement
+
+- Spawn admission now evaluates worker placement using worker status, heartbeat freshness, provider support, declared capacity, and active sandbox counts.
+- Scheduler status now reports the selected worker and number of eligible workers.
+- Local execution remains honest: if the scheduler would place work on a remote worker, admission reports `remote_worker_rpc_unavailable` until the worker RPC slice lands.
+- Stale local worker records are no longer special-cased; real server runs keep the local worker fresh through the heartbeat loop.
+
+### Distributed Lease Foundation
+
+- Added durable lease records for resource ownership fencing.
+- Added store APIs to acquire, renew, release, get, and list leases.
+- Lease acquisition is holder-aware and expiry-aware: a competing worker cannot acquire an unexpired lease held by another worker.
+- Lease renewals require the current holder and an unexpired lease.
+- Diagnostics and Prometheus now expose lease totals so operators can inspect active and expired lease state.
+
+### Lease Enforcement
+
+- Local spawns now acquire a sandbox lease before persisting the sandbox record.
+- Runtime adoption during reconciliation now acquires a sandbox lease before adopting unknown provider runtimes.
+- Pool VM and pooled logical sandbox creation now acquire leases.
+- Destroy now acquires or renews the local worker lease before mutating provider/runtime/store state.
+- Successful destroy releases the sandbox lease.
+- Wrong-holder lease tests now prevent local destroy from mutating a sandbox owned by another worker.
+
+### Worker RPC Contract And Auth Model
+
+- Added `internal/workerproto` with transport-neutral worker request and response envelopes.
+- Defined contract methods for heartbeat, spawn, destroy, status, lease renewal, and shutdown.
+- Mutating worker assignments require a lease token in the message contract.
+- Added transport-neutral worker auth claims and initial scopes.
+- Documented the worker trust boundary, suggested headers, lease fencing rules, and Postgres cluster-store guarantees in `docs/worker-rpc-contract.md`.
+- Remote worker execution remains gated until a network transport enforces this contract.
+
+### Diagnostics And Metrics
+
+- Diagnostics now include worker totals, online count, stale count, unhealthy count, and worker items.
+- Diagnostics now include lease totals, active count, expired count, and active leases by holder.
+- Diagnostics sandbox summaries now include `by_worker` counts.
+- Prometheus output now includes:
+ - `stacyvm_workers_total{status="total"}`
+ - `stacyvm_workers_total{status="online"}`
+ - `stacyvm_workers_total{status="stale"}`
+ - `stacyvm_workers_total{status="unhealthy"}`
+ - `stacyvm_leases_total{status="active"}`
+ - `stacyvm_sandboxes_by_worker_total{worker="local"}`
+
+### Documentation
+
+- Updated the changelog with Phase 10 changes.
+- Updated the API reference with worker endpoints and metrics.
+- Updated the README endpoint table with worker discovery.
+- Updated the production readiness checklist with Phase 10 acceptance criteria.
+
+## Code Areas
+
+- `internal/store`: worker model, lease model, migrations, SQLite CRUD, and sandbox `worker_id` persistence.
+- `internal/workerproto`: worker RPC contract and auth claim types.
+- `internal/api/routes`: worker routes, diagnostics worker summary, and Prometheus worker metrics.
+- `internal/api/server.go`: local worker startup registration, heartbeat refresh loop, and route mounting.
+- `docs`: API, README, changelog, production readiness, and release notes.
+
+## Verification
+
+- `go test ./internal/store ./internal/api/routes ./internal/api`
+- `scripts/check-swagger.sh`
+- `go test ./...`
+- `git diff --check`
+
+## Remaining Phase 10 Direction
+
+Phase 10 is complete as a foundation branch. Follow-up phases should implement the network worker daemon/transport, Postgres-backed cluster store, remote lifecycle conformance tests, and OIDC/RBAC for enterprise production.
diff --git a/docs/releases/phase-11-remote-worker-runtime.md b/docs/releases/phase-11-remote-worker-runtime.md
new file mode 100644
index 0000000..bd12a8b
--- /dev/null
+++ b/docs/releases/phase-11-remote-worker-runtime.md
@@ -0,0 +1,98 @@
+# Phase 11 Remote Worker Runtime Release Notes
+
+Date: 2026-05-09
+Branch: `phase-11-remote-worker-runtime`
+
+## Summary
+
+Phase 11 turns the Phase 10 worker RPC contract into a real remote-worker runtime path. It adds a worker process, worker-specific authentication, worker heartbeat, inbound worker RPC, and control-plane routing for remote spawn, status, destroy, lease renewal, and drain-mode shutdown.
+
+This is now suitable for internal two-process staging with the mock provider. It is not yet enterprise multi-worker production: remote exec/files/logs/previews, production-grade worker identity, Postgres-backed lease semantics, and assignment handoff remain future work.
+
+## What Changed
+
+### Worker Runtime Command
+
+- Added `stacyvm worker` as the entrypoint for remote worker processes.
+- Added `--id`, `--control-plane`, `--worker-token`, `--heartbeat-interval`, `--listen`, and `--once` flags.
+- The worker ID defaults to `worker.id`, then the host name.
+- `--once` sends a single heartbeat and exits for smoke tests and staging probes.
+- `--listen` starts the inbound worker RPC server for control-plane-to-worker calls.
+
+### Worker Configuration
+
+- Added `worker.id`.
+- Added `worker.control_plane_url`.
+- Added `worker.listen_addr`.
+- Added `worker.heartbeat_interval`.
+- Added `worker.shutdown_timeout`.
+- Added `auth.worker_token` for worker-to-control-plane authentication.
+- Worker durations are validated through the normal config validation path.
+
+### Worker Authentication
+
+- Added a dedicated worker auth role and `worker:heartbeat` scope.
+- Added `X-Worker-ID` and `X-Worker-Token` validation for worker endpoints.
+- Worker credentials cannot authenticate regular API/admin routes.
+- Worker heartbeats are rejected when the authenticated worker ID does not match the requested worker path.
+
+### Worker Heartbeat Transport
+
+- Added `internal/worker` with a heartbeat client and runtime loop.
+- Added a worker-only control-plane endpoint:
+ - `POST /api/v1/worker/{workerID}/heartbeat`
+- Added a worker-only control-plane lease renewal endpoint:
+ - `POST /api/v1/worker/{workerID}/leases/{resourceID}/renew`
+- The endpoint persists worker host, status, provider list, capability list, capacity, and last heartbeat timestamp through the existing worker registry.
+
+### Worker RPC Transport
+
+- Added worker-side `/rpc` HTTP handling for `workerproto.Request` envelopes.
+- Added worker RPC authentication with `X-Worker-ID` and `X-Worker-Token`.
+- Implemented `worker.status` against the local worker provider registry.
+- Implemented `worker.renew_lease` with resource, holder, and expiry validation before calling the control plane to renew the durable lease.
+- Implemented worker-side `worker.spawn` with lease validation and provider-backed runtime creation.
+- Added a typed worker RPC client for control-plane calls to `worker.spawn` and `worker.status`.
+- Added control-plane remote spawn assignment when the scheduler selects a non-local worker with an advertised `rpc_url`.
+- Remote spawn now acquires the durable sandbox lease for the selected worker before calling `worker.spawn`.
+- Remote spawn persists the control-plane sandbox ID, selected `worker_id`, and provider `runtime_id`.
+- Sandbox reads now refresh remote-owned sandbox state through `worker.status` using the persisted provider `runtime_id`.
+- Remote status refresh updates persisted sandbox state when the worker reports a state change.
+- Implemented worker-side `worker.destroy` with lease validation and provider runtime teardown.
+- Added control-plane remote destroy routing for remote-owned sandboxes using persisted `worker_id` and `runtime_id`.
+- Remote destroy updates sandbox state, releases the durable lease, and publishes the normal destroyed event.
+- Implemented `worker.shutdown` drain behavior: the worker marks itself draining, rejects new spawn assignments, and reports `draining` on subsequent heartbeats.
+- `worker.spawn` returns the control-plane sandbox ID and provider runtime ID separately, and the control plane persists that mapping for later routing.
+- Destroy now uses the worker RPC path for remote-owned sandboxes.
+- Added a two-process remote worker staging guide in `docs/remote-worker-staging.md`.
+- Added `scripts/smoke-remote-worker.sh` to exercise control plane plus worker with the mock provider.
+
+## Code Areas
+
+- `cmd/stacyvm/cmd_worker.go`: remote worker command.
+- `internal/worker`: worker heartbeat client, runtime loop, and inbound worker RPC handler.
+- `internal/api/middleware/auth.go`: worker auth role, scope, and credential validation.
+- `internal/api/server.go`: worker-only heartbeat route.
+- `internal/api/routes/workers.go`: worker ID ownership check for heartbeat.
+- `internal/config/config.go`: worker runtime and worker token configuration.
+- `docs/remote-worker-staging.md`: two-process staging guide.
+- `scripts/smoke-remote-worker.sh`: local mock remote-worker smoke flow.
+
+## Verification
+
+- `go test ./internal/config ./internal/api ./internal/worker ./cmd/stacyvm`
+- `go test ./internal/worker ./internal/config ./cmd/stacyvm`
+- `go test ./...`
+- `scripts/check-swagger.sh`
+- `bash -n scripts/smoke-remote-worker.sh`
+- `scripts/smoke-remote-worker.sh /private/tmp/stacyvm-phase11-smoke`
+- `npm run build` in `web`
+- `npm run build` in `sdk/js`
+- `PYTHONPYCACHEPREFIX=/private/tmp/stacyvm-pycache python3 -m compileall stacyvm` in `sdk/python`
+- `scripts/ci-upgrade-migration.sh`
+- `scripts/ci-public-release-sanity.sh`
+
+## Next Phase Direction
+
+- Extend worker routing beyond spawn/status/destroy to exec, files, logs, and previews.
+- Add production-grade drain handoff/reassignment across workers.
diff --git a/docs/releases/phase-12-remote-sandbox-io-routing.md b/docs/releases/phase-12-remote-sandbox-io-routing.md
new file mode 100644
index 0000000..ebd55f6
--- /dev/null
+++ b/docs/releases/phase-12-remote-sandbox-io-routing.md
@@ -0,0 +1,85 @@
+# Phase 12 Remote Sandbox I/O Routing Release Notes
+
+Date: 2026-05-09
+Branch: `phase-12-remote-sandbox-io-routing`
+
+## Summary
+
+Phase 12 extends the Phase 11 remote worker runtime beyond lifecycle operations into sandbox I/O routing and conservative remote ownership handling.
+
+Non-streaming exec, live exec-stream calls, file APIs, console logs, preview metadata, and drain/offline ownership policy are now routed through remote worker ownership.
+
+## What Changed
+
+### Worker RPC Contract
+
+- Added the `worker.exec` method to the worker RPC contract.
+- Added the `worker.exec_stream` method to the worker RPC contract.
+- Added NDJSON stream transport for `worker.exec_stream`.
+- Added `workerproto.ExecParams` for command, argv mode, environment, workdir, timeout, provider, sandbox ID, and provider runtime ID.
+- Added `workerproto.ExecResult` for exit code, stdout, and stderr.
+- Added `workerproto.ExecStreamResult` for stdout/stderr chunk delivery.
+- Added worker file RPC methods for write, read, list, delete, move, chmod, stat, and glob.
+- Added worker file result payloads for content, file listings, stat entries, and glob matches.
+- Added `worker.logs` for remote console log retrieval.
+- Added `worker.preview_domain` config and heartbeat capacity advertisement for worker-specific preview ingress.
+- Added `unhealthy` and `expired` sandbox states for remote ownership reconciliation.
+- Added the `worker:exec` scope constant for future token-scoped worker identity.
+- Added the `worker:files` scope constant for future token-scoped worker identity.
+- Added the `worker:logs` scope constant for future token-scoped worker identity.
+
+### Worker Runtime
+
+- Implemented worker-side `worker.exec` handling in the inbound RPC server.
+- Implemented worker-side `worker.exec_stream` handling in the inbound RPC server.
+- Worker exec resolves the provider runtime ID, runs through the worker's provider registry, and returns a typed RPC result.
+- Worker exec and live exec stream honor the provided timeout string as a worker-side context deadline when present.
+- Implemented worker-side file read/write/list/delete/move/chmod/stat/glob handling in the inbound RPC server.
+- Implemented worker-side console log handling in the inbound RPC server.
+- Added typed `RPCClient.Exec` support for control-plane calls.
+- Added typed `RPCClient.ExecStream` support for control-plane calls.
+- Added typed `RPCClient.ExecStreamLive` support for live NDJSON control-plane calls.
+- Added typed file RPC client helpers for control-plane calls.
+- Added typed logs RPC client support for control-plane calls.
+
+### Control Plane Routing
+
+- `Manager.Exec` now detects remote-owned sandboxes and routes non-streaming exec to the owning worker RPC endpoint.
+- `Manager.ExecStream` now detects remote-owned sandboxes and routes live exec streams to the owning worker RPC endpoint.
+- File APIs now detect remote-owned sandboxes and route through the owning worker RPC endpoint.
+- Console logs now detect remote-owned sandboxes and route through the owning worker RPC endpoint.
+- Remote-owned sandboxes now return the owning worker's advertised preview domain when present.
+- Startup reconciliation now applies a remote worker ownership policy:
+ - fresh draining workers keep existing sandbox ownership and remain unavailable for new placement.
+ - stale, offline, or missing worker ownership marks non-expired sandboxes `unhealthy`.
+ - expired remote-owned sandboxes become `expired` and release their durable lease.
+- Remote exec uses persisted `worker_id` and provider `runtime_id` instead of local provider state.
+- Remote file APIs use persisted `worker_id` and provider `runtime_id` instead of local provider state.
+- Remote logs use persisted `worker_id` and provider `runtime_id` instead of local provider state.
+- Remote preview URLs use persisted worker ownership plus worker heartbeat capacity.
+- Remote exec keeps the existing event, audit, metrics, timeout, and exec-log behavior.
+- Remote exec stream keeps the existing manager channel API while forwarding worker NDJSON chunks as they arrive.
+- Remote sandboxes no longer inherit pool-mode default workdir or file path scoping just because their provider runtime ID is stored in `VMID`.
+
+## Code Areas
+
+- `internal/workerproto/protocol.go`: `worker.exec`, `worker.exec_stream`, file RPC, and logs RPC contract/result types.
+- `internal/worker/rpc.go`: worker-side exec, exec-stream, file, and logs RPC handlers.
+- `internal/worker/rpc_client.go`: typed exec, live exec-stream, file, and logs RPC client methods.
+- `internal/orchestrator/manager.go`: remote-owned sandbox exec, exec-stream, file, and logs routing.
+- `internal/orchestrator/scheduler.go`: expired sandbox ownership exclusion from placement capacity.
+- `cmd/stacyvm/cmd_worker.go`: worker preview domain capacity advertisement and Docker preview-domain wiring.
+- `internal/config/config.go`: worker preview domain configuration.
+- `internal/worker/rpc_test.go`: worker exec and exec-stream RPC handler coverage.
+- `internal/worker/rpc_client_test.go`: typed exec, exec-stream, file, and logs client coverage.
+- `internal/orchestrator/manager_test.go`: control-plane remote exec, exec-stream, file, and logs routing coverage.
+
+## Verification
+
+- `go test ./internal/workerproto ./internal/worker ./internal/orchestrator`
+- `go test ./...`
+- `git diff --check`
+
+## Remaining Direction
+
+- Real stateful runtime migration is still provider-dependent and should be implemented per provider through snapshot or migration capabilities rather than simulated by the control plane.
diff --git a/docs/releases/phase-13-cluster-store-and-worker-identity.md b/docs/releases/phase-13-cluster-store-and-worker-identity.md
new file mode 100644
index 0000000..e8357e4
--- /dev/null
+++ b/docs/releases/phase-13-cluster-store-and-worker-identity.md
@@ -0,0 +1,79 @@
+# Phase 13 Cluster Store And Worker Identity Release Notes
+
+Date: 2026-05-09
+Branch: `phase-13-cluster-store-and-worker-identity`
+
+## Summary
+
+Phase 13 starts the enterprise multi-worker production track after Phase 12 completed remote sandbox I/O routing. The checkpoints make persistence explicitly driver-based, add a reusable store contract harness, and link a Postgres-backed store path while keeping SQLite as the default supported store.
+
+## What Changed
+
+### Store Factory
+
+- Added `store.Open` with explicit `sqlite` and `postgres` driver handling.
+- SQLite remains the default and continues to use the existing `NewSQLiteStore` implementation.
+- Postgres now opens through `NewPostgresStore` with the pgx stdlib driver.
+- Added factory tests for default SQLite opening and missing database configuration.
+
+### Postgres Migration Foundation
+
+- Added Postgres-native migration definitions for the current store schema.
+- Introduced shared migration metadata so SQLite and Postgres migration versions can be compared directly.
+- Added tests that verify Postgres migrations track SQLite migration versions.
+- Added tests that verify Postgres migrations cover all store tables and avoid SQLite-only dialect tokens.
+- Added a Postgres store migrator that applies those migrations through `store.Open`.
+- Added live Postgres migration rehearsal coverage for idempotent migration application.
+
+### Store Contract Harness
+
+- Added a reusable store contract test harness in `internal/store`.
+- Wired the contract harness to SQLite as the first concrete driver.
+- Wired the contract harness to Postgres when `STACYVM_POSTGRES_TEST_DSN` is set.
+- Covered sandbox lifecycle semantics, including soft-delete behavior and active-list filtering.
+- Covered worker registry behavior for save, update, list, get, and delete.
+- Covered lease acquisition, renewal, conflict detection, release ownership, and expired lease takeover.
+- Covered live Postgres lease acquisition and expired-takeover races across multiple store connections.
+- Covered exec logs, admin audit logs, operation audit logs, owner quotas, and provider configs.
+- Covered templates, environment specs, environment builds, build artifacts, and registry connections.
+
+### Configuration
+
+- Added `database.driver`.
+- Added `database.dsn`.
+- Kept `database.path` for SQLite.
+- Config validation now rejects unsupported database drivers.
+- Config validation now requires `database.dsn` when `database.driver` is `postgres`.
+
+### CLI And Diagnostics
+
+- `stacyvm serve` now opens the store through the driver-based factory.
+- `stacyvm config lint` accepts Postgres configs with a valid DSN.
+- `stacyvm config lint --production` now distinguishes shared staging worker tokens from production per-worker credentials.
+
+### Worker Identity
+
+- Added `auth.worker_tokens` as a map of `worker_id: token`.
+- Kept `auth.worker_token` for shared-token staging compatibility.
+- Per-worker credentials override the shared worker token for that worker ID.
+- Worker identities now receive explicit scopes for heartbeat, spawn, destroy, status, exec, files, logs, and leases.
+- Worker lease renewal now requires the dedicated `worker:lease` scope.
+
+### Cluster Conformance
+
+- Added `scripts/ci-cluster-conformance.sh`.
+- Added the `cluster-conformance` GitHub Actions job.
+- Added the `remote-worker-postgres-smoke` GitHub Actions job.
+- Added `docs/cluster-conformance.md` with store, worker identity, runtime, and promotion gates.
+- CI now verifies the SQLite store contract, live Postgres store contract, live Postgres lease concurrency, live Postgres migration rehearsal, worker identity tests, production-aligned cluster config linting, and a Postgres-backed remote worker smoke.
+
+## Verification
+
+- `go test ./internal/store`
+- `go test ./internal/api/middleware ./internal/api ./internal/config ./cmd/stacyvm`
+- `scripts/ci-cluster-conformance.sh`
+
+## Next Phase 13 Direction
+
+- Continue worker identity hardening toward signed tokens or mTLS transport enforcement.
+- Extend multi-worker conformance beyond the mock-provider smoke into Docker/gVisor/Kata/Firecracker certified hosts.
diff --git a/docs/releases/phase-14-worker-identity-hardening.md b/docs/releases/phase-14-worker-identity-hardening.md
new file mode 100644
index 0000000..83eb51b
--- /dev/null
+++ b/docs/releases/phase-14-worker-identity-hardening.md
@@ -0,0 +1,158 @@
+# Phase 14 Worker Identity Hardening
+
+Phase 14 begins the worker identity hardening lane for production multi-worker StacyVM deployments. The goal of this slice is to move beyond static shared worker credentials while keeping the existing Phase 11-13 worker runtime compatible.
+
+## What Changed
+
+### Signed worker tokens
+
+- Added HMAC-SHA256 signed worker token support.
+- Added the `stacyvm-worker-v1..` token format.
+- Added signed token claims for:
+ - `worker_id`
+ - token ID through `jti`
+ - audience through `aud`
+ - optional worker `scopes`
+ - issued-at time through `iat`
+ - optional not-before time through `nbf`
+ - expiry time through `exp`
+- Enforced signed-token expiry before accepting worker requests.
+- Enforced signed-token not-before and issued-at validation with clock-skew tolerance.
+- Enforced a 15 minute max signed worker token lifetime when `iat` is present.
+- Enforced that signed `worker_id` must match the `X-Worker-ID` request header.
+- Enforced token audience separation between worker-to-control-plane routes and control-plane-to-worker RPC.
+- Added `auth.worker_revoked_token_ids` emergency revocation for signed worker token IDs.
+- Added worker token issuer `--format json`, `--token-id`, and `--not-before` options for incident-response runbooks.
+- Added `stacyvm worker token inspect ` to recover unverified signed-token metadata and `jti` values during incident response.
+- Added `stacyvm worker token verify ` to validate signed tokens against active and rotation keys, expected worker IDs, expected audiences, and revoked token IDs.
+- Added `stacyvm worker token rotation-plan` to print a no-secret signing-key rotation checklist, config sketch, and validation commands.
+- Added worker secret file flags for token issuance, token verification, and worker runtime startup so operators can use secret-mounted files instead of command-line or environment secrets.
+- Filtered signed-token scopes so tokens cannot grant user, API, or admin scopes.
+- Added `stacyvm worker token ` to issue signed worker tokens from the CLI.
+
+### Configuration
+
+- Added `auth.worker_signing_key`.
+- Added `auth.worker_token_file` and `auth.worker_signing_key_file` so production services can mount worker credentials from files.
+- Added `auth.worker_signing_keys` for old verification keys during rotation.
+- Kept `auth.worker_token` for shared-token staging compatibility.
+- Kept `auth.worker_tokens` for per-worker static token migration paths.
+- Updated `stacyvm config lint --production` so a strong `auth.worker_signing_key` satisfies production-aligned worker credential checks.
+- Added config lint warnings when revoked signed-token IDs are configured without signed worker-token verification.
+- Added config lint warnings for shared worker tokens left enabled beside signed worker tokens, duplicate rotation keys, and rotation keys that repeat the active signing key.
+- Config loading now rejects ambiguous inline/file worker secret pairs such as `auth.worker_signing_key` plus `auth.worker_signing_key_file`.
+- Config lint now reports whether worker token and signing-key values are file-backed or still configured inline.
+
+### Worker runtime
+
+- Added dynamic worker token generation for `stacyvm worker` heartbeat and lease-renewal calls.
+- When no static `--worker-token` or `auth.worker_token` is configured, a worker can derive short-lived signed control-plane tokens from `auth.worker_signing_key`.
+- Workers can read static worker tokens from `--worker-token-file` or signing keys from `--worker-signing-key-file`.
+- `stacyvm worker --worker-token-file` reloads the token file for each heartbeat and lease-renewal request, so a sidecar or external issuer can rotate short-lived signed tokens without restarting the worker process.
+- Worker RPC servers now accept signed control-plane-to-worker tokens.
+- Control planes can mint short-lived worker RPC tokens from `auth.worker_signing_key` when no shared `auth.worker_token` is configured.
+- Existing static token behavior is unchanged.
+
+### Rotation
+
+- New signed tokens are minted with `auth.worker_signing_key`.
+- Old tokens can continue verifying through `auth.worker_signing_keys` during a rotation window.
+- Operators can generate a concrete no-secret rollout checklist with `stacyvm worker token rotation-plan`.
+- The documented rotation sequence is:
+ - promote the new key into `auth.worker_signing_key`
+ - move the old key into `auth.worker_signing_keys`
+ - restart or reload workers
+ - wait for old token TTLs to expire
+ - remove the old key from `auth.worker_signing_keys`
+
+### Worker RPC mTLS
+
+- Added `worker.rpc_tls` configuration for enterprise worker RPC networks.
+- Added TLS server support for `stacyvm worker --listen`.
+- Added mTLS client support for control-plane calls to worker RPC.
+- Added worker RPC mTLS conformance that completes a real RPC call with generated CA, server, and client certificates.
+- Added config lint checks for worker server certificates, client CA verification, control-plane client certificates, worker CA verification, and unsafe `insecure_skip_verify`.
+- Documented how worker-side and control-plane-side certificate settings are used.
+
+### Documentation
+
+- Updated the README configuration example.
+- Updated the worker RPC contract with signed-token semantics.
+- Documented the issue, inspect, verify, and revoke operator runbook for worker tokens.
+- Updated the API docs for worker heartbeat and lease-renewal headers.
+- Updated the cluster conformance matrix to mark signed worker tokens as the public/enterprise worker identity path.
+- Added `scripts/certify-worker-identity.sh` for host-level signed-token lifecycle signoff with text, JSON, or Markdown report output.
+- Updated the public support matrix to reflect multi-worker signed identity, worker RPC routing, mTLS wiring, and worker identity certification evidence.
+- Updated production readiness notes to reflect signed worker tokens, worker identity certification reporting, worker RPC mTLS wiring, and the remaining target-network/runtime signoff work.
+- Added cluster conformance coverage for signed-token migration lint warnings.
+- Added cluster conformance coverage for worker identity certification report generation.
+- Updated the threat model and remote-worker staging guide so worker impersonation controls and staging guidance reflect the implemented signed-token and mTLS paths.
+- Documented reloadable worker token files as the handoff path for external worker-token issuers.
+- Updated deployment and configuration docs for secret-mounted worker token and signing-key files.
+- Documented production lint visibility for file-backed worker credential sources.
+
+## Code Areas Changed
+
+- `internal/api/middleware`: signed token creation, verification, worker scope filtering, and worker auth config.
+- `internal/api`: server worker auth wiring for `auth.worker_signing_key`.
+- `internal/config`: config schema and defaults for primary and rotation worker signing keys.
+- `internal/worker`: dynamic token callback support for worker heartbeat and lease renewal, plus worker RPC TLS client/server helpers.
+- `internal/orchestrator`: worker RPC client TLS wiring for remote worker calls.
+- `cmd/stacyvm`: `serve`, `worker`, `worker token`, and `config lint` wiring.
+- `scripts`: worker identity certification smoke.
+- `docs`: worker identity and conformance documentation.
+
+## Compatibility
+
+The new signed-token path is additive:
+
+- Existing `auth.worker_token` deployments continue to work.
+- Existing inline worker secrets can be moved to `auth.worker_token_file` or `auth.worker_signing_key_file` without changing runtime behavior.
+- Existing `auth.worker_tokens.` deployments continue to work.
+- Signed tokens can be introduced gradually by setting `auth.worker_signing_key`.
+- Workers can also consume externally issued short-lived tokens through reloadable `--worker-token-file` secrets.
+- Key rotation can be introduced gradually by adding old keys to `auth.worker_signing_keys`.
+- Worker RPC mTLS is opt-in; local HTTP worker RPC remains available for local development and internal staging.
+
+## Phase 14 Enterprise Governance (implemented)
+
+### OIDC/SSO and RBAC
+- Added RS256 JWT Bearer token validation with configurable OIDC issuer, JWKS URL, and static public key.
+- Added RBAC roles: `viewer` (read-only), `operator` (sandbox lifecycle), `tenant_admin` (per-tenant admin) beyond existing `api`/`admin`.
+- Added OIDC group-to-role mapping via `auth.oidc_admin_groups`, `auth.oidc_operator_groups`, `auth.oidc_viewer_groups`.
+- Added scopes: `read:*`, `operator:*`, `tenant:admin`.
+- API key auth and OIDC Bearer auth coexist; existing API key deployments are unaffected.
+
+### Tenant/project model
+- Added `tenants`, `tenant_members`, and `policies` tables (migration 11).
+- Added `tenant_id` to sandboxes, admin audit logs, and operation audit logs.
+- Admin routes: tenant CRUD, member RBAC (viewer/operator/admin per tenant), per-tenant audit export, per-tenant policy management.
+
+### Policy controls
+- Added `policies` store with resource_type (image/provider/network), effect (allow/deny), glob pattern, and priority.
+- Added `PolicyEnforcer` middleware that checks spawn request fields against tenant and global policies.
+
+### Centralized worker token issuer
+- Added `POST /api/v1/admin/worker-tokens` to mint signed worker tokens with configurable TTL, audience, and scopes.
+- Workers no longer need direct access to `auth.worker_signing_key` in hardened deployments.
+
+### Postgres operations
+- Added `stacyvm db pg-backup