diff --git a/Dockerfile b/Dockerfile index 45a18d128..10b27e6f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,9 @@ # ────────────────────────────────────────────── -# Stage 1: CI Builder -# Image: ghcr.io/gsd-build/gsd-ci-builder -# Used by: pipeline.yml Dev stage -# ────────────────────────────────────────────── -FROM node:24-bookworm AS builder - -# Rust toolchain (stable, minimal profile) -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal -ENV PATH="/root/.cargo/bin:${PATH}" - -# Cross-compilation for linux-arm64 -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc-aarch64-linux-gnu \ - g++-aarch64-linux-gnu \ - && rustup target add aarch64-unknown-linux-gnu \ - && rm -rf /var/lib/apt/lists/* - -# Verify toolchain -RUN node --version && rustc --version && cargo --version - -# ────────────────────────────────────────────── -# Stage 2: Runtime +# Runtime # Image: ghcr.io/gsd-build/gsd-pi # Used by: end users via docker run # ────────────────────────────────────────────── -FROM node:24-slim AS runtime +FROM node:24-slim # Git is required for GSD's git operations RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/docker/.env.example b/docker/.env.example index 71c2f4802..ca9c3db84 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -3,6 +3,12 @@ # Copy this file to .env and fill in your keys. # ────────────────────────────────────────────── +# ── Container User Identity ── +# Match your host UID/GID to avoid permission issues on bind mounts. +# Run `id -u` and `id -g` on your host to find the right values. +PUID=1000 +PGID=1000 + # ── LLM Provider API Keys (at least one required) ── # Anthropic (Claude) diff --git a/docker/Dockerfile.ci-builder b/docker/Dockerfile.ci-builder new file mode 100644 index 000000000..822651db4 --- /dev/null +++ b/docker/Dockerfile.ci-builder @@ -0,0 +1,20 @@ +# ────────────────────────────────────────────── +# CI Builder +# Image: ghcr.io/gsd-build/gsd-ci-builder +# Used by: pipeline.yml Dev stage +# ────────────────────────────────────────────── +FROM node:24-bookworm + +# Rust toolchain (stable, minimal profile) +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal +ENV PATH="/root/.cargo/bin:${PATH}" + +# Cross-compilation for linux-arm64 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu \ + g++-aarch64-linux-gnu \ + && rustup target add aarch64-unknown-linux-gnu \ + && rm -rf /var/lib/apt/lists/* + +# Verify toolchain +RUN node --version && rustc --version && cargo --version diff --git a/docker/Dockerfile.sandbox b/docker/Dockerfile.sandbox index af1bf40d1..596bdf803 100644 --- a/docker/Dockerfile.sandbox +++ b/docker/Dockerfile.sandbox @@ -4,7 +4,7 @@ # Purpose: Isolated environment for GSD auto mode # Usage: docker sandbox create --template ./docker # ────────────────────────────────────────────── -FROM node:22-bookworm-slim +FROM node:24-bookworm-slim # System dependencies required by GSD RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ openssh-client \ + gosu \ && rm -rf /var/lib/apt/lists/* # Install GSD globally — version controlled via build arg @@ -29,10 +30,13 @@ RUN mkdir -p /home/gsd/.gsd && chown -R gsd:gsd /home/gsd/.gsd WORKDIR /workspace RUN chown gsd:gsd /workspace -USER gsd +# Entrypoint handles UID/GID remapping, bootstrap, and drops to gsd user +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY bootstrap.sh /usr/local/bin/bootstrap.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/bootstrap.sh # Expose default GSD web UI port EXPOSE 3000 -ENTRYPOINT ["gsd"] -CMD ["--help"] +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["gsd", "--help"] diff --git a/docker/README.md b/docker/README.md index a4bf7a65e..4d9e8ae06 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,6 +7,22 @@ Run GSD auto mode inside an isolated Docker sandbox so it cannot touch your host - Docker Desktop 4.58+ (macOS or Windows; Linux support is experimental) - At least one LLM provider API key +## Docker Images + +| File | Purpose | +|------|---------| +| `Dockerfile.sandbox` | Runtime sandbox with entrypoint (UID remapping, bootstrap) | +| `Dockerfile.ci-builder` | CI builds — includes build tools, no entrypoint magic | + +## Compose Files + +| File | Purpose | +|------|---------| +| `docker-compose.yaml` | Minimal zero-config setup — just works with sensible defaults | +| `docker-compose.full.yaml` | Fully documented reference with all options, resource limits, health checks | + +Start with `docker-compose.yaml`. Copy options from `docker-compose.full.yaml` when you need them. + ## Quick Start ### Option A: Docker Sandbox CLI (recommended) @@ -34,7 +50,7 @@ cp docker/.env.example docker/.env # Edit docker/.env with your keys # 2. Start the sandbox -docker compose -f docker/docker-compose.yml up -d +docker compose -f docker/docker-compose.yaml up -d # 3. Shell into the container docker exec -it gsd-sandbox bash @@ -43,6 +59,29 @@ docker exec -it gsd-sandbox bash gsd auto "implement the feature described in issue #42" ``` +## UID/GID Remapping + +The entrypoint handles UID/GID remapping via `PUID` and `PGID` environment variables. This avoids permission issues on bind-mounted volumes by matching the container's `gsd` user to your host UID/GID. + +```bash +# Find your host UID/GID +id -u # PUID +id -g # PGID +``` + +Set these in your `.env` file or in the `environment` section of the compose file. Defaults to `1000:1000`. + +## Entrypoint Behavior + +The container entrypoint (`entrypoint.sh`) runs four steps on every start: + +1. **UID/GID remapping** — adjusts the `gsd` user to match `PUID`/`PGID` +2. **Pre-create critical files** — prevents Docker bind-mount from creating directories where files are expected +3. **Sentinel-based bootstrap** — runs `bootstrap.sh` exactly once on first boot +4. **Drop privileges** — `exec gosu gsd` for proper PID 1 signal forwarding + +No hardcoded `user:` directive in compose — the entrypoint starts as root, remaps, then drops to `gsd`. + ## Two-Terminal Workflow GSD's recommended workflow uses two terminals — one for auto mode, one for interactive discussion: @@ -85,7 +124,7 @@ If you restrict outbound network access in your sandbox, GSD needs these endpoin Build with a specific GSD version: ```bash -docker compose -f docker/docker-compose.yml build --build-arg GSD_VERSION=2.43.0 +docker compose -f docker/docker-compose.yaml build --build-arg GSD_VERSION=2.51.0 ``` ## Cleanup @@ -95,7 +134,7 @@ docker compose -f docker/docker-compose.yml build --build-arg GSD_VERSION=2.43.0 docker sandbox rm gsd-sandbox # Docker Compose -docker compose -f docker/docker-compose.yml down -v +docker compose -f docker/docker-compose.yaml down -v ``` ## Known Limitations diff --git a/docker/bootstrap.sh b/docker/bootstrap.sh new file mode 100755 index 000000000..463952877 --- /dev/null +++ b/docker/bootstrap.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +# ────────────────────────────────────────────── +# GSD First-Boot Bootstrap +# +# Runs once on initial container creation. +# Called by entrypoint.sh as the gsd user. +# +# This script is idempotent — safe to run multiple +# times, but the sentinel in entrypoint.sh ensures +# it only runs once in practice. +# ────────────────────────────────────────────── + +# ── Git Identity ──────────────────────────────────────── +# Without this, git commits inside the container will fail +# or use garbage defaults. + +if [ -n "${GIT_AUTHOR_NAME}" ]; then + git config --global user.name "${GIT_AUTHOR_NAME}" +fi + +if [ -n "${GIT_AUTHOR_EMAIL}" ]; then + git config --global user.email "${GIT_AUTHOR_EMAIL}" +fi + +echo "Bootstrap complete." diff --git a/docker/docker-compose.full.yaml b/docker/docker-compose.full.yaml new file mode 100644 index 000000000..6ff8cad83 --- /dev/null +++ b/docker/docker-compose.full.yaml @@ -0,0 +1,61 @@ +services: + gsd: + build: + context: . # Build context is the docker/ directory + dockerfile: Dockerfile.sandbox # Runtime sandbox image with entrypoint + args: + GSD_VERSION: latest # Pin a specific version: GSD_VERSION=2.51.0 + + container_name: gsd-sandbox + + ports: + - "3000:3000" # GSD web UI + + volumes: + - ../:/workspace # Project root mounted into the container + - gsd-state:/home/gsd/.gsd # Persistent GSD state across restarts + # - ~/.ssh:/home/gsd/.ssh:ro # SSH keys for git operations (read-only) + # - ~/.gitconfig:/home/gsd/.gitconfig:ro # Host git config + + env_file: + - .env # API keys and secrets (see .env.example) + + environment: + - NODE_ENV=development + # UID/GID remapping — match your host user to avoid permission issues + # on bind-mounted volumes. The entrypoint remaps the container's gsd + # user to these IDs at startup. Run `id -u` / `id -g` to find yours. + - PUID=1000 + - PGID=1000 + # Git identity inside the container (overrides .env if set here) + # - GIT_AUTHOR_NAME=Your Name + # - GIT_AUTHOR_EMAIL=you@example.com + + stdin_open: true # Keep stdin open for interactive use + tty: true # Allocate a pseudo-TTY + + # Health check — verify GSD is installed and responsive + healthcheck: + test: ["CMD", "gsd", "--version"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # Resource limits — uncomment to constrain container resources + # deploy: + # resources: + # limits: + # cpus: "4.0" + # memory: 8G + # reservations: + # cpus: "1.0" + # memory: 2G + + # Network mode — uncomment ONE if you need host networking + # network_mode: host # Full host network access (no port mapping needed) + # network_mode: bridge # Default Docker bridge (already the default) + +volumes: + gsd-state: + driver: local diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 000000000..21641e2f1 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,23 @@ +services: + gsd: + build: + context: . + dockerfile: Dockerfile.sandbox + args: + GSD_VERSION: latest + container_name: gsd-sandbox + ports: + - "3000:3000" + volumes: + - ../:/workspace + - gsd-state:/home/gsd/.gsd + env_file: + - .env + environment: + - NODE_ENV=development + stdin_open: true + tty: true + +volumes: + gsd-state: + driver: local diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index d685f3a00..000000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Docker Compose for running GSD in a sandbox -# Usage: docker compose -f docker/docker-compose.yml up -# -# Copy docker/.env.example to docker/.env and fill in your API keys first. -# See docker/README.md for full setup instructions. - -services: - gsd: - build: - context: . - dockerfile: Dockerfile.sandbox - args: - GSD_VERSION: latest - container_name: gsd-sandbox - ports: - - "3000:3000" - volumes: - # Sync project code into the sandbox - - ../:/workspace - # Persistent GSD state across container restarts - - gsd-state:/home/gsd/.gsd - env_file: - - .env - environment: - - NODE_ENV=development - user: "1000:1000" - stdin_open: true - tty: true - # Override entrypoint for interactive shell access - # entrypoint: /bin/bash - -volumes: - gsd-state: - driver: local diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..465a28fe0 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -e + +# ────────────────────────────────────────────── +# GSD Container Entrypoint +# +# Responsibilities: +# 1. UID/GID remapping — match host user via PUID/PGID +# 2. Pre-create critical files — prevent Docker bind-mount +# from creating directories where files are expected +# 3. Sentinel-based bootstrap — one-time first-boot setup +# 4. Signal forwarding — exec into the final process +# ────────────────────────────────────────────── + +GSD_USER="gsd" +GSD_HOME="/home/${GSD_USER}" +GSD_DIR="${GSD_HOME}/.gsd" + +# ── 1. UID/GID Remapping ──────────────────────────────── +# Accept PUID/PGID from the environment so the container +# can run with the same UID/GID as the host user, avoiding +# permission headaches on bind-mounted volumes. + +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" + +CURRENT_UID=$(id -u "${GSD_USER}") +CURRENT_GID=$(id -g "${GSD_USER}") + +REMAPPED=0 + +if [ "${PGID}" != "${CURRENT_GID}" ]; then + groupmod -o -g "${PGID}" "${GSD_USER}" + REMAPPED=1 +fi + +if [ "${PUID}" != "${CURRENT_UID}" ]; then + usermod -o -u "${PUID}" "${GSD_USER}" + REMAPPED=1 +fi + +# Fix ownership only when UID/GID actually changed +if [ "${REMAPPED}" -eq 1 ]; then + chown -R "${PUID}:${PGID}" "${GSD_HOME}" + chown "${PUID}:${PGID}" /workspace +fi + +# ── 2. Pre-create Critical Files ──────────────────────── +# Docker bind-mounts will create a *directory* if the target +# path doesn't exist. We need these to be files, so touch +# them before Docker gets a chance to mangle things. + +mkdir -p "${GSD_DIR}" + +if [ ! -f "${GSD_DIR}/settings.json" ]; then + echo '{}' > "${GSD_DIR}/settings.json" +fi + +chown "${PUID}:${PGID}" "${GSD_DIR}" "${GSD_DIR}/settings.json" + +# ── 3. Sentinel-based Bootstrap ───────────────────────── +# Run first-boot setup exactly once. Subsequent container +# starts (or restarts) skip this entirely. + +SENTINEL="${GSD_DIR}/.bootstrapped" + +if [ ! -f "${SENTINEL}" ]; then + if [ -x /usr/local/bin/bootstrap.sh ]; then + # Run bootstrap as the gsd user so files get correct ownership + gosu "${GSD_USER}" /usr/local/bin/bootstrap.sh + fi + touch "${SENTINEL}" + chown "${PUID}:${PGID}" "${SENTINEL}" +fi + +# ── 4. Drop Privileges & Exec ────────────────────────── +# Replace this shell process with the final command running +# as the gsd user. exec + gosu = proper PID 1 = proper +# signal forwarding (SIGTERM, SIGINT, etc.). + +exec gosu "${GSD_USER}" "$@" diff --git a/src/tests/docker-template.test.ts b/src/tests/docker-template.test.ts index 946b20d51..dc01b3551 100644 --- a/src/tests/docker-template.test.ts +++ b/src/tests/docker-template.test.ts @@ -15,9 +15,9 @@ function readFile(relativePath: string): string { // ── Dockerfile.sandbox ── -test("docker/Dockerfile.sandbox exists and uses Node 22 base", () => { +test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => { const content = readFile("docker/Dockerfile.sandbox"); - assert.match(content, /FROM node:22/); + assert.match(content, /FROM node:24/); }); test("docker/Dockerfile.sandbox installs gsd-pi globally", () => { @@ -28,7 +28,6 @@ test("docker/Dockerfile.sandbox installs gsd-pi globally", () => { test("docker/Dockerfile.sandbox creates a non-root user", () => { const content = readFile("docker/Dockerfile.sandbox"); assert.match(content, /useradd/); - assert.match(content, /USER gsd/); }); test("docker/Dockerfile.sandbox exposes port 3000", () => { @@ -41,29 +40,47 @@ test("docker/Dockerfile.sandbox installs git", () => { assert.match(content, /git/); }); -// ── docker-compose.yml ── +// ── docker-compose.yaml (minimal) ── -test("docker/docker-compose.yml exists and defines gsd service", () => { - const content = readFile("docker/docker-compose.yml"); +test("docker/docker-compose.yaml exists and defines gsd service", () => { + const content = readFile("docker/docker-compose.yaml"); assert.match(content, /services:/); assert.match(content, /gsd:/); }); -test("docker/docker-compose.yml mounts workspace volume", () => { - const content = readFile("docker/docker-compose.yml"); +test("docker/docker-compose.yaml mounts workspace volume", () => { + const content = readFile("docker/docker-compose.yaml"); assert.match(content, /\/workspace/); }); -test("docker/docker-compose.yml references Dockerfile.sandbox", () => { - const content = readFile("docker/docker-compose.yml"); +test("docker/docker-compose.yaml references Dockerfile.sandbox", () => { + const content = readFile("docker/docker-compose.yaml"); assert.match(content, /Dockerfile\.sandbox/); }); -test("docker/docker-compose.yml maps port 3000", () => { - const content = readFile("docker/docker-compose.yml"); +test("docker/docker-compose.yaml maps port 3000", () => { + const content = readFile("docker/docker-compose.yaml"); assert.match(content, /3000:3000/); }); +test("docker/docker-compose.yaml has no hardcoded user directive", () => { + const content = readFile("docker/docker-compose.yaml"); + assert.doesNotMatch(content, /^\s+user:/m); +}); + +// ── docker-compose.full.yaml (reference) ── + +test("docker/docker-compose.full.yaml exists with health check", () => { + const content = readFile("docker/docker-compose.full.yaml"); + assert.match(content, /healthcheck:/); +}); + +test("docker/docker-compose.full.yaml documents PUID/PGID", () => { + const content = readFile("docker/docker-compose.full.yaml"); + assert.match(content, /PUID/); + assert.match(content, /PGID/); +}); + // ── .env.example ── test("docker/.env.example exists and lists ANTHROPIC_API_KEY", () => {