diff --git a/.claudio b/.claudio index 11a7d06..d0db9be 100644 --- a/.claudio +++ b/.claudio @@ -19,6 +19,23 @@ MOUNTS=( GUI=0 AUDIO=0 +# ROOT=1: passwordless sudo inside the container (e.g. to apt-get install tools). +# System changes are lost when the container exits, unless PERSIST=1. +ROOT=0 + +# PERSIST=1: keep this project's container between runs, so system changes +# (packages installed with sudo, files outside mounts) survive. +# It is recreated, losing them, when the settings here or the image +# change. `claudio reset` removes it. +PERSIST=0 + +# Extra Debian packages, built into a per-project image. Unlike changes made +# with sudo, these survive `claudio update` and PERSIST recreations. +PACKAGES=( + # python3 + # nodejs +) + # Extra `podman run` options (env vars, ports, resources, ...). PODMAN_ARGS=( # -e GITHUB_TOKEN diff --git a/Containerfile b/Containerfile index 70f7aa0..e64014d 100644 --- a/Containerfile +++ b/Containerfile @@ -4,14 +4,19 @@ FROM docker.io/library/debian:stable-slim # Base tools Claude Code relies on. Add project-wide tools here (python3, nodejs, ...). # The second line is for GUI=1 / AUDIO=1: OpenGL drivers, a font, PulseAudio client. +# sudo is for ROOT=1: the "sudo" group gets passwordless root. claude is not a +# member; claudio.sh adds the group at run time only when ROOT=1. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl git ripgrep less procps openssh-client \ - libgl1-mesa-dri fonts-dejavu-core libpulse0 libasound2-plugins \ + libgl1-mesa-dri fonts-dejavu-core libpulse0 libasound2-plugins sudo \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -u 1000 -s /bin/bash claude \ && install -d -m 700 -o claude -g claude /run/user/1000 \ - && printf 'pcm.!default pulse\nctl.!default pulse\n' > /etc/asound.conf + && printf 'pcm.!default pulse\nctl.!default pulse\n' > /etc/asound.conf \ + && echo '%sudo ALL=(ALL:ALL) NOPASSWD: ALL' > /etc/sudoers.d/claudio \ + && chmod 440 /etc/sudoers.d/claudio \ + && echo 'Set disable_coredump false' >> /etc/sudo.conf # Native Claude Code binary, installed system-wide (not in $HOME) so that image # rebuilds update it even though /home/claude is a persistent volume. diff --git a/README.md b/README.md index 4051044..57f9cd1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Run Claude Code isolated in a podman container, one command per project. - `Containerfile`: the image (Debian slim + Claude Code). - `claudio.sh`: builds, updates and runs the container. - `.claudio`: template for per-project settings (plain bash). +- `test.sh`: end-to-end tests, run on the host (`./test.sh`, or `./test.sh --update` + to also test the slow `claudio update`). ## Install @@ -27,6 +29,7 @@ claudio --resume # any extra args are passed to claude claudio shell # bash inside the container instead of claude claudio build # build the image claudio update # rebuild from scratch: latest base image + latest Claude Code +claudio reset # remove this project's persistent container (PERSIST=1) ``` The current directory is mounted at the same path inside the container. @@ -43,6 +46,9 @@ cp ~/claudio/.claudio ~/projects/myapp/ ```bash GUI=1 AUDIO=1 +ROOT=1 +PERSIST=1 +PACKAGES=(python3 nodejs) MOUNTS=("$HOME/.gitconfig:/home/claude/.gitconfig:ro" "/data/shared") PODMAN_ARGS=(-e GITHUB_TOKEN -p 3000:3000) CLAUDE_ARGS=(--model opus) @@ -50,6 +56,9 @@ CLAUDE_ARGS=(--model opus) - `GUI=1`: graphical apps open on your desktop (see below). - `AUDIO=1`: apps can play sound (see below). +- `ROOT=1`: passwordless `sudo` inside the container (see below). +- `PERSIST=1`: keep the container, and system changes, between runs (see below). +- `PACKAGES`: extra Debian packages for this project (see below). - `MOUNTS`: extra `-v` mounts. A bare path is mounted at the same path. Add `:ro` for read-only. - `PODMAN_ARGS`: extra `podman run` options. - `CLAUDE_ARGS`: default arguments for `claude`. @@ -73,11 +82,46 @@ Both are off by default and work by sharing a single socket from your session. The image only ships OpenGL drivers, a font and the PulseAudio client. Add the apps you want to run (and their libraries) to the `apt-get install` line. +## Root access + +`ROOT=1` lets Claude (and you, in `claudio shell`) run `sudo` without a +password, for example to `apt-get install` a missing tool. It is off by default: +the image grants passwordless sudo to the `sudo` group, and claudio only adds +you to that group when `ROOT=1`. + +- System changes last only as long as the container, which is removed after + every run unless `PERSIST=1` (see below). +- With rootless podman, root in the container is still your unprivileged host + user, so it gets no access to the host beyond what is already mounted. With + rootful podman it is real root (limited by the container's capabilities). + +## Keeping system changes + +There are two ways, and they combine well: + +- `PACKAGES=(python3 nodejs)` builds a per-project image with those packages + on top of the base image (`localhost/claudio:pkgs-`). It is rebuilt + automatically when the list changes or after `claudio update`, so this is + the place for anything the project always needs. Projects with the same list + share the image. +- `PERSIST=1` keeps one container per project instead of a fresh one each run, + so anything done with `sudo` (or written outside the mounts) is still there + next time. All `claudio` and `claudio shell` sessions in the project share + it, and it is stopped when the last one exits. It is **recreated, losing + those changes**, when the image changes (`claudio update`, a new + `PACKAGES` list) or when the container's options change (`MOUNTS`, `GUI`, + `AUDIO`, `ROOT`, `PODMAN_ARGS`); editing `CLAUDE_ARGS` or comments does not. Run `claudio reset` + to start it over by hand. Background processes do not survive the stop, and + `-e VAR` values in `PODMAN_ARGS` are read when the container is created. + +A good habit: let Claude install things ad hoc with `PERSIST=1`, and move the +ones worth keeping into `PACKAGES`. + ## State The container's home (`/home/claude`) lives in the podman volume `claudio-home`. This volume is shared by all projects and holds your login, settings and history. -The container itself is removed after every run. +The container itself is removed after every run, unless `PERSIST=1`. - Reset everything: `podman volume rm claudio-home` - Need more tools in the image? Add them to the `apt-get install` line, then run `claudio update`. diff --git a/claudio.sh b/claudio.sh index 0a2b2df..7251937 100755 --- a/claudio.sh +++ b/claudio.sh @@ -8,10 +8,14 @@ # claudio shell [bash args] open a bash shell in the container instead # claudio build build the image # claudio update rebuild from scratch (latest Debian + Claude Code) +# claudio reset remove this project's container (PERSIST=1) # # The current directory is mounted at its own path. If it contains a .claudio # file, that file is sourced (bash) for extra mounts and options, and can set -# GUI=1 / AUDIO=1 to share the host's Wayland / PulseAudio sockets. +# GUI=1 / AUDIO=1 to share the host's Wayland / PulseAudio sockets, ROOT=1 +# to allow passwordless sudo inside the container, PACKAGES=(...) to build a +# per-project image with extra Debian packages, or PERSIST=1 to keep the +# container (and any system changes) between runs. # The container's home (/home/claude: login, settings, history) lives in the # global podman volume "claudio-home". set -euo pipefail @@ -24,7 +28,10 @@ build() { podman build -t "$IMAGE" "$@" "$DIR"; } case "${1:-}" in build) build; exit ;; - update) build --pull=always --no-cache && podman image prune -f; exit ;; + # PACKAGES images built on the old base are stale; they are rebuilt on demand. + update) build --pull=always --no-cache \ + && podman image prune -af --filter label=claudio.packages \ + && podman image prune -f; exit ;; esac if [[ "$PWD" == "$HOME" || "$PWD" == / ]]; then @@ -32,12 +39,36 @@ if [[ "$PWD" == "$HOME" || "$PWD" == / ]]; then exit 1 fi +# PERSIST=1 container for this directory: readable name + hash of the full path. +name="claudio-$(printf %s "${PWD##*/}" | tr -c 'a-zA-Z0-9_.-' -)-$(printf %s "$PWD" | sha256sum | cut -c1-8)" + +if [[ "${1:-}" == reset ]]; then + podman rm -f --ignore "$name" >/dev/null + exit +fi + podman image exists "$IMAGE" || build # Project settings (see the .claudio template). -MOUNTS=() PODMAN_ARGS=() CLAUDE_ARGS=() GUI=0 AUDIO=0 +MOUNTS=() PODMAN_ARGS=() CLAUDE_ARGS=() PACKAGES=() GUI=0 AUDIO=0 ROOT=0 PERSIST=0 if [[ -f .claudio ]]; then source ./.claudio; fi +# PACKAGES: a derived image, tagged by base image + package list, so changing +# either one gives a new tag that gets built on first use. +image="$IMAGE" +if (( ${#PACKAGES[@]} )); then + image="localhost/claudio:pkgs-$(printf '%s\n' "$(podman image inspect -f '{{.Id}}' "$IMAGE")" \ + "${PACKAGES[@]}" | sha256sum | cut -c1-12)" + podman image exists "$image" || podman build -t "$image" --label claudio.packages=1 -f - "$DIR" < same path inside the container @@ -64,19 +95,54 @@ if [[ "$AUDIO" == 1 ]]; then args+=(-e PULSE_SERVER=unix:/run/user/1000/pulse/native) fi -if [[ "${1:-}" == shell ]]; then - shift - args+=(--entrypoint bash) - CLAUDE_ARGS=() -fi - -if [[ -t 0 && -t 1 ]]; then args+=(-it); else args+=(-i); fi +# ROOT: the image gives the "sudo" group passwordless sudo; join it only on request. +if [[ "$ROOT" == 1 ]]; then args+=(--group-add sudo); fi # keep-id maps your host user to the container's uid 1000, so files written # in mounts stay owned by you. label=disable avoids SELinux relabeling of mounts. -exec podman run --rm "${args[@]}" \ - --userns=keep-id:uid=1000,gid=1000 \ - --security-opt label=disable \ - --hostname claudio \ - "${PODMAN_ARGS[@]}" \ - "$IMAGE" "${CLAUDE_ARGS[@]}" "$@" +args+=(--userns=keep-id:uid=1000,gid=1000 --security-opt label=disable --hostname claudio + "${PODMAN_ARGS[@]}") + +cmd=(claude "${CLAUDE_ARGS[@]}") +if [[ "${1:-}" == shell ]]; then + shift + cmd=(bash) +fi + +if [[ -t 0 && -t 1 ]]; then tty=(-it); else tty=(-i); fi + +if [[ "$PERSIST" != 1 ]]; then + exec podman run --rm "${tty[@]}" "${args[@]}" --entrypoint "${cmd[0]}" \ + "$image" "${cmd[@]:1}" "$@" +fi + +# PERSIST: the container idles under `sleep` and every session is a `podman +# exec` into it. It is recreated when the image or the options above change. +config="$(printf '%s\n' "$(podman image inspect -f '{{.Id}}' "$image")" "${args[@]}" \ + | sha256sum | cut -c1-12)" +current="$(podman container inspect -f '{{index .Config.Labels "claudio.config"}}' "$name" 2>/dev/null)" \ + || current=none +if [[ "$current" != "$config" && "$current" != none ]]; then + if [[ "$(podman container inspect -f '{{.State.Running}}' "$name")" == true ]]; then + echo "claudio: settings or image changed, but $name is in use by another session." >&2 + echo "claudio: close it (or run 'claudio reset') to recreate the container." >&2 + exit 1 + fi + echo "claudio: settings or image changed, recreating $name (system changes are lost)." >&2 + podman rm "$name" >/dev/null + current=none +fi +if [[ "$current" == none ]]; then + podman create --name "$name" --label claudio.config="$config" --init "${args[@]}" \ + --entrypoint sleep "$image" infinity >/dev/null +fi + +podman start "$name" >/dev/null +status=0 +podman exec "${tty[@]}" -w "$PWD" "$name" "${cmd[@]}" "$@" || status=$? + +# Stop once no session is left. Exec'd processes (and the init) have parent +# pid 0, so with only the init and this ps left there are no sessions. +left="$(podman exec "$name" ps -e -o ppid= | grep -cx ' *0')" || left=0 +if (( left <= 2 )); then podman stop -t 2 --ignore "$name" >/dev/null; fi +exit "$status" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..cede04a --- /dev/null +++ b/test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# test.sh — end-to-end tests for claudio, run on the host with the real podman. +# +# Usage: ./test.sh build the image (cached if unchanged) and test +# ./test.sh --update also test `claudio update` (slow: full rebuild) +# +# It works in throwaway directories under /tmp and removes the containers and +# PACKAGES images it creates. The shared claudio-home volume is not touched. +set -uo pipefail + +CLAUDIO="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/claudio.sh" +proj="$(mktemp -d /tmp/claudio-test.XXXXXX)" +extra="$(mktemp -d /tmp/claudio-test-mount.XXXXXX)" +# Same formula as claudio.sh, to find this project's PERSIST container. +name="claudio-$(printf %s "${proj##*/}" | tr -c 'a-zA-Z0-9_.-' -)-$(printf %s "$proj" | sha256sum | cut -c1-8)" +pkg_images() { podman images -q --filter label=claudio.packages | sort -u; } +pkg_before="$(pkg_images)" + +cleanup() { + (cd "$proj" && "$CLAUDIO" reset) + comm -13 <(echo "$pkg_before") <(pkg_images) | xargs -r podman rmi -f >/dev/null + rm -rf "$proj" "$extra" +} +trap cleanup EXIT + +pass=0 fail=0 +check() { # check "description" command... + if "${@:2}"; then echo " ok $1"; ((pass++)) + else echo " FAIL $1"; ((fail++)); fi +} +not() { ! "$@"; } +cfg() { printf '%s\n' "$@" > "$proj/.claudio"; } # write the project's .claudio +claudio() { (cd "$proj" && "$CLAUDIO" "$@" /dev/null)" == true ]]; } +image_of() { podman container inspect -f '{{.ImageName}}' "$name"; } + +from_home() { (cd ~ && "$CLAUDIO" shell -c true /dev/null); } +owned_by_you() { cin 'touch owned' && [[ "$(stat -c %u "$proj/owned")" == "$(id -u)" ]]; } +pkg_image_labeled() { [[ -n "$(podman images -q --filter label=claudio.packages \ + --filter reference='localhost/claudio:pkgs-*')" ]]; } +claude_runs() { claudio --version >/dev/null; } +bg_session_up() { podman exec "$name" pgrep -fx 'sleep 8' >/dev/null 2>&1; } + +echo "== build" +"$CLAUDIO" build || { echo "build failed"; exit 1; } + +echo "== basics" +cfg '' +check "refuses to run from \$HOME" not from_home +check "runs as uid 1000 in the project dir" cin "[[ \$(id -u) == 1000 && \$PWD == '$proj' ]]" +check "claude is installed" claude_runs +check "files in the project are yours" owned_by_you +check "exit status is passed through" exits_with 3 cin 'exit 3' +check "home is the claudio-home volume" cin 'mountpoint -q /home/claude' + +echo "== MOUNTS" +cfg "MOUNTS=('$extra')" +check "bare path is mounted read-write" cin "touch '$extra/rw'" +cfg "MOUNTS=('$extra:/mnt/x:ro')" +check "':ro' mount is read-only" cin 'test -f /mnt/x/rw && ! touch /mnt/x/no 2>/dev/null' + +echo "== ROOT" +cfg '' +check "no sudo without ROOT=1" cin '! sudo -n true 2>/dev/null' +cfg 'ROOT=1' +check "passwordless sudo with ROOT=1" cin '[[ $(sudo -n id -u) == 0 ]]' +check "root can change the system" cin 'sudo -n touch /opt/marker' +check "...but it is gone next run" cin '! test -e /opt/marker' + +echo "== PACKAGES" +cfg 'PACKAGES=(hello)' +check "package is installed" cin 'hello >/dev/null' +check "image is labeled claudio.packages" pkg_image_labeled +cfg '' +check "not installed without PACKAGES" cin '! command -v hello >/dev/null' + +echo "== PERSIST" +persist=('ROOT=1' 'PERSIST=1' 'PACKAGES=(hello)') +cfg "${persist[@]}" +check "system changes are kept" cin 'sudo -n touch /opt/marker' +check "...and seen by the next run" cin 'test -e /opt/marker' +check "PACKAGES and sudo work too" cin 'hello >/dev/null && sudo -n true' +check "stopped after the run" not running +check "exit status is passed through" exits_with 3 cin 'exit 3' +check "claudio (not shell) runs claude" claude_runs + +cin 'sleep 8' & bg=$! +check "background session started" wait_for bg_session_up +cin true +check "still running while a session is" running +cfg "${persist[@]}" "MOUNTS=('$extra')" # must change the podman options, not just the file +check "refuses to recreate while in use" not cin true 2>/dev/null +cfg "${persist[@]}" +wait "$bg" +check "stopped after the last session" not running + +cfg "${persist[@]}" "MOUNTS=('$extra')" +check "settings change recreates it" cin '! test -e /opt/marker' +claudio reset +check "reset removes the container" not podman container exists "$name" +cfg "${persist[@]}" +cin 'sudo -n touch /opt/marker' +cfg "${persist[@]/#PERSIST=1/PERSIST=0}" +check "PERSIST=0 ignores the container" cin '! test -e /opt/marker' + +echo "== GUI / AUDIO (skipped if your session has no such socket)" +host_run="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +wl="${WAYLAND_DISPLAY:-wayland-0}" +if [[ -S "$host_run/$wl" ]]; then + cfg 'GUI=1' + check "Wayland socket is shared" cin "test -S /run/user/1000/$wl && [[ \$WAYLAND_DISPLAY == '$wl' ]]" +else echo " skip GUI (no $host_run/$wl)"; fi +if [[ -S "$host_run/pulse/native" ]]; then + cfg 'AUDIO=1' + check "PulseAudio socket is shared" cin 'test -S /run/user/1000/pulse/native' +else echo " skip AUDIO (no $host_run/pulse/native)"; fi + +if [[ "${1:-}" == --update ]]; then + echo "== update" + cfg "${persist[@]}" + cin 'sudo -n touch /opt/marker' + old="$(image_of)" + "$CLAUDIO" update + check "update recreates the container" cin '! test -e /opt/marker && hello >/dev/null' + check "...on a new PACKAGES image" [ "$(image_of)" != "$old" ] +fi + +echo "== $pass passed, $fail failed" +(( fail == 0 ))