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
# 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.
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:
# - curl/ca-certificates: for downloads (the Claude installer, git-spice)
# - 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 \
curl \
ca-certificates \
git \
ripgrep \
bash \
&& rm -rf /var/lib/apt/lists/*
bash
# --- 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.

View File

@ -42,20 +42,26 @@ so you can launch from a subdirectory and it'll still find the right one.
.safeclaude/
Dockerfile # which system packages / language versions this project needs
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)
version # the safeclaude version this config was created with
```
A few things worth knowing:
- **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
present, or that should persist between runs (installing dependencies,
starting a database proxy), goes in a `hooks/` script that runs at launch.
- **It only rebuilds when something changed.** safeclaude remembers what it
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
starter dependency hook only reinstalls when your lockfile actually changed.
- **Hooks are safe to run every time.** They check before doing work, so a
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
copy from.

View File

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

View File

@ -5,27 +5,46 @@
FROM safeclaude-base:latest
# 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 \
build-essential \
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
libpq-dev \
socat \
chromium chromium-driver \
&& rm -rf /var/lib/apt/lists/*
xz-utils
# Capybara/Selenium look for Chrome at these paths.
ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER=/usr/bin/chromedriver
# rbenv and nvm get installed by the hooks at launch instead of here, because
# they live in the home folder — and that folder is swapped in fresh each run, so
# anything we installed there now would just be thrown away. Here we only set the
# paths and shell setup; the two lines below do nothing until a hook installs the
# matching tool.
ENV RBENV_ROOT=/home/coder/.rbenv
ENV NVM_DIR=/home/coder/.nvm
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
# --- Ruby (one pinned version) ---
# A project only ever needs one Ruby, so we install it straight into /usr/local
# instead of running a version manager. ruby-build (the tool rbenv uses under the
# hood) does the download + compile. To change versions, bump RUBY_VERSION and
# rebuild with `safeclaude build`.
ARG RUBY_VERSION=3.3.6
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build && \
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 && \
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc
# Gems install into the project's cache folder (which lives on the host), so they
# 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
# 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
# near-instant no-op.
# version it installed, so an unchanged lockfile is a 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
[ -f /code/Gemfile ] || exit 0
eval "$(rbenv init - bash)"
CACHE=/code/.safeclaude/cache
mkdir -p "$CACHE"
LOCK=/code/Gemfile.lock
MARKER="$HOME/.safeclaude-deps/gemfile.sha"
mkdir -p "$(dirname "$MARKER")"
MARKER="$CACHE/gemfile.sha"
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
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
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the
Postgres client, and headless Chrome for browser tests. It's here as a reference
you can copy from when setting up your own project.
A filled-in `.safeclaude/` for a typical Rails-style app — a pinned Ruby and
Node, the Postgres client, and headless Chrome for browser tests. It's here as a
reference you can copy from when setting up your own project.
## 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 |
| --- | --- | --- |
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers |
| `hooks/10-ruby.sh` | each launch | installs the project's Ruby and bundler (skips if already done) |
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, a pinned Ruby + Node, Chrome, and bundler |
| `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/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) |
| `version` | — | the safeclaude version this config was created with |
The pattern to take away: slow, one-time installs go in the **Dockerfile** so
they're cached; anything that needs your code or has to stick around between
runs goes in a **hook**.
A couple of things to take away:
- 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.
set -euo pipefail
SAFECLAUDE_VERSION="0.1.0"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
BASE_IMAGE="safeclaude-base:latest"
@ -19,6 +20,7 @@ Usage:
safeclaude build [PATH] Rebuild the project's container from scratch
safeclaude init [PATH] Create a starter .safeclaude/ folder
safeclaude envs List the containers and saved data safeclaude made
safeclaude version Print the safeclaude version
safeclaude help Show this help
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 matches what we already built, we skip rebuilding. (We leave .env
# out on purpose — changing a secret shouldn't force a rebuild.)
# fingerprint matches what we already built, we skip rebuilding. We skip .env
# (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() {
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
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
} | sha256sum | cut -c1-12
@ -87,6 +90,19 @@ cmd_run() {
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
[ -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[@]}" \
--user 1001:1001 \
--cap-drop ALL \
@ -96,7 +112,7 @@ cmd_run() {
-v "${proj}:/code" \
-w /code \
"${env_args[@]}" \
"$image" claude "$@"
"$image" claude --append-system-prompt "$sys_prompt" "$@"
}
cmd_build() {
@ -121,7 +137,10 @@ cmd_init() {
[ -d "$target" ] || die "not a directory: $target"
local sc; sc="$(cd "$target" && pwd)/.safeclaude"
[ -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'
# 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 \
# build-essential libssl-dev libreadline-dev zlib1g-dev \
# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver \
# && rm -rf /var/lib/apt/lists/*
# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver
#
# Tip: tools that need to stick around between runs (rbenv, nvm, installed gems)
# should be set up in hooks/, not herethe home folder is swapped in fresh each
# run, so anything installed into it during the build would just disappear.
# Tip: pin specific language versions here (install one Ruby, one Node, etc.)
# rather than a version manager — a project only needs one. See the repo's
# example/ for how.
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.
EOF
cat > "$sc/hooks/10-deps.sh.example" <<'EOF'
cat > "$sc/hooks/10-setup.sh" <<'EOF'
#!/bin/bash
# Installs your dependencies, but only when they've actually changed: it
# remembers the last lockfile it saw (saved between runs) and skips if it matches.
# Runs every time the container starts, with your project at /code. Put your
# 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
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
cat > "$sc/.env.example" <<'EOF'
@ -191,11 +205,12 @@ EOF
cat > "$sc/.gitignore" <<'EOF'
.env
cache/
EOF
echo "[safeclaude] created $sc"
echo " - edit Dockerfile to add the packages your project needs"
echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh"
echo " - edit Dockerfile to add the packages and language versions you need"
echo " - edit hooks/10-setup.sh for any startup setup"
echo " - then run: safeclaude $target"
}
@ -205,6 +220,7 @@ main() {
build) shift; cmd_build "${1:-.}" ;;
init) shift; cmd_init "${1:-.}" ;;
envs|ls|list) cmd_envs ;;
version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;;
help|-h|--help) usage ;;
run) shift; cmd_run "$@" ;;
*) cmd_run "$@" ;; # default: PATH and/or claude args