Add opt-in root access, persistent containers and per-project packages

This commit is contained in:
2026-10-27 21:04:41 +01:00
parent a2cda697dd
commit 874352397f
5 changed files with 283 additions and 19 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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-<hash>`). 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`.

View File

@@ -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" <<EOF
FROM $IMAGE
USER root
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ${PACKAGES[*]@Q} \\
&& rm -rf /var/lib/apt/lists/*
USER claude
EOF
fi
args=(-v "$PWD:$PWD" -w "$PWD" -v "$VOLUME:/home/claude")
for m in "${MOUNTS[@]}"; do
[[ "$m" == *:* ]] || m="$m:$m" # bare path -> 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"

132
test.sh Executable file
View File

@@ -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); }
cin() { claudio shell -c "$1"; } # run a command in the container
exits_with() { local want="$1"; shift; "$@"; [[ $? == "$want" ]]; }
wait_for() { for _ in {1..30}; do "$@" && return; sleep 1; done; return 1; }
running() { [[ "$(podman container inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" == true ]]; }
image_of() { podman container inspect -f '{{.ImageName}}' "$name"; }
from_home() { (cd ~ && "$CLAUDIO" shell -c true </dev/null 2>/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 ))