first round of changes: dedicated cache folder, cleaner base image, version management

This commit is contained in:
Justin
2026-06-20 10:56:44 -04:00
parent 4b3df1ddae
commit ecfdbd9f98
10 changed files with 122 additions and 110 deletions

View File

@ -5,18 +5,24 @@
# This image runs as root so project Dockerfiles can install system packages # This image runs as root so project Dockerfiles can install system packages
# freely. Don't worry — the launcher locks things down at run time (normal user, # freely. Don't worry — the launcher locks things down at run time (normal user,
# no special privileges), so nothing Claude does actually runs as root. # no special privileges), so nothing Claude does actually runs as root.
FROM node:22-slim #
# Debian (slim) as the base: small, stable, and its apt packages behave
# predictably in a container — notably `chromium` is a real package here, unlike
# on Ubuntu where it's a snap stub that won't run in a container.
FROM debian:bookworm-slim
# Just the basics every project needs: # Just the basics every project needs:
# - curl/ca-certificates: for downloads (the Claude installer, git-spice) # - curl/ca-certificates: for downloads (the Claude installer, git-spice)
# - git + ripgrep: required by Claude Code # - git + ripgrep: required by Claude Code
# We leave apt's package lists in place (no cleanup) so a project Dockerfile can
# install packages without re-fetching them — though it should still run its own
# `apt-get update` first, since this base image may be days or weeks old.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
git \ git \
ripgrep \ ripgrep \
bash \ bash
&& rm -rf /var/lib/apt/lists/*
# --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) --- # --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) ---
# Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch. # Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch.

View File

@ -42,20 +42,26 @@ so you can launch from a subdirectory and it'll still find the right one.
.safeclaude/ .safeclaude/
Dockerfile # which system packages / language versions this project needs Dockerfile # which system packages / language versions this project needs
hooks/ # setup scripts that run each time the container starts hooks/ # setup scripts that run each time the container starts
cache/ # scratch space on the host, gitignored (dependencies, downloads…)
.env # secrets, kept out of git (.env.example shows the format) .env # secrets, kept out of git (.env.example shows the format)
version # the safeclaude version this config was created with
``` ```
A few things worth knowing: A few things worth knowing:
- **Two places setup can live, and the difference matters.** Slow, one-time - **Two places setup can live, and the difference matters.** Slow, one-time
installs (system packages, a language toolchain) go in the `Dockerfile` installs (system packages, a pinned language version) go in the `Dockerfile`
these get cached, so they don't repeat. Anything that needs your actual code these get cached, so they don't repeat. Anything that needs your actual code
present, or that should persist between runs (installing dependencies, present, or that should persist between runs (installing dependencies,
starting a database proxy), goes in a `hooks/` script that runs at launch. starting a database proxy), goes in a `hooks/` script that runs at launch.
- **It only rebuilds when something changed.** safeclaude remembers what it - **It only rebuilds when something changed.** safeclaude remembers what it
already built, so a normal launch starts right up with no waiting. already built, so a normal launch starts right up with no waiting.
- **Hooks are safe to run every time.** They check before doing work — e.g. the - **Hooks are safe to run every time.** They check before doing work, so a
starter dependency hook only reinstalls when your lockfile actually changed. launch with nothing to do is near-instant.
- **`cache/` is your scratch space.** It lives on the host and is gitignored, so
it survives rebuilds and `docker volume` resets without ending up in your repo
— a good home for installed dependencies, downloads, or "already did this"
markers.
See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can
copy from. copy from.

View File

@ -1 +1,2 @@
.env .env
cache/

View File

@ -5,27 +5,46 @@
FROM safeclaude-base:latest FROM safeclaude-base:latest
# System packages: what's needed to build Ruby, talk to Postgres, proxy the # System packages: what's needed to build Ruby, talk to Postgres, proxy the
# database (socat), and run browser tests (headless Chrome). # database (socat), run browser tests (headless Chrome), and unpack the Node
# download below (xz-utils).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \ libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
libpq-dev \ libpq-dev \
socat \ socat \
chromium chromium-driver \ chromium chromium-driver \
&& rm -rf /var/lib/apt/lists/* xz-utils
# Capybara/Selenium look for Chrome at these paths. # Capybara/Selenium look for Chrome at these paths.
ENV CHROME_BIN=/usr/bin/chromium ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER=/usr/bin/chromedriver ENV CHROMEDRIVER=/usr/bin/chromedriver
# rbenv and nvm get installed by the hooks at launch instead of here, because # --- Ruby (one pinned version) ---
# they live in the home folder — and that folder is swapped in fresh each run, so # A project only ever needs one Ruby, so we install it straight into /usr/local
# anything we installed there now would just be thrown away. Here we only set the # instead of running a version manager. ruby-build (the tool rbenv uses under the
# paths and shell setup; the two lines below do nothing until a hook installs the # hood) does the download + compile. To change versions, bump RUBY_VERSION and
# matching tool. # rebuild with `safeclaude build`.
ENV RBENV_ROOT=/home/coder/.rbenv ARG RUBY_VERSION=3.3.6
ENV NVM_DIR=/home/coder/.nvm RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build && \
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH" PREFIX=/usr/local /tmp/ruby-build/install.sh && \
rm -rf /tmp/ruby-build && \
ruby-build "$RUBY_VERSION" /usr/local && \
gem install bundler --no-document
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc && \ # Gems install into the project's cache folder (which lives on the host), so they
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc # persist between runs and survive container/volume resets. The bundle hook
# relies on this too. BUNDLE_PATH is set here so both the hook and your app see it.
ENV BUNDLE_PATH=/code/.safeclaude/cache/bundle
# --- Node (one pinned version) ---
# Same idea: download one Node and unpack it into /usr/local. Bump NODE_VERSION
# and rebuild to change it.
ARG NODE_VERSION=22.11.0
RUN arch="$(uname -m)" && \
case "$arch" in \
x86_64) narch=x64 ;; \
aarch64) narch=arm64 ;; \
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
esac && \
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${narch}.tar.xz" \
| tar -xJ -C /usr/local --strip-components=1

View File

@ -1,31 +0,0 @@
#!/bin/bash
# Sets up rbenv and the project's Ruby. Safe to run every launch — it only does
# real work the first time, or when the Ruby version changes.
set -euo pipefail
# Grab rbenv (cloned into the home folder so it sticks around between runs).
if [ ! -d "$RBENV_ROOT/bin" ]; then
echo "[ruby] installing rbenv..."
git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT"
git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build"
fi
eval "$(rbenv init - bash)"
# Pick the Ruby version: the project's .ruby-version wins, then whatever rbenv
# was last set to, then a sensible default.
if [ -f /code/.ruby-version ]; then
RUBY_VERSION="$(tr -d '[:space:]' < /code/.ruby-version)"
elif [ -f "$RBENV_ROOT/version" ]; then
RUBY_VERSION="$(cat "$RBENV_ROOT/version")"
else
RUBY_VERSION="3.3.6"
fi
if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
echo "[ruby] installing Ruby $RUBY_VERSION (first time only — takes a few minutes)..."
rbenv install "$RUBY_VERSION"
fi
rbenv global "$RUBY_VERSION"
# Make sure bundler is installed for this Ruby.
gem list bundler -i &>/dev/null || gem install bundler --no-document

View File

@ -1,15 +0,0 @@
#!/bin/bash
# OPTIONAL — only needed if your project pins a Node version via .nvmrc (the base
# already includes a working Node). Rename to 15-node.sh to turn it on.
set -euo pipefail
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "[node] installing nvm..."
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \
| NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash
fi
. "$NVM_DIR/nvm.sh"
if [ -f /code/.nvmrc ]; then
( cd /code && nvm install && nvm use )
fi

View File

@ -1,15 +1,19 @@
#!/bin/bash #!/bin/bash
# Installs gems, but only when Gemfile.lock has changed: it remembers the last # Installs gems, but only when Gemfile.lock has changed: it remembers the last
# version it installed (saved between runs), so an unchanged lockfile is a # version it installed, so an unchanged lockfile is a near-instant no-op.
# near-instant no-op. #
# Both the gems (via BUNDLE_PATH, set in the Dockerfile) and the marker below
# live in /code/.safeclaude/cache — on the host, so they persist between runs
# and stay out of git. Ruby is already on PATH from the Dockerfile, so there's
# no version manager to initialize here.
set -euo pipefail set -euo pipefail
[ -f /code/Gemfile ] || exit 0 [ -f /code/Gemfile ] || exit 0
eval "$(rbenv init - bash)"
CACHE=/code/.safeclaude/cache
mkdir -p "$CACHE"
LOCK=/code/Gemfile.lock LOCK=/code/Gemfile.lock
MARKER="$HOME/.safeclaude-deps/gemfile.sha" MARKER="$CACHE/gemfile.sha"
mkdir -p "$(dirname "$MARKER")"
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )" CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then

View File

@ -0,0 +1 @@
0.1.0

View File

@ -1,8 +1,8 @@
# Example: a Ruby + Postgres project # Example: a Ruby + Postgres project
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the A filled-in `.safeclaude/` for a typical Rails-style app — a pinned Ruby and
Postgres client, and headless Chrome for browser tests. It's here as a reference Node, the Postgres client, and headless Chrome for browser tests. It's here as a
you can copy from when setting up your own project. reference you can copy from when setting up your own project.
## How to use it ## How to use it
@ -16,13 +16,18 @@ you can copy from when setting up your own project.
| File | When it runs | What it does | | File | When it runs | What it does |
| --- | --- | --- | | --- | --- | --- |
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers | | `.safeclaude/Dockerfile` | once, then cached | installs system packages, a pinned Ruby + Node, Chrome, and bundler |
| `hooks/10-ruby.sh` | each launch | installs the project's Ruby and bundler (skips if already done) |
| `hooks/20-bundle.sh` | each launch | runs `bundle install`, but only when `Gemfile.lock` changed | | `hooks/20-bundle.sh` | each launch | runs `bundle install`, but only when `Gemfile.lock` changed |
| `hooks/30-pg-proxy.sh` | each launch | lets the app reach the host's Postgres at the usual `127.0.0.1:5432` | | `hooks/30-pg-proxy.sh` | each launch | lets the app reach the host's Postgres at the usual `127.0.0.1:5432` |
| `hooks/15-node.sh.example` | off by default | optional Node setup — rename to `15-node.sh` to turn on |
| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) | | `.env.example` | — | copy to `.env` for a private gem token (kept out of git) |
| `version` | — | the safeclaude version this config was created with |
The pattern to take away: slow, one-time installs go in the **Dockerfile** so A couple of things to take away:
they're cached; anything that needs your code or has to stick around between
runs goes in a **hook**. - Slow, one-time installs go in the **Dockerfile** so they're cached. Pin one
Ruby and one Node there directly — a project only needs one of each, so a
version manager would just be overhead. Anything that needs your code present,
or has to stick around between runs, goes in a **hook**.
- `cache/` is the project's scratch space, on the host and gitignored. Here the
gems install into it (`BUNDLE_PATH`), so they survive container and volume
resets without ever touching your repo.

View File

@ -5,6 +5,7 @@
# .safeclaude/ folder. We find it, build the container once, then reuse it. # .safeclaude/ folder. We find it, build the container once, then reuse it.
set -euo pipefail set -euo pipefail
SAFECLAUDE_VERSION="0.1.0"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
BASE_IMAGE="safeclaude-base:latest" BASE_IMAGE="safeclaude-base:latest"
@ -19,6 +20,7 @@ Usage:
safeclaude build [PATH] Rebuild the project's container from scratch safeclaude build [PATH] Rebuild the project's container from scratch
safeclaude init [PATH] Create a starter .safeclaude/ folder safeclaude init [PATH] Create a starter .safeclaude/ folder
safeclaude envs List the containers and saved data safeclaude made safeclaude envs List the containers and saved data safeclaude made
safeclaude version Print the safeclaude version
safeclaude help Show this help safeclaude help Show this help
A project is any folder (or a parent of it) that has a .safeclaude/ folder. A project is any folder (or a parent of it) that has a .safeclaude/ folder.
@ -41,12 +43,13 @@ find_project_root() {
} }
# Fingerprint the project's setup so we can tell when it changed. If the # Fingerprint the project's setup so we can tell when it changed. If the
# fingerprint matches what we already built, we skip rebuilding. (We leave .env # fingerprint matches what we already built, we skip rebuilding. We skip .env
# out on purpose — changing a secret shouldn't force a rebuild.) # (changing a secret shouldn't force a rebuild) and cache/ (it's scratch space
# the hooks write to constantly — hashing it would rebuild on every run).
context_hash() { context_hash() {
local sc="$1/.safeclaude" local sc="$1/.safeclaude"
{ {
find "$sc" -type f -not -name '.env' -not -name '.env.*' -print0 \ find "$sc" -type f -not -name '.env' -not -name '.env.*' -not -path "$sc/cache/*" -print0 \
| sort -z | xargs -0 sha256sum | sort -z | xargs -0 sha256sum
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
} | sha256sum | cut -c1-12 } | sha256sum | cut -c1-12
@ -87,6 +90,19 @@ cmd_run() {
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env") [ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
[ -t 0 ] && tty_args=(-it) [ -t 0 ] && tty_args=(-it)
# Tell the inner Claude where it is and what it can/can't do. The host path is
# included so it can give the developer exact instructions. Built here (not in
# the container) because only the launcher knows the real host path.
local sys_prompt
sys_prompt="You are running inside safeclaude, a sandboxed Docker container — not directly on the host machine.
- This project is mounted at /code. On the host it lives at: ${proj}
- You run as the non-root user 'coder'. You cannot use sudo or install system packages.
- The .safeclaude/ directory configures this container. NEVER edit anything under .safeclaude/ yourself — instead, tell the developer exactly what to change and have them do it on the host.
- To add a system package: ask the developer to add it to .safeclaude/Dockerfile and run 'safeclaude build' on the host.
- To add setup that runs at container startup: ask the developer to add a script to .safeclaude/hooks/.
- /home/coder persists between runs. Edits under /code are real and shared with the host. Everything else is discarded when the container exits."
exec docker run --rm "${tty_args[@]}" \ exec docker run --rm "${tty_args[@]}" \
--user 1001:1001 \ --user 1001:1001 \
--cap-drop ALL \ --cap-drop ALL \
@ -96,7 +112,7 @@ cmd_run() {
-v "${proj}:/code" \ -v "${proj}:/code" \
-w /code \ -w /code \
"${env_args[@]}" \ "${env_args[@]}" \
"$image" claude "$@" "$image" claude --append-system-prompt "$sys_prompt" "$@"
} }
cmd_build() { cmd_build() {
@ -121,7 +137,10 @@ cmd_init() {
[ -d "$target" ] || die "not a directory: $target" [ -d "$target" ] || die "not a directory: $target"
local sc; sc="$(cd "$target" && pwd)/.safeclaude" local sc; sc="$(cd "$target" && pwd)/.safeclaude"
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc" [ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
mkdir -p "$sc/hooks" mkdir -p "$sc/hooks" "$sc/cache"
# Record which safeclaude version created this, for future reference.
echo "$SAFECLAUDE_VERSION" > "$sc/version"
cat > "$sc/Dockerfile" <<'EOF' cat > "$sc/Dockerfile" <<'EOF'
# safeclaude builds this on top of its shared base. Everything here happens once # safeclaude builds this on top of its shared base. Everything here happens once
@ -135,12 +154,11 @@ FROM safeclaude-base:latest
# #
# RUN apt-get update && apt-get install -y --no-install-recommends \ # RUN apt-get update && apt-get install -y --no-install-recommends \
# build-essential libssl-dev libreadline-dev zlib1g-dev \ # build-essential libssl-dev libreadline-dev zlib1g-dev \
# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver \ # libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver
# && rm -rf /var/lib/apt/lists/*
# #
# Tip: tools that need to stick around between runs (rbenv, nvm, installed gems) # Tip: pin specific language versions here (install one Ruby, one Node, etc.)
# should be set up in hooks/, not herethe home folder is swapped in fresh each # rather than a version manager — a project only needs one. See the repo's
# run, so anything installed into it during the build would just disappear. # example/ for how.
EOF EOF
cat > "$sc/hooks/README.md" <<'EOF' cat > "$sc/hooks/README.md" <<'EOF'
@ -158,28 +176,24 @@ Edits take effect on the next launch (no rebuild needed). Rename a
`*.sh.example` file to `*.sh` to turn it on. `*.sh.example` file to `*.sh` to turn it on.
EOF EOF
cat > "$sc/hooks/10-deps.sh.example" <<'EOF' cat > "$sc/hooks/10-setup.sh" <<'EOF'
#!/bin/bash #!/bin/bash
# Installs your dependencies, but only when they've actually changed: it # Runs every time the container starts, with your project at /code. Put your
# remembers the last lockfile it saw (saved between runs) and skips if it matches. # project's startup setup here — installing dependencies, preparing services,
# and so on. It's empty by default; add what you need.
#
# Keep it safe to run every launch: check whether something is already done
# before doing it, so repeat launches stay quick.
#
# Need to keep things between runs? Write them to /code/.safeclaude/cache. That
# folder lives on the host (not inside the container), so it survives rebuilds
# and `docker volume` resets, and it's gitignored so it won't land in your repo.
# It's a good home for installed dependencies, downloads, or "already did this"
# markers.
#
# For example, you might install gems into the cache, run `npm install`, or wait
# for a service to come up. See the repo's example/ for a worked version.
set -euo pipefail set -euo pipefail
install_if_changed() {
local lockfile="$1"; shift
[ -f "$lockfile" ] || return 0
local marker="$HOME/.safeclaude-deps/$(echo "$lockfile" | tr / _).sha"
mkdir -p "$(dirname "$marker")"
local cur; cur="$(sha256sum "$lockfile" | cut -d' ' -f1)"
if [ "$(cat "$marker" 2>/dev/null || true)" != "$cur" ]; then
echo "[deps] $lockfile changed — installing..."
"$@" && echo "$cur" > "$marker"
fi
}
# Uncomment the ones your project uses:
# install_if_changed /code/Gemfile.lock bash -lc 'cd /code && bundle install'
# install_if_changed /code/package-lock.json bash -lc 'cd /code && npm ci'
# install_if_changed /code/requirements.txt bash -lc 'pip install -r /code/requirements.txt'
EOF EOF
cat > "$sc/.env.example" <<'EOF' cat > "$sc/.env.example" <<'EOF'
@ -191,11 +205,12 @@ EOF
cat > "$sc/.gitignore" <<'EOF' cat > "$sc/.gitignore" <<'EOF'
.env .env
cache/
EOF EOF
echo "[safeclaude] created $sc" echo "[safeclaude] created $sc"
echo " - edit Dockerfile to add the packages your project needs" echo " - edit Dockerfile to add the packages and language versions you need"
echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh" echo " - edit hooks/10-setup.sh for any startup setup"
echo " - then run: safeclaude $target" echo " - then run: safeclaude $target"
} }
@ -205,6 +220,7 @@ main() {
build) shift; cmd_build "${1:-.}" ;; build) shift; cmd_build "${1:-.}" ;;
init) shift; cmd_init "${1:-.}" ;; init) shift; cmd_init "${1:-.}" ;;
envs|ls|list) cmd_envs ;; envs|ls|list) cmd_envs ;;
version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
run) shift; cmd_run "$@" ;; run) shift; cmd_run "$@" ;;
*) cmd_run "$@" ;; # default: PATH and/or claude args *) cmd_run "$@" ;; # default: PATH and/or claude args