diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11b2c45..abc5b8fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: "pnpm" - name: Install dependencies @@ -65,7 +65,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: "pnpm" - name: Install dependencies @@ -90,7 +90,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24" cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 528c42b6..417cc6d4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -16,16 +16,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Resolve tool versions (bundled OpenCode and microsandbox for reproducibility) + - name: Set tool versions (pinned for reproducibility) id: versions run: | - UV_VERSION=$(git ls-remote --tags --sort=-v:refname https://github.com/astral-sh/uv.git 'refs/tags/[0-9]*' | head -1 | sed 's/.*refs\/tags\///') - OPENCODE_VERSION=1.18.16 - MICROSANDBOX_VERSION=0.6.15 + UV_VERSION=0.12.7 + OPENCODE_VERSION=1.18.31 + MICROSANDBOX_VERSION=0.7.2 + PLAYWRIGHT_VERSION=1.63.0 echo "uv=${UV_VERSION}" >> $GITHUB_OUTPUT echo "opencode=${OPENCODE_VERSION}" >> $GITHUB_OUTPUT echo "microsandbox=${MICROSANDBOX_VERSION}" >> $GITHUB_OUTPUT - echo "Versions: uv=${UV_VERSION} (latest), opencode=${OPENCODE_VERSION} (bundled default), microsandbox=${MICROSANDBOX_VERSION} (pinned)" + echo "playwright=${PLAYWRIGHT_VERSION}" >> $GITHUB_OUTPUT + echo "Versions: uv=${UV_VERSION} (pinned), opencode=${OPENCODE_VERSION} (bundled default), microsandbox=${MICROSANDBOX_VERSION} (pinned), playwright=${PLAYWRIGHT_VERSION} (pinned)" - name: Docker meta id: meta @@ -63,6 +65,7 @@ jobs: UV_VERSION=${{ steps.versions.outputs.uv }} OPENCODE_VERSION=${{ steps.versions.outputs.opencode }} MICROSANDBOX_VERSION=${{ steps.versions.outputs.microsandbox }} + PLAYWRIGHT_VERSION=${{ steps.versions.outputs.playwright }} cache-from: type=gha cache-to: type=gha,mode=max target: runner diff --git a/.github/workflows/publish-ocm-cli.yml b/.github/workflows/publish-ocm-cli.yml index 53b38563..bffbb936 100644 --- a/.github/workflows/publish-ocm-cli.yml +++ b/.github/workflows/publish-ocm-cli.yml @@ -30,7 +30,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'pnpm' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml new file mode 100644 index 00000000..bd649af6 --- /dev/null +++ b/.github/workflows/sandbox-image.yml @@ -0,0 +1,158 @@ +name: Sandbox Image + +on: + workflow_dispatch: + inputs: + tag: + description: Extra tag to publish alongside the package version and commit sha + default: latest + required: false + pull_request: + paths: + - Dockerfile.sandbox + - scripts/sandbox-dockerd-start.sh + - .github/workflows/sandbox-image.yml + +env: + IMAGE: docker.io/cstechdev/ocm-sandbox + +permissions: + contents: read + +jobs: + build: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: true + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Platform slug + id: platform + run: echo "slug=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT" + env: + PLATFORM: ${{ matrix.platform }} + + - name: Login to Docker Hub + if: github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and verify native image + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.sandbox + platforms: ${{ matrix.platform }} + outputs: ${{ github.event_name == 'workflow_dispatch' && format('type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE) || 'type=cacheonly' }} + cache-from: type=gha,scope=sandbox-${{ steps.platform.outputs.slug }} + cache-to: type=gha,scope=sandbox-${{ steps.platform.outputs.slug }},mode=max + + - name: Export digest + if: github.event_name == 'workflow_dispatch' + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.slug }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Resolve tags + id: tags + run: | + version="$(jq -er .version package.json)" + tags="sha-${GITHUB_SHA::12} $version" + for tag in "$version" "$EXTRA_TAG"; do + if [ -n "$tag" ] && [[ ! "$tag" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then + printf 'Invalid image tag: %s\n' "$tag" >&2 + exit 1 + fi + done + if [ -n "$EXTRA_TAG" ]; then + tags="$tags $EXTRA_TAG" + fi + echo "tags=$tags" >> "$GITHUB_OUTPUT" + env: + EXTRA_TAG: ${{ inputs.tag }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + read -ra tags <<< "$TAGS" + args=() + for tag in "${tags[@]}"; do + args+=(-t "$IMAGE:$tag") + done + digests=(*) + test "${#digests[@]}" -eq 2 + for digest in "${digests[@]}"; do + [[ "$digest" =~ ^[a-f0-9]{64}$ ]] + args+=("$IMAGE@sha256:$digest") + done + docker buildx imagetools create "${args[@]}" + env: + TAGS: ${{ steps.tags.outputs.tags }} + + - name: Report index digest + run: | + digest="$(docker buildx imagetools inspect "$IMAGE:sha-${GITHUB_SHA::12}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + { + echo "## Sandbox image" + echo + echo "Tags: $TAGS" + echo + echo "Pin \`SANDBOX_IMAGE\` to:" + echo + echo '```' + echo "$IMAGE@$digest" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "$IMAGE@$digest" + env: + TAGS: ${{ steps.tags.outputs.tags }} diff --git a/Dockerfile b/Dockerfile index d30d323d..e6183315 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.13.0-trixie AS base +FROM node:24.21.0-trixie AS base RUN apt-get update && apt-get install -y \ git \ @@ -26,7 +26,7 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | d && apt-get update && apt-get install -y gh \ && rm -rf /var/lib/apt/lists/* -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@10.28.1 --activate RUN curl -fsSL https://bun.sh/install | bash && \ mv /root/.bun /opt/bun && \ @@ -58,18 +58,23 @@ RUN pnpm --filter frontend build FROM base AS runner -ARG UV_VERSION=latest -ARG OPENCODE_VERSION=1.18.16 -ARG MICROSANDBOX_VERSION=0.6.15 +# uv 0.12.8 and later segfault under qemu-user x86_64 emulation, which is how +# an arm64 host builds the amd64 platform; 0.12.7 is the newest verified-good +# release, so re-verify a bump there before moving it. +ARG UV_VERSION=0.12.7 +ARG OPENCODE_VERSION=1.18.31 +ARG MICROSANDBOX_VERSION=0.7.2 +ARG PLAYWRIGHT_VERSION=1.63.0 # Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode # install without invalidating the rest of the build cache. ARG TOOLS_CACHEBUST=0 RUN echo "Installing uv=${UV_VERSION} opencode=${OPENCODE_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \ - curl -LsSf https://astral.sh/uv/install.sh | UV_NO_MODIFY_PATH=1 sh && \ + curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | UV_NO_MODIFY_PATH=1 sh && \ mv /root/.local/bin/uv /usr/local/bin/uv && \ mv /root/.local/bin/uvx /usr/local/bin/uvx && \ chmod +x /usr/local/bin/uv /usr/local/bin/uvx && \ + test "$(uv --version | cut -d' ' -f2)" = "${UV_VERSION}" && \ echo "Downloading opencode ${OPENCODE_VERSION}..." && \ OC_ARCH=$(uname -m) && \ if [ "$OC_ARCH" = "aarch64" ]; then OC_ARCH="arm64"; fi && \ @@ -116,6 +121,10 @@ RUN echo "Installing microsandbox=${MICROSANDBOX_VERSION} (cachebust=${TOOLS_CAC chmod -R a+rX /opt/microsandbox && \ msb --version +RUN echo "Installing Chromium runtime libraries for playwright=${PLAYWRIGHT_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \ + npx --yes "playwright@${PLAYWRIGHT_VERSION}" install-deps chromium && \ + rm -rf /var/lib/apt/lists/* /root/.npm + ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=5003 diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index b7b12fd2..1aea33e0 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,16 +1,17 @@ # Guest image for the agent sandbox microVM (SANDBOX_IMAGE). # -# node:24 is buildpack-deps based, so gcc/g++/make/ld/pkg-config, glib-2.0, -# git, ssh, python3, curl and wget are already present, but it ships no network -# diagnostics at all. This image adds the rest of the Manager image toolchain -# (pnpm, bun, uv, jq), the GitHub CLI, a Playwright-managed Chromium, the -# `python` alias, and the network tooling agents reach for. -FROM node:24 +FROM node:24.21.0-trixie -ARG PLAYWRIGHT_VERSION=1.56.0 -# uv@latest (0.12.9) segfaults under qemu-user amd64 emulation, which is how -# cross-arch builds execute it; pin a known-good release and verify a bump. +ARG PLAYWRIGHT_VERSION=1.63.0 +# uv 0.12.8 and later segfault under qemu-user x86_64 emulation, which is how +# an arm64 host builds the amd64 platform; 0.12.7 is the newest verified-good +# release, so re-verify a bump there before moving it. ARG UV_VERSION=0.12.7 +ARG PNPM_VERSION=11.24.0 +ARG BUN_VERSION=1.4.2 +ARG FALLOW_VERSION=3.27.0 +ARG RUST_VERSION=1.98.1 +ARG GO_VERSION=1.27.1 # Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh tool install # without invalidating the rest of the build cache. ARG TOOLS_CACHEBUST=0 @@ -24,23 +25,23 @@ ENV NODE_PATH=/usr/local/lib/node_modules # msb exec runs commands as a numeric host uid with no /etc/passwd entry, so # the guest defaults to HOME=/ and every tool that writes per-user state (uv # and pip caches, git config, gh config, the pnpm store) fails with EACCES. -# Point HOME at a sticky world-writable directory and give corepack a shared -# prewarmed cache so the preinstalled pnpm resolves without network or root. # PNPM_CONFIG_STORE_DIR pins the pnpm store to a container-internal path: # pnpm 11 no longer reads npm_config_* env vars, and an unpinned store falls # back into the bind-mounted project directory, where it floods the host file # watcher and gets committed. ENV HOME=/home/ocm-agent -ENV COREPACK_HOME=/usr/local/share/corepack ENV UV_TOOL_BIN_DIR=/opt/agent-tools/bin ENV PNPM_HOME=/opt/agent-tools ENV PNPM_CONFIG_STORE_DIR=/home/ocm-agent/.local/share/pnpm/store -ENV PATH=/opt/agent-tools/bin:$PATH +ENV RUSTUP_HOME=/usr/local/rustup +ENV CARGO_HOME=/usr/local/cargo +ENV GOBIN=/opt/agent-tools/bin +ENV PATH=/opt/agent-tools:/opt/agent-tools/bin:/usr/local/cargo/bin:/usr/local/go/bin:$PATH RUN echo "Installing gh and CLI tooling (cachebust=${TOOLS_CACHEBUST})" && \ apt-get update && \ apt-get install -y --no-install-recommends \ - ca-certificates curl gh jq less ripgrep sudo tree file procps \ + ca-certificates curl jq less ripgrep sudo tree file procps \ python3-pip python3-venv python-is-python3 \ iproute2 iputils-ping bind9-dnsutils bind9-host net-tools \ netcat-openbsd traceroute lsof rsync && \ @@ -59,24 +60,30 @@ RUN echo "Installing gh and CLI tooling (cachebust=${TOOLS_CACHEBUST})" && \ visudo -c && \ gh --version -# corepack prepare runs as root and creates COREPACK_HOME/v1 and -# lastKnownGood.json beneath the sticky top-level directory, so those must be -# opened up afterwards or a project pinning any other packageManager version -# fails with "Failed to create cache directory" for the exec user. -RUN echo "Installing pnpm, bun and uv (cachebust=${TOOLS_CACHEBUST})" && \ - mkdir -p /home/ocm-agent /opt/agent-tools/bin /usr/local/share/corepack && \ - chmod 1777 /home/ocm-agent /opt/agent-tools /opt/agent-tools/bin /usr/local/share/corepack && \ - corepack enable && corepack prepare pnpm@latest --activate && \ - find /usr/local/share/corepack -type d -exec chmod 1777 {} + && \ - chmod a+rw /usr/local/share/corepack/lastKnownGood.json && \ - curl -fsSL https://bun.sh/install | BUN_INSTALL=/opt/bun bash && \ +RUN echo "Installing pnpm=${PNPM_VERSION}, bun=${BUN_VERSION}, uv=${UV_VERSION}, fallow=${FALLOW_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \ + mkdir -p /home/ocm-agent /opt/agent-tools/bin && \ + chmod 1777 /home/ocm-agent /opt/agent-tools /opt/agent-tools/bin && \ + npm install -g "pnpm@${PNPM_VERSION}" "fallow@${FALLOW_VERSION}" && \ + test "$(pnpm --version)" = "${PNPM_VERSION}" && \ + curl -fsSL https://bun.sh/install | BUN_INSTALL=/opt/bun bash -s "bun-v${BUN_VERSION}" && \ chmod -R a+rX /opt/bun && \ ln -sf /opt/bun/bin/bun /opt/bun/bin/bunx && \ ln -s /opt/bun/bin/bun /usr/local/bin/bun && \ ln -s /opt/bun/bin/bun /usr/local/bin/bunx && \ + test "$(bun --version)" = "${BUN_VERSION}" && \ curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh && \ + test "$(uv --version | cut -d' ' -f2)" = "${UV_VERSION}" && \ rm -rf /root/.npm /root/.cache/uv /home/ocm-agent/.cache && \ - pnpm --version && bun --version && uv --version + pnpm --version && bun --version && uv --version && fallow --version | grep -x "fallow ${FALLOW_VERSION}" >/dev/null + +RUN echo "Installing rust=${RUST_VERSION} and go=${GO_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \ + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | \ + HOME=/root sh -s -- -y --no-modify-path --profile minimal --default-toolchain "${RUST_VERSION}" && \ + test "$(rustc --version | cut -d' ' -f2)" = "${RUST_VERSION}" && \ + chmod -R a+w "${RUSTUP_HOME}" "${CARGO_HOME}" && \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar -xzC /usr/local && \ + test "$(go version | cut -d' ' -f3)" = "go${GO_VERSION}" && \ + cargo --version && go version RUN echo "Installing playwright=${PLAYWRIGHT_VERSION} chromium (cachebust=${TOOLS_CACHEBUST})" && \ npm install -g "playwright@${PLAYWRIGHT_VERSION}" && \ @@ -85,9 +92,6 @@ RUN echo "Installing playwright=${PLAYWRIGHT_VERSION} chromium (cachebust=${TOOL rm -rf /var/lib/apt/lists/* /root/.npm && \ npx --yes "playwright@${PLAYWRIGHT_VERSION}" --version -# Declared after the Playwright layer on purpose: npm_config_prefix must not -# affect the root-run `npm install -g playwright`, which has to land in -# /usr/local/lib/node_modules for the NODE_PATH fallback above to work. ENV npm_config_prefix=/opt/agent-tools # The runtime exec user is a numeric host uid with no /etc/passwd entry, so the @@ -100,8 +104,23 @@ RUN echo "Verifying guest toolchain as an unknown uid (cachebust=${TOOLS_CACHEBU rm -rf /home/ocm-agent && \ mkdir -p /home/ocm-agent && \ chmod 1777 /home/ocm-agent && \ - setpriv --reuid=4242 --regid=4242 --clear-groups sh -c "set -e; pnpm --version; mkdir /tmp/pm-pin-check && cd /tmp/pm-pin-check && printf '{\"name\":\"pm-pin-check\",\"packageManager\":\"pnpm@10.12.1\"}' > package.json && pnpm --version | grep -qx 10.12.1 && cd / && rm -rf /tmp/pm-pin-check; bun --version; bunx --version; uv --version; uvx --version; playwright --version; gh --version; jq --version; rg --version; npm install -g --silent cowsay && cowsay -t verify-ok | head -1; git config --global user.email verify@ocm.local; uv venv /tmp/uv-check >/dev/null; uv pip install --python /tmp/uv-check/bin/python idna >/dev/null; pip3 install --user --force-reinstall --no-deps --quiet idna; rm -rf /tmp/uv-check; for tool in python python3 git curl wget ssh rsync lsof ip ss netstat ping dig host nslookup nc traceroute; do command -v \"\$tool\" >/dev/null || { echo \"missing guest tool: \$tool\"; exit 1; }; done; python --version" && \ - setpriv --reuid=1000 --regid=1000 --clear-groups sh -c "set -e; sudo -n apt-get update -qq; sudo -n apt-get install -y -qq --no-install-recommends bc; bc --version | head -1" && \ - rm -rf /home/ocm-agent /opt/agent-tools /var/lib/apt/lists/* /usr/local/share/corepack/v1/pnpm/10.12.1 && \ + setpriv --reuid=4242 --regid=4242 --clear-groups bash -euo pipefail -c 'pnpm --version; pm_dir=$(mktemp -d); cd "$pm_dir"; printf "%s" "{\"name\":\"pm-pin-check\",\"packageManager\":\"pnpm@10.12.1\"}" > package.json; pnpm --version | grep -x 10.12.1 >/dev/null; cd /; rm -rf "$pm_dir"; bun --version; bunx --version; uv --version; uvx --version; fallow --version | grep -x "fallow ${FALLOW_VERSION}" >/dev/null; playwright --version; gh --version; jq --version; rg --version; npm install -g --silent cowsay; cowsay -t verify-ok | grep verify-ok >/dev/null; git config --global user.email verify@ocm.local; uv venv /tmp/uv-check >/dev/null; uv pip install --python /tmp/uv-check/bin/python idna >/dev/null; pip3 install --user --force-reinstall --no-deps --quiet idna; rm -rf /tmp/uv-check; for tool in python python3 git curl wget ssh rsync lsof ip ss netstat ping dig host nslookup nc traceroute; do if ! command -v "$tool" >/dev/null; then echo "missing guest tool: $tool"; exit 1; fi; done; python --version' && \ + setpriv --reuid=4242 --regid=4242 --clear-groups bash -euo pipefail -c 'rs_dir=$(mktemp -d); cargo new --quiet --vcs none "$rs_dir/rs-check"; cargo build --quiet --manifest-path "$rs_dir/rs-check/Cargo.toml"; "$rs_dir/rs-check/target/debug/rs-check" | grep -x "Hello, world!" >/dev/null; rm -rf "$rs_dir"; go_dir=$(mktemp -d); cd "$go_dir"; go mod init go-check >/dev/null 2>&1; printf "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"go-ok\") }\n" > main.go; go run . | grep -x go-ok >/dev/null; cd /; rm -rf "$go_dir"' && \ + setpriv --reuid=1000 --regid=1000 --clear-groups bash -euo pipefail -c 'sudo -n apt-get update -qq; sudo -n apt-get install -y -qq --no-install-recommends bc; bc --version' && \ + rm -rf /home/ocm-agent /opt/agent-tools /var/lib/apt/lists/* "${CARGO_HOME}/registry" && \ mkdir -p /home/ocm-agent /opt/agent-tools/bin && \ chmod 1777 /home/ocm-agent /opt/agent-tools /opt/agent-tools/bin + +RUN install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \ + 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/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list && \ + apt-get update && \ + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \ + rm -rf /var/lib/apt/lists/* && \ + docker --version && \ + docker buildx version && \ + docker compose version + +COPY --chmod=0755 scripts/sandbox-dockerd-start.sh /usr/local/bin/ocm-dockerd-start diff --git a/backend/src/services/sandbox/command.ts b/backend/src/services/sandbox/command.ts index be3e16dd..4913c2c4 100644 --- a/backend/src/services/sandbox/command.ts +++ b/backend/src/services/sandbox/command.ts @@ -14,6 +14,10 @@ import { export const WORKSPACE_SANDBOX_NAME = 'ocm-workspace' +export const SANDBOX_DOCKER_DATA_VOLUME_NAME = 'ocm-workspace-docker-data' +export const SANDBOX_DOCKER_DATA_GUEST_PATH = '/var/lib/docker' +export const SANDBOX_DOCKER_DATA_SIZE = '20G' + export const SANDBOX_UNAVAILABLE_PREFIX = 'Sandbox enforcement is on but the sandbox is unavailable: ' const SANDBOX_PLAN_REQUEST_MARGIN_MS = 30000 @@ -171,7 +175,18 @@ export function quoteForShell(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } +function buildSandboxDockerDataMountArg(): string { + return `${SANDBOX_DOCKER_DATA_VOLUME_NAME}:${SANDBOX_DOCKER_DATA_GUEST_PATH}:kind=disk,size=${SANDBOX_DOCKER_DATA_SIZE}` +} + +function parseSandboxNamedMountSpec(spec: string): { name: string; guest: string } { + const [name = '', guest = ''] = spec.split(':') + return { name, guest } +} + export function buildSandboxCreateArgs(): string[] { + const tmpfsSizeMib = resolveSandboxRuntimeTmpfsSizeMib(parseMemoryMib(ENV.SANDBOX.MEMORY)) + if (tmpfsSizeMib === null) throw new Error('sandbox memory must be positive to size the runtime tmpfs') return [ 'run', '-d', @@ -190,6 +205,10 @@ export function buildSandboxCreateArgs(): string[] { '-u', resolveSandboxExecUser(), ...sandboxMountRoots().flatMap((root) => ['--mount-dir', `${root}:${root}`]), + '--mount-named', + buildSandboxDockerDataMountArg(), + '--tmpfs', + `/tmp:${tmpfsSizeMib}M`, '-w', getReposPath(), '--entrypoint', @@ -353,6 +372,7 @@ function parseSandboxCreateArgs(args: string[]): { cpus: number user: string mountDirs: string[] + namedMounts: Array<{ name: string; guest: string }> workdir: string entrypoint: string[] shell: string @@ -361,6 +381,7 @@ function parseSandboxCreateArgs(args: string[]): { } { const labels: Record = {} const mountDirs: string[] = [] + const namedMounts: Array<{ name: string; guest: string }> = [] let name = '' let memory = '' let cpus = 0 @@ -390,8 +411,10 @@ function parseSandboxCreateArgs(args: string[]): { case '-m': memory = value ?? ''; i += 1; break case '-c': cpus = Number(value); i += 1; break case '--net': i += 1; break + case '--tmpfs': i += 1; break case '-u': user = value ?? ''; i += 1; break case '--mount-dir': if (value !== undefined) mountDirs.push(value); i += 1; break + case '--mount-named': if (value !== undefined) namedMounts.push(parseSandboxNamedMountSpec(value)); i += 1; break case '-w': workdir = value ?? ''; i += 1; break case '--entrypoint': if (value !== undefined) entrypoint = [value]; i += 1; break case '--shell': if (value !== undefined) shell = value; i += 1; break @@ -400,7 +423,7 @@ function parseSandboxCreateArgs(args: string[]): { if (image === '' && !token.startsWith('-')) image = token } } - return { name, labels, memory, cpus, user, mountDirs, workdir, entrypoint, shell, image, cmd } + return { name, labels, memory, cpus, user, mountDirs, namedMounts, workdir, entrypoint, shell, image, cmd } } export function buildCanonicalSandboxSpec(): Record { @@ -421,6 +444,15 @@ export function buildCanonicalSandboxSpec(): Record { quota_mib: null, } }) + const namedMounts = args.namedMounts.map((mount) => ({ + type: 'Named', + name: mount.name, + guest: mount.guest, + options: { readonly: false, noexec: false, nosuid: false, nodev: false }, + stat_virtualization: 'strict', + host_permissions: 'private', + follow_root_symlinks: false, + })) return { name: args.name, image: { @@ -449,7 +481,7 @@ export function buildCanonicalSandboxSpec(): Record { env: [], labels: args.labels, rlimits: [], - mounts: bindMounts, + mounts: [...bindMounts, ...namedMounts], patches: [], network: { enabled: true, ports: [] }, init: null, @@ -528,7 +560,9 @@ export function buildSandboxProvisionArgs(): string[] { `echo 'ocm-exec:x:${uid}:${gid}:Manager sandbox exec user:/home/ocm-agent:/bin/sh' >> /etc/passwd; ` + `grep -q '^ocm-exec:' /etc/shadow || echo 'ocm-exec:*:19000:0:99999:7:::' >> /etc/shadow; ` + `}; ` + - `getent passwd ${uid} >/dev/null || exit 1`, + `getent passwd ${uid} >/dev/null || exit 1; ` + + `if getent group docker >/dev/null 2>&1; then ` + + `usermod -aG docker "$(getent passwd ${uid} | cut -d: -f1)" || exit 1; ` + + `fi`, ] } - diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index e763607c..8b976404 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -228,6 +228,7 @@ function emptyArrayMismatch(value: unknown, path: string): string | null { type ParsedSandboxMount = | { kind: 'bind'; host: string; guest: string; readonly: boolean } | { kind: 'tmpfs'; guest: string; readonly: boolean } + | { kind: 'named'; name: string; guest: string; readonly: boolean } | { kind: 'other' } function parseInspectMount(mount: unknown): ParsedSandboxMount | null { @@ -241,6 +242,10 @@ function parseInspectMount(mount: unknown): ParsedSandboxMount | null { if (typeof mount.guest !== 'string') return null return { kind: 'tmpfs', guest: mount.guest, readonly } } + if (mount.type === 'Named') { + if (typeof mount.name !== 'string' || typeof mount.guest !== 'string') return null + return { kind: 'named', name: mount.name, guest: mount.guest, readonly } + } return { kind: 'other' } } @@ -282,6 +287,7 @@ async function attestWorkspaceSandboxConfig(config: unknown): Promise() const expectedRealRoots = new Set() + const expectedNamedMounts: Array<{ name: string; guest: string }> = [] for (const rawMount of canonicalMounts) { const mount = parseInspectMount(rawMount) if (mount?.kind === 'bind') { @@ -292,6 +298,8 @@ async function attestWorkspaceSandboxConfig(config: unknown): Promise() let runtimeTmpfsSeen = false + let dockerDataVolumeSeen = false for (let mountIndex = 0; mountIndex < mounts.length; mountIndex++) { const rawMount = mounts[mountIndex] const mount = parseInspectMount(rawMount) @@ -385,6 +394,47 @@ async function attestWorkspaceSandboxConfig(config: unknown): Promise { const dockerfile = read(dockerfilePath) it('declares MICROSANDBOX_VERSION next to the other tool args', () => { - expect(dockerfile).toMatch(/ARG MICROSANDBOX_VERSION=0\.6\.15/) + expect(dockerfile).toMatch(/ARG MICROSANDBOX_VERSION=0\.7\.2/) }) it('resolves the release URL from MICROSANDBOX_VERSION, not only the log message', () => { @@ -103,7 +103,7 @@ describe('microsandbox runtime install', () => { it('passes the same MICROSANDBOX_VERSION from the docker-build workflow', () => { const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml')) - expect(workflow).toContain('MICROSANDBOX_VERSION=0.6.15') + expect(workflow).toContain('MICROSANDBOX_VERSION=0.7.2') expect(workflow).toContain('MICROSANDBOX_VERSION=${{ steps.versions.outputs.microsandbox }}') }) @@ -130,6 +130,68 @@ describe('microsandbox runtime install', () => { }) }) +describe('uv install pin', () => { + const dockerfile = read(dockerfilePath) + const sandboxDockerfile = read(join(repoRoot, 'Dockerfile.sandbox')) + const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml')) + const uvRun = dockerfile.slice(dockerfile.indexOf('Installing uv='), dockerfile.indexOf('Downloading opencode')) + + it('installs uv from the versioned installer URL', () => { + expect(uvRun).toMatch(/https:\/\/astral\.sh\/uv\/\$\{UV_VERSION\}\/install\.sh/) + expect(uvRun).not.toMatch(/https:\/\/astral\.sh\/uv\/install\.sh/) + }) + + it('verifies the installed uv version matches the build argument', () => { + expect(uvRun).toContain('test "$(uv --version | cut -d\' \' -f2)" = "${UV_VERSION}"') + }) + + it('pins the workflow to the verified-good release instead of resolving the latest tag', () => { + expect(workflow).toContain('UV_VERSION=0.12.7') + expect(workflow).not.toContain('astral-sh/uv.git') + }) + + it('pins the same UV_VERSION in the sandbox guest image', () => { + expect(sandboxDockerfile).toMatch(/ARG UV_VERSION=0\.12\.7/) + expect(sandboxDockerfile).toContain('test "$(uv --version | cut -d\' \' -f2)" = "${UV_VERSION}"') + }) +}) + +describe('chromium runtime libraries for playwright', () => { + const dockerfile = read(dockerfilePath) + const sandboxDockerfile = read(join(repoRoot, 'Dockerfile.sandbox')) + const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml')) + const installRun = dockerfile.slice( + dockerfile.indexOf('Installing Chromium runtime libraries'), + dockerfile.indexOf('ENV NODE_ENV=production'), + ) + + it('declares PLAYWRIGHT_VERSION next to the other tool args', () => { + expect(dockerfile).toMatch(/ARG PLAYWRIGHT_VERSION=1\.63\.0/) + }) + + it('resolves the system dependency list from the pinned playwright version', () => { + expect(installRun).toMatch(/npx --yes "playwright@\$\{PLAYWRIGHT_VERSION\}" install-deps chromium/) + }) + + it('does not hand-maintain a package list', () => { + expect(installRun).not.toMatch(/libasound2t64|libnss3|libgbm1/) + expect(installRun).not.toContain('apt-get install') + }) + + it('cleans the apt lists and npm cache in the same layer', () => { + expect(installRun).toContain('rm -rf /var/lib/apt/lists/* /root/.npm') + }) + + it('pins the same PLAYWRIGHT_VERSION in the sandbox guest image', () => { + expect(sandboxDockerfile).toMatch(/ARG PLAYWRIGHT_VERSION=1\.63\.0/) + }) + + it('passes the same PLAYWRIGHT_VERSION from the docker-build workflow', () => { + expect(workflow).toContain('PLAYWRIGHT_VERSION=1.63.0') + expect(workflow).toContain('PLAYWRIGHT_VERSION=${{ steps.versions.outputs.playwright }}') + }) +}) + describe('workspace ownership configuration', () => { it('exposes PUID and PGID environment defaults in docker-compose.yml', () => { const compose = read(composePath) @@ -232,6 +294,151 @@ describe('sandbox guest image', () => { expect(sandboxDockerfile).toContain('PNPM_CONFIG_STORE_DIR=/home/ocm-agent/.local/share/pnpm/store') expect(sandboxDockerfile, 'pnpm 11 ignores npm_config_* env vars').not.toContain('npm_config_store_dir=') }) + + it('builds from the same pinned base tag as the Manager image', () => { + const managerBase = read(dockerfilePath).match(/^FROM (node:\S+) AS base$/m)?.[1] + expect(managerBase).toMatch(/^node:\d+\.\d+\.\d+-trixie$/) + expect(sandboxDockerfile).toMatch(new RegExp(`^FROM ${managerBase!.replace(/\./g, '\\.')}$`, 'm')) + }) + + it('installs pnpm through npm at a pinned version and asserts it, without corepack', () => { + expect(sandboxDockerfile).toMatch(/ARG PNPM_VERSION=11\.24\.0/) + expect(sandboxDockerfile).toContain('npm install -g "pnpm@${PNPM_VERSION}"') + expect(sandboxDockerfile).toContain('test "$(pnpm --version)" = "${PNPM_VERSION}"') + expect(sandboxDockerfile).not.toContain('corepack') + }) + + it('pins bun through the installer tag and asserts the installed version', () => { + expect(sandboxDockerfile).toMatch(/ARG BUN_VERSION=1\.4\.2/) + expect(sandboxDockerfile).toContain('bash -s "bun-v${BUN_VERSION}"') + expect(sandboxDockerfile).toContain('test "$(bun --version)" = "${BUN_VERSION}"') + }) + + it('pins fallow, rust and go and asserts each installed version', () => { + expect(sandboxDockerfile).toMatch(/ARG FALLOW_VERSION=3\.27\.0/) + expect(sandboxDockerfile).toContain('"fallow@${FALLOW_VERSION}"') + expect(sandboxDockerfile).toContain('fallow --version | grep -x "fallow ${FALLOW_VERSION}" >/dev/null') + expect(sandboxDockerfile).toMatch(/ARG RUST_VERSION=1\.98\.1/) + expect(sandboxDockerfile).toContain('--profile minimal --default-toolchain "${RUST_VERSION}"') + expect(sandboxDockerfile).toContain('test "$(rustc --version | cut -d\' \' -f2)" = "${RUST_VERSION}"') + expect(sandboxDockerfile).toMatch(/ARG GO_VERSION=1\.27\.1/) + expect(sandboxDockerfile).toContain('test "$(go version | cut -d\' \' -f3)" = "go${GO_VERSION}"') + }) + + it('opens the rust homes to every uid and routes go installs onto the agent tools PATH', () => { + expect(sandboxDockerfile).toContain('ENV RUSTUP_HOME=/usr/local/rustup') + expect(sandboxDockerfile).toContain('ENV CARGO_HOME=/usr/local/cargo') + expect(sandboxDockerfile).toContain('chmod -R a+w "${RUSTUP_HOME}" "${CARGO_HOME}"') + expect(sandboxDockerfile).toContain('ENV GOBIN=/opt/agent-tools/bin') + expect(sandboxDockerfile).toContain('ENV PNPM_HOME=/opt/agent-tools') + expect(sandboxDockerfile).toMatch(/ENV PATH=\/opt\/agent-tools:\/opt\/agent-tools\/bin:\/usr\/local\/cargo\/bin:\/usr\/local\/go\/bin:\$PATH/) + }) + + it('declares npm_config_prefix only after every root-run npm install -g', () => { + const prefixIndex = sandboxDockerfile.indexOf('ENV npm_config_prefix=') + expect(prefixIndex).toBeGreaterThan(-1) + expect(sandboxDockerfile.lastIndexOf('npm install -g "')).toBeLessThan(prefixIndex) + }) + + it('verifies the rust and go toolchains as the unknown uid', () => { + const verifyRun = sandboxDockerfile.slice(sandboxDockerfile.indexOf('Verifying guest toolchain')) + expect(verifyRun).toMatch(/setpriv --reuid=4242 [^\n]*fallow --version/) + expect(verifyRun).toMatch(/setpriv --reuid=4242 [^\n]*cargo build/) + expect(verifyRun).toMatch(/setpriv --reuid=4242 [^\n]*go run \./) + }) + + it('runs each verification step under bash errexit and pipefail without head pipelines', () => { + const verifyRun = sandboxDockerfile.slice(sandboxDockerfile.indexOf('Verifying guest toolchain')) + expect(verifyRun).not.toContain('sh -c "set -e;') + expect(verifyRun).not.toContain('| head') + const scripts = [...verifyRun.matchAll(/bash -euo pipefail -c '([^']*)'/g)].map((match) => match[1]!) + expect(scripts).toHaveLength(3) + }) + + it('aborts the verification script when an injected cargo build fails', () => { + const verifyRun = sandboxDockerfile.slice(sandboxDockerfile.indexOf('Verifying guest toolchain')) + const rustGoScript = [...verifyRun.matchAll(/bash -euo pipefail -c '([^']*)'/g)] + .map((match) => match[1]!) + .find((script) => script.includes('cargo build')) + expect(rustGoScript, 'rust/go verification script must be extractable').toBeDefined() + + const workDir = mkdtempSync(join(tmpdir(), 'sandbox-verify-')) + const binDir = join(workDir, 'bin') + mkdirSync(binDir) + + const writeStub = (name: string, lines: string[]) => { + const stubPath = join(binDir, name) + writeFileSync(stubPath, ['#!/usr/bin/env bash', 'set -euo pipefail', ...lines, ''].join('\n')) + chmodSync(stubPath, 0o755) + } + + writeStub('cargo', [ + 'crate_dir=""', + 'for arg in "$@"; do crate_dir="$arg"; done', + 'case "$1" in', + ' new) mkdir -p "$crate_dir/target/debug"; printf "%s\\n" "$crate_dir" > "$STUB_STATE/crate-dir" ;;', + ' build)', + ' dir="$(cat "$STUB_STATE/crate-dir")"', + ' printf "#!/usr/bin/env bash\\necho \\"Hello, world!\\"\\n" > "$dir/target/debug/rs-check"', + ' chmod +x "$dir/target/debug/rs-check"', + ' exit 1 ;;', + 'esac', + ]) + + writeStub('go', [ + 'case "$1" in', + ' mod) exit 0 ;;', + ' run) echo go-ok ;;', + 'esac', + ]) + + let status = 0 + try { + execFileSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', rustGoScript!], { + cwd: workDir, + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}`, TMPDIR: workDir, HOME: workDir, STUB_STATE: binDir }, + stdio: 'pipe', + }) + } catch (error) { + status = (error as { status?: number }).status ?? 1 + } + + expect(status, 'a failed cargo build must abort the verification script').not.toBe(0) + expect(existsSync(join(binDir, 'crate-dir'))).toBe(true) + rmSync(workDir, { recursive: true, force: true }) + }) +}) + +describe('sandbox image workflow', () => { + const workflow = read(join(repoRoot, '.github/workflows/sandbox-image.yml')) + + it('builds each platform on a native runner instead of under qemu', () => { + expect(workflow).toMatch(/platform: linux\/amd64\n\s+runner: ubuntu-latest/) + expect(workflow).toMatch(/platform: linux\/arm64\n\s+runner: ubuntu-24\.04-arm/) + expect(workflow).not.toContain('setup-qemu-action') + }) + + it('pushes per-platform digests and merges them into one manifest list', () => { + expect(workflow).toContain('push-by-digest=true,name-canonical=true,push=true') + expect(workflow).toContain('docker buildx imagetools create') + expect(workflow).toContain('file: Dockerfile.sandbox') + }) + + it('publishes the digest-pinned default image repository', () => { + expect(workflow).toContain('IMAGE: docker.io/cstechdev/ocm-sandbox') + }) + + it('only pushes from this repository, never from fork pull requests', () => { + expect(workflow).toContain("github.event.pull_request.head.repo.full_name == github.repository") + }) + + it('validates pull requests without registry credentials or publishing', () => { + expect(workflow).toMatch(/- name: Login to Docker Hub\n\s+if: github.event_name == 'workflow_dispatch'/) + expect(workflow).toContain("|| 'type=cacheonly'") + expect(workflow).toMatch(/- name: Export digest\n\s+if: github.event_name == 'workflow_dispatch'/) + expect(workflow).toMatch(/- name: Upload digest\n\s+if: github.event_name == 'workflow_dispatch'/) + expect(workflow).toMatch(/merge:\n\s+if: github.event_name == 'workflow_dispatch'/) + }) }) describe('sandbox compose overlay', () => { @@ -339,3 +546,97 @@ chown -R "$(id -u):$(id -g)" "$dst" rmSync(src, { recursive: true, force: true }) }) }) + +describe('sandbox dockerd startup helper', () => { + const sandboxDockerfilePath = join(repoRoot, 'Dockerfile.sandbox') + const sandboxDockerdPath = join(repoRoot, 'scripts/sandbox-dockerd-start.sh') + const sandboxWorkflowPath = join(repoRoot, '.github/workflows/sandbox-image.yml') + + const runSandboxDockerd = (options: { dockerExit?: number; env?: Record } = {}) => { + const stubDir = mkdtempSync(join(tmpdir(), 'ocm-dockerd-')) + const logPath = join(stubDir, 'calls.log') + try { + const writeStub = (name: string, body: string) => { + const file = join(stubDir, name) + writeFileSync(file, `#!/bin/bash\n${body}\n`) + chmodSync(file, 0o755) + } + + writeStub('timeout', 'shift\nexec "$@"') + writeStub( + 'docker', + [ + 'echo "docker $*" >> "$OCM_STUB_LOG"', + 'echo "DOCKER_HOST=${DOCKER_HOST:-}" >> "$OCM_STUB_LOG"', + 'echo "DOCKER_CONTEXT=${DOCKER_CONTEXT:-}" >> "$OCM_STUB_LOG"', + 'exit "${OCM_STUB_DOCKER_EXIT:-0}"', + ].join('\n'), + ) + writeStub('id', 'case "$1" in\n -u) echo "${OCM_STUB_CALLER_UID:-1000}" ;;\n *) echo 0 ;;\nesac') + writeStub('sudo', 'echo "sudo $*" >> "$OCM_STUB_LOG"\nexit 0') + writeStub('setsid', 'echo "setsid $*" >> "$OCM_STUB_LOG"\nexit 0') + + const result = spawnSync('sh', [sandboxDockerdPath], { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${stubDir}:${process.env.PATH ?? ''}`, + OCM_STUB_LOG: logPath, + OCM_STUB_DOCKER_EXIT: String(options.dockerExit ?? 0), + ...options.env, + }, + }) + + const calls = existsSync(logPath) ? readFileSync(logPath, 'utf-8').split('\n').filter(Boolean) : [] + return { result, calls } + } finally { + rmSync(stubDir, { recursive: true, force: true }) + } + } + + it('exits successfully against a ready guest daemon without sudo or setsid', () => { + const { result, calls } = runSandboxDockerd() + + expect(result.status).toBe(0) + expect(calls.some((call) => call.startsWith('sudo '))).toBe(false) + expect(calls.some((call) => call.startsWith('setsid '))).toBe(false) + }) + + it('probes with the explicit socket after stripping ambient DOCKER_HOST and DOCKER_CONTEXT', () => { + const { result, calls } = runSandboxDockerd({ + env: { DOCKER_HOST: 'tcp://ambient:2375', DOCKER_CONTEXT: 'ambient-context' }, + }) + + expect(result.status).toBe(0) + expect(calls).toContain('docker -H unix:///var/run/docker.sock info') + expect(calls).toContain('DOCKER_HOST=') + expect(calls).toContain('DOCKER_CONTEXT=') + }) + + it('reexecutes itself through sudo -n when a non-root caller cannot reach the daemon', () => { + const { result, calls } = runSandboxDockerd({ + dockerExit: 1, + env: { OCM_STUB_CALLER_UID: '1000' }, + }) + + expect(result.status).toBe(0) + expect(calls).toContain(`sudo -n ${sandboxDockerdPath}`) + expect(calls.some((call) => call.startsWith('setsid '))).toBe(false) + }) + + it('installs Docker from the official Debian repository', () => { + const sandboxDockerfile = read(sandboxDockerfilePath) + expect(sandboxDockerfile).toContain('https://download.docker.com/linux/debian') + expect(sandboxDockerfile).toContain('docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin') + }) + + it('copies the startup helper into the guest image', () => { + const sandboxDockerfile = read(sandboxDockerfilePath) + expect(sandboxDockerfile).toMatch(/^COPY --chmod=0755 scripts\/sandbox-dockerd-start\.sh \/usr\/local\/bin\/ocm-dockerd-start$/m) + }) + + it('runs the sandbox image workflow when the helper changes', () => { + const workflow = read(sandboxWorkflowPath) + expect(workflow).toMatch(/pull_request:\n\s+paths:\n(?:\s+-\s+\S+\n)*\s+-\s+scripts\/sandbox-dockerd-start\.sh/) + }) +}) diff --git a/backend/test/services/sandbox/command.test.ts b/backend/test/services/sandbox/command.test.ts index d222e241..04608556 100644 --- a/backend/test/services/sandbox/command.test.ts +++ b/backend/test/services/sandbox/command.test.ts @@ -8,6 +8,9 @@ import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' import { WORKSPACE_SANDBOX_NAME, SANDBOX_UNAVAILABLE_PREFIX, + SANDBOX_DOCKER_DATA_VOLUME_NAME, + SANDBOX_DOCKER_DATA_GUEST_PATH, + SANDBOX_DOCKER_DATA_SIZE, buildCanonicalSandboxSpec, buildSandboxCreateArgs, buildSandboxInspectArgs, @@ -108,10 +111,29 @@ describe('sandbox command builders', () => { expect(mountArgs[6]).toBe(`${getOpenCodeAgentTmpPath()}:${getOpenCodeAgentTmpPath()}`) }) + it('explicitly creates the runtime tmpfs required by attestation', () => { + const args = buildSandboxCreateArgs() + const spec = buildCanonicalSandboxSpec() + const resources = spec.resources as { memory_mib: number } + expect(args[args.indexOf('--tmpfs') + 1]).toBe(`/tmp:${resolveSandboxRuntimeTmpfsSizeMib(resources.memory_mib)}M`) + }) + + it('attaches the persistent Docker data disk with the tested mount-named specification', () => { + const args = buildSandboxCreateArgs() + + const mountNamedIndex = args.indexOf('--mount-named') + expect(mountNamedIndex).toBeGreaterThan(-1) + expect(args[mountNamedIndex + 1]).toBe( + `${SANDBOX_DOCKER_DATA_VOLUME_NAME}:${SANDBOX_DOCKER_DATA_GUEST_PATH}:kind=disk,size=${SANDBOX_DOCKER_DATA_SIZE}`, + ) + }) + it('never masks the assistant .opencode directory with a tmpfs overlay', () => { const args = buildSandboxCreateArgs() - expect(args).not.toContain('--tmpfs') + const tmpfsMounts = args.flatMap((arg, index) => arg === '--tmpfs' ? [args[index + 1]] : []) + expect(tmpfsMounts).toHaveLength(1) + expect(tmpfsMounts[0]).toMatch(/^\/tmp:\d+M$/) expect(args).not.toContain(getAssistantOpenCodeDir()) }) @@ -202,6 +224,18 @@ describe('sandbox command builders', () => { expect(mount.quota_mib).toBeNull() } + const named = mounts.filter((mount) => mount.type === 'Named') + expect(named).toHaveLength(1) + expect(named[0]).toEqual({ + type: 'Named', + name: SANDBOX_DOCKER_DATA_VOLUME_NAME, + guest: SANDBOX_DOCKER_DATA_GUEST_PATH, + options: { readonly: false, noexec: false, nosuid: false, nodev: false }, + stat_virtualization: 'strict', + host_permissions: 'private', + follow_root_symlinks: false, + }) + expect(mounts.find((mount) => mount.type === 'Tmpfs')).toBeUndefined() }) @@ -319,7 +353,8 @@ describe('sandbox command builders', () => { "getent passwd 1001 >/dev/null 2>&1 || { echo 'ocm-exec:x:1001:1002:Manager sandbox exec user:/home/ocm-agent:/bin/sh' >> /etc/passwd; grep -q '^ocm-exec:' /etc/shadow || echo 'ocm-exec:*:19000:0:99999:7:::' >> /etc/shadow; }", ) expect(script).toContain('getent passwd 1001 >/dev/null || exit 1') - expect(script).toContain('getent passwd 1001 >/dev/null || exit 1') + expect(script).toContain('if getent group docker >/dev/null 2>&1; then') + expect(script).toContain('usermod -aG docker "$(getent passwd 1001 | cut -d: -f1)" || exit 1') }) it('returns no provisioning args when the exec user does not resolve to numeric uid and gid', () => { diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index aad17c57..13231546 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -7,7 +7,7 @@ import { ENV, getAssistantOpenCodeDir, getForgeWorktreesPath, getOpenCodeAgentTm import { migrate } from '../../../src/db/migration-runner' import { allMigrations } from '../../../src/db/migrations' import { SettingsService } from '../../../src/services/settings' -import { buildSandboxInspectArgs, resolveSandboxExecUser, resolveSandboxRuntimeTmpfsSizeMib, sandboxExecutablePath, WORKSPACE_SANDBOX_NAME } from '../../../src/services/sandbox/command' +import { buildSandboxInspectArgs, resolveSandboxExecUser, resolveSandboxRuntimeTmpfsSizeMib, sandboxExecutablePath, SANDBOX_DOCKER_DATA_GUEST_PATH, SANDBOX_DOCKER_DATA_VOLUME_NAME, WORKSPACE_SANDBOX_NAME } from '../../../src/services/sandbox/command' import { SandboxRuntimeService, backgroundProvisionRetryForTests, provisionSandboxExecUserForTests, resetSandboxRuntimeState, stopWorkspaceSandboxOnShutdown } from '../../../src/services/sandbox/runtime' import { executeCommand } from '../../../src/utils/process' import { detectSandboxCapability } from '../../../src/services/sandbox/capability' @@ -126,6 +126,38 @@ describe('SandboxRuntimeService', () => { } } + function namedMount(name: string, guest: string): Record { + return { + type: 'Named', + name, + guest, + options: { readonly: false, noexec: false, nosuid: false, nodev: false }, + stat_virtualization: 'strict', + host_permissions: 'private', + follow_root_symlinks: false, + } + } + + function sandboxMounts(overrides: { named?: Record | null; extra?: Array> } = {}): Array> { + const mounts: Array> = [ + bindMount(reposRoot), + bindMount(worktreesRoot), + bindMount(openCodeWorktreesRoot), + bindMount(forgeWorktreesRoot), + bindMount(toolOutputRoot), + bindMount(skillsRoot), + bindMount(agentTmpRoot), + ] + if (overrides.named !== null) { + mounts.push(overrides.named ?? namedMount(SANDBOX_DOCKER_DATA_VOLUME_NAME, SANDBOX_DOCKER_DATA_GUEST_PATH)) + } + mounts.push(tmpfsMount('/tmp', runtimeTmpfsSizeMib())) + if (overrides.extra !== undefined) { + mounts.push(...overrides.extra) + } + return mounts + } + function realInspectConfig(overrides: Record = {}): Record { return { name: WORKSPACE_SANDBOX_NAME, @@ -149,16 +181,7 @@ describe('SandboxRuntimeService', () => { ], labels: { 'ocm.managed': 'true', 'ocm.net': ENV.SANDBOX.NET }, rlimits: [], - mounts: [ - bindMount(reposRoot), - bindMount(worktreesRoot), - bindMount(openCodeWorktreesRoot), - bindMount(forgeWorktreesRoot), - bindMount(toolOutputRoot), - bindMount(skillsRoot), - bindMount(agentTmpRoot), - tmpfsMount('/tmp', runtimeTmpfsSizeMib()), - ], + mounts: sandboxMounts(), patches: [], network: { enabled: true, @@ -1253,16 +1276,7 @@ describe('SandboxRuntimeService', () => { return { exitCode: 0, stdout: runningInspectOutput(realInspectConfig({ - mounts: [ - bindMount(reposRoot), - bindMount(worktreesRoot), - bindMount(openCodeWorktreesRoot), - bindMount(forgeWorktreesRoot), - bindMount(toolOutputRoot), - bindMount(skillsRoot), - bindMount(agentTmpRoot), - tmpfsMount('/tmp', runtimeTmpfsSizeMib()), - ], + mounts: sandboxMounts(), })), stderr: '', } @@ -3101,4 +3115,87 @@ describe('SandboxRuntimeService', () => { 'unexpected tmpfs mount', ) }) + + it('reuses a running sandbox carrying the exact Docker data volume at /var/lib/docker', async () => { + enableEnforcement() + mockExecuteCommand.mockImplementation(async (args: string[]) => { + if (args.includes('ls')) { + return { + exitCode: 0, + stdout: JSON.stringify([{ name: WORKSPACE_SANDBOX_NAME, status: 'running' }]), + stderr: '', + } + } + if (args.includes('inspect')) { + return { + exitCode: 0, + stdout: runningInspectOutput(realInspectConfig({ + mounts: sandboxMounts({ + named: namedMount(SANDBOX_DOCKER_DATA_VOLUME_NAME, SANDBOX_DOCKER_DATA_GUEST_PATH), + }), + })), + stderr: '', + } + } + return { exitCode: 0, stdout: '', stderr: '' } + }) + + const plan = await service.planShell(repoADir) + + expect(plan).toEqual({ mode: 'sandbox', workdir: repoADir }) + expect(mockExecuteCommand.mock.calls.filter((call) => call[0].includes('rm'))).toHaveLength(0) + expect(mockExecuteCommand.mock.calls.filter((call) => call[0].includes('run'))).toHaveLength(0) + expect(mockExecuteCommand.mock.calls.filter((call) => call[0].includes('start'))).toHaveLength(0) + expect(mockExecuteCommand.mock.calls.filter((call) => call[0].includes('inspect'))).toHaveLength(1) + }) + + it('removes and recreates a sandbox missing the Docker data volume mount', async () => { + await assertRecreateForInspectMutation( + realInspectConfig({ mounts: sandboxMounts({ named: null }) }), + 'missing the Docker data volume mount', + ) + }) + + it('removes and recreates a sandbox duplicating the Docker data volume mount', async () => { + await assertRecreateForInspectMutation( + realInspectConfig({ + mounts: sandboxMounts({ + extra: [namedMount(SANDBOX_DOCKER_DATA_VOLUME_NAME, SANDBOX_DOCKER_DATA_GUEST_PATH)], + }), + }), + 'duplicate Docker data volume mount', + ) + }) + + it('removes and recreates a sandbox whose Docker data volume has an unexpected name', async () => { + await assertRecreateForInspectMutation( + realInspectConfig({ + mounts: sandboxMounts({ named: namedMount('ocm-other-docker-data', SANDBOX_DOCKER_DATA_GUEST_PATH) }), + }), + 'unexpected named volume mount', + ) + }) + + it('removes and recreates a sandbox whose Docker data volume is mounted at an unexpected destination', async () => { + await assertRecreateForInspectMutation( + realInspectConfig({ + mounts: sandboxMounts({ named: namedMount(SANDBOX_DOCKER_DATA_VOLUME_NAME, '/var/lib/docker-other') }), + }), + 'unexpected named volume mount', + ) + }) + + it('removes and recreates a sandbox whose Docker data volume has relaxed options', async () => { + await assertRecreateForInspectMutation( + realInspectConfig({ + mounts: sandboxMounts({ + named: { + ...namedMount(SANDBOX_DOCKER_DATA_VOLUME_NAME, SANDBOX_DOCKER_DATA_GUEST_PATH), + options: { readonly: false, noexec: true, nosuid: false, nodev: false }, + }, + }), + }), + 'options.noexec', + ) + }) }) diff --git a/docker-compose.sandbox.yml b/docker-compose.sandbox.yml index bc9baa86..90e0ffb5 100644 --- a/docker-compose.sandbox.yml +++ b/docker-compose.sandbox.yml @@ -16,7 +16,7 @@ services: cap_add: - NET_ADMIN environment: - - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:7435dce147503b846bcd89ad5e9f7192f37b332c8e49087a7e619034352c47f4} + - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:9df035cfb1a7c367bac6edbfd660d084b08e02faa051a93e0a6e381272b85342} - SANDBOX_MEMORY=${SANDBOX_MEMORY:-4G} - SANDBOX_CPUS=${SANDBOX_CPUS:-2} - SANDBOX_EXEC_USER=${SANDBOX_EXEC_USER:-${PUID:-1000}} diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 5c2ba7ec..9cee9659 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -330,7 +330,7 @@ services: cap_add: - NET_ADMIN environment: - - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:7435dce147503b846bcd89ad5e9f7192f37b332c8e49087a7e619034352c47f4} + - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:9df035cfb1a7c367bac6edbfd660d084b08e02faa051a93e0a6e381272b85342} - SANDBOX_MEMORY=${SANDBOX_MEMORY:-4G} - SANDBOX_CPUS=${SANDBOX_CPUS:-2} - SANDBOX_EXEC_USER=${SANDBOX_EXEC_USER:-${PUID:-1000}} diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index c82beb04..c053759a 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -116,7 +116,7 @@ Sandboxed agent commands run inside a microVM managed by `msb` (see [Agent Sandb |----------|-------------|---------| | `MSB_PATH` | Path to the `msb` executable | `msb` | | `MSB_LIBKRUNFW_PATH` | Path to the `libkrunfw` firmware library used by `msb` (set in the container image) | `/opt/microsandbox/lib/libkrunfw.so` | -| `SANDBOX_IMAGE` | OCI image the microVM boots from. Digest-pinned by default so a rebuilt guest image is actually adopted; see [Sandbox Guest Image](../features/sandboxing.md#sandbox-guest-image) for what the default ships and how to build your own | `docker.io/cstechdev/ocm-sandbox@sha256:7435dce1…` | +| `SANDBOX_IMAGE` | OCI image the microVM boots from. Digest-pinned by default so a rebuilt guest image is actually adopted; see [Sandbox Guest Image](../features/sandboxing.md#sandbox-guest-image) for what the default ships and how to build your own | `docker.io/cstechdev/ocm-sandbox@sha256:9df035cf…` | | `SANDBOX_MEMORY` | MicroVM memory (e.g. `4G`) | `4G` | | `SANDBOX_CPUS` | MicroVM CPU count | `2` | | `SANDBOX_EXEC_USER` | Guest identity sandboxed commands run as: a numeric `uid`, a numeric `uid:gid`, or a guest username. A numeric uid must match the Manager's effective uid (`PUID`); the compose overlay defaults it to `${PUID:-1000}`. A guest username is resolved to the Manager's effective `uid:gid` so writes to the mounted project roots always succeed. When a configured numeric identity cannot write the workspace, enforcement is reported unavailable | `${PUID:-1000}` via the overlay, otherwise `node` | diff --git a/docs/features/sandboxing.md b/docs/features/sandboxing.md index 4e99aaa9..d90ec9a0 100644 --- a/docs/features/sandboxing.md +++ b/docs/features/sandboxing.md @@ -169,48 +169,49 @@ Understand the trade-off before enabling it. `msb exec -e` is the only injection ## Sandbox Guest Image -`SANDBOX_IMAGE` defaults to a digest-pinned reference of `docker.io/cstechdev/ocm-sandbox`, built from `Dockerfile.sandbox` in this repository and published for `linux/amd64` and `linux/arm64` from a build host with `docker buildx`: +`SANDBOX_IMAGE` defaults to a digest-pinned reference of `docker.io/cstechdev/ocm-sandbox`. The `Sandbox Image` workflow (`.github/workflows/sandbox-image.yml`) builds `Dockerfile.sandbox` natively: `linux/amd64` on `ubuntu-latest` and `linux/arm64` on `ubuntu-24.04-arm`. Pull requests from this repository that touch the Dockerfile or workflow run build-time verification without publishing or requiring registry credentials. A manual `workflow_dispatch` publishes both platforms by digest and merges them into one manifest list, tagged with the commit SHA, package version, and optional `tag` input (default `latest`). Publishing requires `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` repository secrets. The job summary prints the index digest to pin. + +Native runners avoid the x86_64 `rustc` segmentation fault observed under qemu-user on the arm64 build host. Later uv releases also failed in that environment. Neither platform skips toolchain execution to work around emulation failures. A single-platform local build on an arm64 host is: ```bash -docker buildx build --builder \ - --platform linux/amd64,linux/arm64 \ - -t docker.io/cstechdev/ocm-sandbox:latest \ - --build-arg PLAYWRIGHT_VERSION=1.56.0 \ - -f Dockerfile.sandbox --push . -docker buildx imagetools inspect docker.io/cstechdev/ocm-sandbox:latest # copy the index digest +docker buildx build --platform linux/arm64 -t ocm-sandbox:local -f Dockerfile.sandbox --load . ``` -A multi-platform build needs the `docker-container` driver and pushes the manifest list directly; `--load` cannot hold two platforms, and the default `docker` driver cannot build them. - -Then update the pin in `shared/src/config/defaults.ts` (`SANDBOX.IMAGE`) and in the `docker-compose.sandbox.yml` default to the new `@sha256:` digest. That bump is what makes deployments adopt a rebuilt guest image, and it is deliberate rather than convenient: +After a publish, update the pin in `shared/src/config/defaults.ts` (`SANDBOX.IMAGE`) and in the `docker-compose.sandbox.yml` default to the new `@sha256:` digest. That bump is what makes deployments adopt a rebuilt guest image, and it is deliberate rather than convenient: - msb caches images by reference. `msb pull docker.io/cstechdev/ocm-sandbox:latest` reports "already cached" without contacting the registry, and `pull_policy: IfMissing` never re-pulls, so a host that pulled a mutable tag once keeps that content forever. - Sandbox attestation compares the image *reference string*. A floating tag therefore keeps passing attestation while its content drifts, so the running microVM is never recreated either. A digest reference sidesteps both: it is a cache key no host has seen before, so `IfMissing` pulls it, and it fails the reference comparison against a microVM created from the old reference, so the Manager removes and recreates that microVM on its own. No manual cleanup is required on deploy; to reclaim the superseded image afterwards, run `msb image prune` or `msb image rm `. -It is `node:24` (Debian 12, `buildpack-deps` based), so the compile toolchain is already present, plus the package managers and CLI tooling from the Manager image: +It is built from the same `node:24.21.0-trixie` tag as the Manager image (Debian 13, `buildpack-deps` based), so both track one Node and one Debian release and the compile toolchain is already present. pnpm, Bun, uv, fallow, Rust, Go, and Playwright have build-argument version pins; base-image and apt tools follow their package sources: | Tool | Source | Notes | | --- | --- | --- | -| `gcc` / `g++` / `make` / `ld` / `pkg-config` | `node:24` | GCC 12.2, GNU Make 4.3 | -| `glib-2.0` | `node:24` | 2.74.6, with `pkg-config` metadata | -| `git`, `ssh`, `python3`, `curl`, `wget`, `unzip` | `node:24` | git 2.39.5 | +| `gcc` / `g++` / `make` / `ld` / `pkg-config` | base image | GCC 14, GNU Make 4.4 | +| `glib-2.0` | base image | With `pkg-config` metadata | +| `git`, `ssh`, `python3`, `curl`, `wget`, `unzip` | base image | | | `python` | apt (`python-is-python3`) | The base image ships only `python3`; a skill or script invoking `python` would otherwise fail | -| `ping`, `ip`, `ss`, `netstat`, `dig`, `host`, `nslookup`, `nc`, `traceroute`, `lsof`, `rsync` | apt | `node:24` ships no network diagnostics at all. The image build fails if any of these is missing | -| `pnpm` | corepack | Prewarmed into a shared `COREPACK_HOME` (`/usr/local/share/corepack`) whose whole tree — including the `v1/` cache and `lastKnownGood.json` that `corepack prepare` creates as root — is opened up after prewarming, so the exec user runs the build-time version offline and a project `packageManager` pin of a different version downloads on first use. The build verifies that pinned download as an unknown uid | -| `bun`, `bunx` | official installer | Installed to `/opt/bun`, world-readable, both symlinked onto `PATH` | -| `uv`, `uvx` | Astral installer | Standalone binaries on `PATH`; `uv tool` shims land in `/opt/agent-tools/bin`, which is on `PATH` | -| `npm -g`, `pnpm -g` | npm / corepack | Global installs redirect to `/opt/agent-tools` (`npm_config_prefix`, `PNPM_HOME`), so they are writable for the exec user and their binaries are on `PATH` | +| `ping`, `ip`, `ss`, `netstat`, `dig`, `host`, `nslookup`, `nc`, `traceroute`, `lsof`, `rsync` | apt | The base image ships no network diagnostics at all. The image build fails if any of these is missing | +| `pnpm` | `npm install -g` (`PNPM_VERSION`) | Installed into `/usr/local` as root. A project `packageManager` pin of a different version is honoured by pnpm itself, which downloads it into the pnpm store on first use; the build verifies that as an unknown uid | +| `bun`, `bunx` | official installer (`BUN_VERSION`) | Installed to `/opt/bun`, world-readable, both symlinked onto `PATH` | +| `uv`, `uvx` | Astral installer (`UV_VERSION`) | Standalone binaries on `PATH`; `uv tool` shims land in `/opt/agent-tools/bin`, which is on `PATH`. Held at 0.12.7: later releases segfault under qemu-user x86_64 emulation, which is how the amd64 platform is built from an arm64 host | +| `fallow` | `npm install -g` (`FALLOW_VERSION`) | Dead-code and unused-export analysis CLI | +| `rustc`, `cargo`, `rustup` | rustup (`RUST_VERSION`, minimal profile) | `RUSTUP_HOME=/usr/local/rustup` and `CARGO_HOME=/usr/local/cargo` follow the official rust image layout and are opened to every uid afterwards, so the exec user can `cargo install` and add toolchains; `/usr/local/cargo/bin` is on `PATH` | +| `go` | official tarball (`GO_VERSION`) | Unpacked to `/usr/local/go`, on `PATH`. `GOBIN=/opt/agent-tools/bin` puts `go install` binaries next to the other agent tools; `GOPATH` and the module cache default under the writable `HOME` | +| `npm -g`, `pnpm -g` | npm / pnpm | Global installs redirect to `/opt/agent-tools` (`npm_config_prefix`, `PNPM_HOME`), so they are writable for the exec user and their binaries are on `PATH` | | `sudo` | apt | Passwordless for every guest user via `/etc/sudoers.d/ocm-guest`; system-wide `apt-get install` works from the exec user | | `pip`, `venv` | apt | `python3-pip` and `python3-venv` on top of the base `python3`. Debian's externally-managed marker is removed, so `pip` and `uv pip --system` are not refused; system-wide writes still need `sudo`, so use `--user` or a venv | -| `jq`, `ripgrep`, `less`, `tree`, `file`, `procps` | apt | Common CLI tools agent workflows expect | +| `jq`, `ripgrep`, `less`, `tree`, `file`, `procps` | apt | Common CLI tools agent workflows expect; Debian 13 carries jq 1.7 and ripgrep 14 | | `gh` | official `cli.github.com` apt repo | Current release. Debian's own package is several years stale | -| Chromium | Playwright (`PLAYWRIGHT_VERSION`, default `1.56.0`) | Installed to `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`, world-readable so `SANDBOX_EXEC_USER` can launch it | +| Docker Engine, CLI, Buildx, Compose | official Docker Debian apt repo | `ocm-dockerd-start` starts the guest daemon on demand and waits for readiness. The Manager adds the provisioned exec user to the guest's Docker group. No host Docker socket is mounted | +| Chromium | Playwright (`PLAYWRIGHT_VERSION`, default `1.63.0`) | Installed to `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`, world-readable so `SANDBOX_EXEC_USER` can launch it | `NODE_PATH=/usr/local/lib/node_modules` is set so agent code can `require("playwright")` from any working directory. It is only a resolution fallback; a project-local `node_modules` still wins. -The guest runs every command as a numeric host uid that has no `/etc/passwd` entry in the image, so the uncorrected default `HOME` is `/` and anything that writes per-user state — the pnpm store, uv and pip caches, `git config`, `gh` config — dies with `EACCES` on the first run. The image therefore sets `HOME=/home/ocm-agent` (mode 1777), prewarms corepack into a world-readable `COREPACK_HOME`, and puts a world-writable `/opt/agent-tools/bin` on `PATH` for `uv tool` and global package-manager installs. Image `ENV` reaches `msb exec` commands verbatim, including for unknown uids. The image build verifies the whole toolchain as an unprivileged uid so a root-only regression fails the build instead of the agent. +Before using Docker inside the guest, run `ocm-dockerd-start`. The helper is safe to call repeatedly, serializes concurrent starts, and probes only the guest's Unix socket regardless of `DOCKER_HOST` or `DOCKER_CONTEXT`. Daemon startup logs are in `/var/log/dockerd.log`. Docker data lives on the 20 GiB sparse ext4 named volume `ocm-workspace-docker-data`, mounted at `/var/lib/docker`; Docker's overlay storage cannot use the sandbox's overlay root filesystem. The named volume survives sandbox restarts and recreation, remains private to the guest, and is not shared with the host Docker daemon. Removing that named volume deletes its Docker images, containers, and volumes. Sandbox attestation requires exactly this named mount in addition to the existing project mounts. + +The guest runs every command as a numeric host uid that has no `/etc/passwd` entry in the image, so the uncorrected default `HOME` is `/` and anything that writes per-user state — the pnpm store, uv and pip caches, `git config`, `gh` config, the cargo registry, the Go module cache — dies with `EACCES` on the first run. The image therefore sets `HOME=/home/ocm-agent` (mode 1777), opens the rust homes to every uid, and puts a world-writable `/opt/agent-tools/bin` on `PATH` for `uv tool`, `go install` and global package-manager installs. Image `ENV` reaches `msb exec` commands verbatim, including for unknown uids. The image build verifies the whole toolchain as an unprivileged uid — including a `cargo build` and a `go run` — so a root-only regression fails the build instead of the agent. The pnpm store itself is pinned to a container-internal path with `PNPM_CONFIG_STORE_DIR=/home/ocm-agent/.local/share/pnpm/store`. The pin goes through pnpm's own config env var because pnpm 11 no longer reads `npm_config_*` variables; with the store unpinned, pnpm places it on the mounted project filesystem, which pollutes the repository, slows installs over the host bind mount, and can end up committed. @@ -218,7 +219,7 @@ The pnpm store itself is pinned to a container-internal path with `PNPM_CONFIG_S Chromium launches headless as the non-root exec user without extra flags. If your host kernel restricts user namespaces so Chromium's own sandbox fails, pass `--no-sandbox` — the microVM is already the isolation boundary. -The image is over 3 GB against a 1.6 GB `node:24` baseline, almost entirely Chromium and its dependencies. When sandboxing is enabled, the Manager gets everything ready before the first command instead of on it: at startup, and whenever enforcement is switched on, it pulls the image and then boots the shared microVM in the background (`msb pull`, then the same create/start/attest/provision path a command would trigger, bounded by `SANDBOX_START_TIMEOUT_MS`; raise it on slow links). One microVM serves every repo and schedule worktree, so a single warm-up covers the whole workspace. The pull is a no-op once cached, `docker-compose.sandbox.yml` persists the microsandbox store in the `microsandbox-data` volume so the download survives container replacement, and the shutdown handler stops the microVM again. Because the warm-up runs in the background, server startup never waits for it, and a command issued while it is still running joins the same in-flight boot rather than starting a second one. +The image is around 5 GB unpacked against a 1.6 GB base; Chromium with its dependencies (about 1.1 GB) and the Rust and Go toolchains (about 0.8 GB) account for most of the rest. When sandboxing is enabled, the Manager gets everything ready before the first command instead of on it: at startup, and whenever enforcement is switched on, it pulls the image and then boots the shared microVM in the background (`msb pull`, then the same create/start/attest/provision path a command would trigger, bounded by `SANDBOX_START_TIMEOUT_MS`; raise it on slow links). One microVM serves every repo and schedule worktree, so a single warm-up covers the whole workspace. The pull is a no-op once cached, `docker-compose.sandbox.yml` persists the microsandbox store in the `microsandbox-data` volume so the download survives container replacement, and the shutdown handler stops the microVM again. Because the warm-up runs in the background, server startup never waits for it, and a command issued while it is still running joins the same in-flight boot rather than starting a second one. ### Using your own image @@ -233,7 +234,9 @@ docker build -f Dockerfile.sandbox -t my-sandbox:local . Pin a concrete tag or digest rather than a floating one. Attestation compares the image *reference string*, so a mutable tag keeps passing attestation while the underlying image drifts. -Override the Playwright version at build time with `--build-arg PLAYWRIGHT_VERSION=1.57.0`. If your project drives Playwright itself, match this version to the one in your `package.json`; a mismatched browser revision makes Playwright refuse to launch. +Override any tool pin at build time with its `ARG`, for example `--build-arg PLAYWRIGHT_VERSION=1.62.0` or `--build-arg RUST_VERSION=1.97.0`; the build asserts the installed version, so a typo fails early instead of shipping a stale tool. If your project drives Playwright itself, match this version to the one in your `package.json`; a mismatched browser revision makes Playwright refuse to launch. Rebuild and republish the guest image, then update the `SANDBOX.IMAGE` digest, whenever you change a pin. + +The Manager image itself carries the same Chromium runtime libraries, resolved by `playwright install-deps chromium` for the same `PLAYWRIGHT_VERSION` at build time. That is what makes a Playwright e2e suite run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime. Both images track one pin, so bumping `PLAYWRIGHT_VERSION` refreshes the sandbox browser and the Manager's system libraries together. ## Caveats diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c14f8d..3d4cf59f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,8 +321,8 @@ importers: version: 4.3.2 devDependencies: '@opencode-ai/sdk': - specifier: 1.18.8 - version: 1.18.8 + specifier: 1.18.31 + version: 1.18.31 typescript: specifier: ^5 version: 5.9.3 @@ -1084,8 +1084,8 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@opencode-ai/sdk@1.18.8': - resolution: {integrity: sha512-8vi5UBKFFgc+fnyKhZhGUxiIWMtUuN00VAInnK9JO6g6EC8WvWefP+ccTBQD023zod69JaEoAzGgO8hdxXHUaw==} + '@opencode-ai/sdk@1.18.31': + resolution: {integrity: sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==} '@opentui/core-darwin-arm64@0.1.107': resolution: {integrity: sha512-Yqt2/9Ntw0IdtPA/qmHvXCE16y4Jq5/btCmuzN9/opzqZ5rYGYYVtiBii3LezGcTZYuJQZthjvh8MLPXXwA2EQ==} @@ -5627,7 +5627,7 @@ snapshots: '@noble/hashes@2.0.1': {} - '@opencode-ai/sdk@1.18.8': + '@opencode-ai/sdk@1.18.31': dependencies: cross-spawn: 7.0.6 diff --git a/scripts/sandbox-dockerd-start.sh b/scripts/sandbox-dockerd-start.sh new file mode 100755 index 00000000..95eafb4e --- /dev/null +++ b/scripts/sandbox-dockerd-start.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -u + +lock=/var/run/ocm-dockerd.lock +log=/var/log/dockerd.log +ready_timeout=60 +lock_wait=120 +socket=unix:///var/run/docker.sock + +probe() { + timeout 5 env -u DOCKER_HOST -u DOCKER_CONTEXT docker -H "$socket" info >/dev/null 2>&1 +} + +probe && exit 0 + +if [ "$(id -u)" -ne 0 ]; then + exec sudo -n "$0" "$@" +fi + +exec 9>"$lock" +flock -w "$lock_wait" 9 || exit 1 + +probe && exit 0 + +if [ -f /var/run/docker.pid ] && kill -0 "$(cat /var/run/docker.pid)" 2>/dev/null; then + : +else + setsid dockerd >"$log" 2>&1 &- & +fi + +deadline=$(($(date +%s) + ready_timeout)) +while [ "$(date +%s)" -lt "$deadline" ]; do + probe && exit 0 + sleep 1 +done + +echo "dockerd did not become ready within ${ready_timeout}s; tail of $log:" >&2 +tail -n 20 "$log" 2>/dev/null >&2 +exit 1 diff --git a/shared/package.json b/shared/package.json index d524738d..4e9fd2b9 100644 --- a/shared/package.json +++ b/shared/package.json @@ -29,7 +29,7 @@ "dotenv": "^17.2.3" }, "devDependencies": { - "@opencode-ai/sdk": "1.18.8", + "@opencode-ai/sdk": "1.18.31", "typescript": "^5" } } diff --git a/shared/src/config/defaults.ts b/shared/src/config/defaults.ts index 2d0b50cb..f48e20ce 100644 --- a/shared/src/config/defaults.ts +++ b/shared/src/config/defaults.ts @@ -33,7 +33,7 @@ export const DEFAULTS = { SANDBOX: { MSB_PATH: 'msb', - IMAGE: 'docker.io/cstechdev/ocm-sandbox@sha256:7435dce147503b846bcd89ad5e9f7192f37b332c8e49087a7e619034352c47f4', + IMAGE: 'docker.io/cstechdev/ocm-sandbox@sha256:9df035cfb1a7c367bac6edbfd660d084b08e02faa051a93e0a6e381272b85342', MEMORY: '4G', CPUS: 2, EXEC_USER: 'node',