diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e092aec..7740380 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -87,7 +87,7 @@ }, { "name": "devcontainer-setup", - "version": "0.1.0", + "version": "0.2.0", "description": "Create pre-configured devcontainers with Claude Code and language-specific tooling", "author": { "name": "Alexis Challande", diff --git a/plugins/devcontainer-setup/.claude-plugin/plugin.json b/plugins/devcontainer-setup/.claude-plugin/plugin.json index 87fa0a2..ea09ad7 100644 --- a/plugins/devcontainer-setup/.claude-plugin/plugin.json +++ b/plugins/devcontainer-setup/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "devcontainer-setup", - "version": "0.1.0", + "version": "0.2.0", "description": "Create pre-configured devcontainers with Claude Code and language-specific tooling", "author": { "name": "Alexis Challande", diff --git a/plugins/devcontainer-setup/README.md b/plugins/devcontainer-setup/README.md index 4339d57..ed48672 100644 --- a/plugins/devcontainer-setup/README.md +++ b/plugins/devcontainer-setup/README.md @@ -8,8 +8,9 @@ Create pre-configured devcontainers with Claude Code and language-specific tooli - **Multi-language support**: Python 3.13, Node 22, Rust, Go - **Modern CLI tools**: ripgrep, fd, fzf, tmux, git-delta, ast-grep - **Session persistence**: command history, GitHub CLI auth, Claude config survive rebuilds +- **Sandboxing**: bubblewrap and socat for Claude Code sandboxing support - **Network isolation**: iptables/ipset with NET_ADMIN capability for restricting outbound traffic -- **Tailscale integration**: Secure networking via devcontainer feature +- **Token forwarding**: `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` forwarded to container ## Usage @@ -40,6 +41,12 @@ devc up Start the devcontainer devc rebuild Rebuild container (preserves persistent volumes) devc down Stop the container devc shell Open zsh shell in container +devc exec Execute a command in the running container +devc upgrade Upgrade Claude Code to latest version +devc mount Add a bind mount to the container +devc sync Sync sessions from devcontainers to host +devc cp Copy files from container to host +devc destroy [-f] Remove container, volumes, and image ``` ## Supported Languages @@ -58,10 +65,13 @@ Multi-language projects automatically get all detected configurations merged. The devcontainer provides **filesystem isolation** with **network isolation** capabilities: - Container filesystem is isolated from host +- `.devcontainer/` mounted read-only inside the container to prevent escape - Your `~/.gitconfig` is mounted read-only +- SYS_ADMIN capability blocked by `devc` CLI to protect read-only mounts - Persistent volumes preserve auth across rebuilds - iptables/ipset with NET_ADMIN/NET_RAW capabilities for restricting network access - NPM security settings: scripts disabled, 24-hour package release delay +- SSH commit signing supported via `gpg.ssh.program` configuration ## Reference Material diff --git a/plugins/devcontainer-setup/skills/devcontainer-setup/SKILL.md b/plugins/devcontainer-setup/skills/devcontainer-setup/SKILL.md index c0f26aa..7e00563 100644 --- a/plugins/devcontainer-setup/skills/devcontainer-setup/SKILL.md +++ b/plugins/devcontainer-setup/skills/devcontainer-setup/SKILL.md @@ -90,10 +90,13 @@ Then apply language-specific modifications below. The base template includes: - **Claude Code** with marketplace plugins (anthropics/skills, trailofbits/skills, trailofbits/skills-curated) +- **Sandboxing** via bubblewrap and socat - **Python 3.13** via uv (fast binary download) - **Node 22** via fnm (Fast Node Manager) - **ast-grep** for AST-based code search - **Network isolation tools** (iptables, ipset) with NET_ADMIN capability +- **Security mounts**: `.devcontainer/` mounted read-only to prevent container escape +- **Token forwarding**: `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` via `remoteEnv` - **Modern CLI tools**: ripgrep, fd, fzf, tmux, git-delta --- diff --git a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/Dockerfile b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/Dockerfile index 6e1c2b8..edfdaba 100644 --- a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/Dockerfile +++ b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/Dockerfile @@ -1,8 +1,7 @@ # {{PROJECT_NAME}} Devcontainer # Based on Microsoft devcontainer image for better devcontainer integration -ARG UV_VERSION=0.10.0 -FROM ghcr.io/astral-sh/uv:${UV_VERSION}@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f AS uv -FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae +FROM ghcr.io/astral-sh/uv:0.10@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 AS uv +FROM mcr.microsoft.com/devcontainers/base:ubuntu24.04@sha256:4bcb1b466771b1ba1ea110e2a27daea2f6093f9527fb75ee59703ec89b5561cb ARG TZ ENV TZ="$TZ" @@ -44,7 +43,7 @@ RUN ARCH=$(dpkg --print-architecture) && \ COPY --from=uv /uv /usr/local/bin/uv # Install fzf from GitHub releases (newer than apt, includes built-in shell integration) -ARG FZF_VERSION=0.67.0 +ARG FZF_VERSION=0.70.0 RUN ARCH=$(dpkg --print-architecture) && \ case "${ARCH}" in \ amd64) FZF_ARCH="linux_amd64" ;; \ diff --git a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/devcontainer.json b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/devcontainer.json index 50c11d3..d2c185a 100644 --- a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/devcontainer.json +++ b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/devcontainer.json @@ -63,6 +63,10 @@ "PYTHONDONTWRITEBYTECODE": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1" }, + "remoteEnv": { + "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN:}", + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY:}" + }, "initializeCommand": "test -f \"$HOME/.gitconfig\" || touch \"$HOME/.gitconfig\"", "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", "workspaceFolder": "/workspace", diff --git a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/install.sh b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/install.sh index 5c4be63..038e254 100644 --- a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/install.sh +++ b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/install.sh @@ -37,6 +37,9 @@ Commands: exec Execute a command in the running container upgrade Upgrade Claude Code to latest version mount Add a mount to the devcontainer (recreates container) + sync [project] [--trusted] Sync sessions from devcontainers to host + cp Copy files/directories from container to host + destroy [-f] Remove container, volumes, and image for current project help Show this help message Examples: @@ -49,6 +52,11 @@ Examples: devc exec ls -la # Run command in container devc upgrade # Upgrade Claude Code to latest devc mount ~/data /data # Add mount to container + devc sync # Sync sessions from all devcontainers + devc sync crypto # Sync only matching devcontainer + devc cp /some/file ./out # Copy a path from container to host + devc destroy # Remove all project Docker resources + devc destroy -f # Skip confirmation prompt EOF } @@ -332,6 +340,410 @@ cmd_mount() { log_success "Mount added: $host_path → $container_path" } +cmd_sync() { + local filter="" + local trusted=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --trusted) + trusted=true + shift + ;; + *) + filter="$1" + shift + ;; + esac + done + + local host_projects="${HOME}/.claude/projects" + + if [[ "$trusted" == false ]]; then + log_warn "This copies files from devcontainers to your host filesystem." + log_warn "Only proceed if you trust the container contents." + log_info "Use --trusted to skip this prompt." + read -p "Continue? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Aborted." + exit 0 + fi + fi + + # Discover all devcontainers (running + stopped) by label. + local container_ids + container_ids=$(docker ps -a -q \ + --filter "label=devcontainer.local_folder" 2>/dev/null || true) + + if [[ -z "$container_ids" ]]; then + log_error "No devcontainers found (running or stopped)." + exit 1 + fi + + # List discovered devcontainers. + log_info "Discovered devcontainers:" + local matched_any=false + while IFS= read -r cid; do + local name folder status + name=$(sync_get_project_name "$cid") + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + + if [[ -n "$filter" ]]; then + if ! echo "$name" | grep -qi "$filter"; then + continue + fi + fi + + matched_any=true + echo " - ${name} (${status}) ${folder}" + done <<<"$container_ids" + + if [[ "$matched_any" == false ]]; then + log_error "No devcontainers matching '${filter}'." + echo "" + echo "Available:" + while IFS= read -r cid; do + local name status + name=$(sync_get_project_name "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + echo " - ${name} (${status})" + done <<<"$container_ids" + exit 1 + fi + + echo "" + + # Sync matching containers. + while IFS= read -r cid; do + local name + name=$(sync_get_project_name "$cid") + + if [[ -n "$filter" ]]; then + if ! echo "$name" | grep -qi "$filter"; then + continue + fi + fi + + sync_one_container "$cid" "$host_projects" + echo "" + done <<<"$container_ids" + + log_success "Run '/insights' in Claude Code to include these sessions." +} + +# Extract project name from devcontainer.local_folder label. +sync_get_project_name() { + local folder + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$1") + basename "$folder" +} + +# Resolve the Claude projects dir inside a container without +# docker exec (works on stopped containers too). +# Reads CLAUDE_CONFIG_DIR from container env, falls back to +# /home//.claude. +sync_get_claude_projects_dir() { + local cid="$1" + local claude_dir + + claude_dir=$(docker inspect --format '{{json .Config.Env}}' "$cid" | + tr ',' '\n' | tr -d '[]"' | + grep '^CLAUDE_CONFIG_DIR=' | + cut -d= -f2- || true) + + if [[ -n "$claude_dir" ]]; then + echo "${claude_dir}/projects" + return + fi + + local user + user=$(docker inspect --format '{{.Config.User}}' "$cid") + if [[ -z "$user" || "$user" == "root" ]]; then + echo "/root/.claude/projects" + else + echo "/home/${user}/.claude/projects" + fi +} + +sync_one_container() { + local cid="$1" + local host_projects="$2" + local project_name status claude_dir folder + + project_name=$(sync_get_project_name "$cid") + folder=$(docker inspect --format \ + '{{index .Config.Labels "devcontainer.local_folder"}}' "$cid") + status=$(docker inspect --format '{{.State.Status}}' "$cid") + claude_dir=$(sync_get_claude_projects_dir "$cid") + + log_info "=== ${project_name} (${status}) ===" + echo " Host path: ${folder}" + echo " Container: ${cid:0:12}" + + # docker cp works on both running and stopped containers. + local tmpdir + tmpdir=$(mktemp -d) + + if ! docker cp "${cid}:${claude_dir}/." "$tmpdir/" 2>/dev/null; then + echo " No sessions found, skipping." + rm -rf "$tmpdir" + return 0 + fi + + local session_count + session_count=$(find "$tmpdir" -name '*.jsonl' | wc -l | tr -d ' ') + + if [[ "$session_count" -eq 0 ]]; then + echo " No sessions found, skipping." + rm -rf "$tmpdir" + return 0 + fi + + echo " Sessions: ${session_count}" + + local total_copied=0 + + # Sync each project key subdirectory. + for key_path in "$tmpdir"/*/; do + [[ ! -d "$key_path" ]] && continue + local key dest_key + key=$(basename "$key_path") + + if [[ "$key" == "-workspace" ]]; then + dest_key="-devcontainer-${project_name}" + else + dest_key="${key}" + fi + + local dest_dir="${host_projects}/${dest_key}" + mkdir -p "$dest_dir" + + local copied=0 + while IFS= read -r -d '' file; do + local rel="${file#"$key_path"}" + local dest_file="${dest_dir}/${rel}" + mkdir -p "$(dirname "$dest_file")" + + if [[ ! -e "$dest_file" ]] || + [[ "$file" -nt "$dest_file" ]]; then + cp -p "$file" "$dest_file" + copied=$((copied + 1)) + fi + done < <(find "$key_path" -type f -print0) + + if [[ "$copied" -gt 0 ]]; then + echo " Synced ${copied} file(s) -> ${dest_key}" + fi + total_copied=$((total_copied + copied)) + done + + # Handle .jsonl files directly in projects/ (no subdirectory). + local orphan_copied=0 + local dest_dir="${host_projects}/-devcontainer-${project_name}" + mkdir -p "$dest_dir" + + while IFS= read -r -d '' file; do + local name + name=$(basename "$file") + local dest_file="${dest_dir}/${name}" + + if [[ ! -e "$dest_file" ]] || + [[ "$file" -nt "$dest_file" ]]; then + cp -p "$file" "$dest_file" + orphan_copied=$((orphan_copied + 1)) + fi + done < <(find "$tmpdir" -maxdepth 1 -name '*.jsonl' -print0) + + if [[ "$orphan_copied" -gt 0 ]]; then + echo " Synced ${orphan_copied} file(s) -> -devcontainer-${project_name}" + total_copied=$((total_copied + orphan_copied)) + fi + + rm -rf "$tmpdir" + + echo " Total: ${total_copied} file(s) synced." +} + +cmd_cp() { + local container_path="${1:-}" + local host_path="${2:-}" + + if [[ -z "$container_path" ]] || [[ -z "$host_path" ]]; then + log_error "Usage: devc cp " + exit 1 + fi + + local workspace_folder + workspace_folder="$(get_workspace_folder)" + + # Find the running container + local label="devcontainer.local_folder=$workspace_folder" + local container_id + container_id=$(docker ps -q --filter "label=$label" 2>/dev/null || true) + + if [[ -z "$container_id" ]]; then + log_error "No running devcontainer found for $workspace_folder" + exit 1 + fi + + log_info "Copying $container_path → $host_path" + docker cp "$container_id:$container_path" "$host_path" + log_success "Copied $container_path → $host_path" +} + +# Discovers all Docker resources associated with the current workspace. +# Sets global variables: CONTAINER_ID, CONTAINER_STATUS, VOLUMES (array), IMAGE, IMAGE_UID +discover_resources() { + local workspace_folder="$1" + local label="devcontainer.local_folder=$workspace_folder" + + CONTAINER_ID="" + CONTAINER_STATUS="" + VOLUMES=() + IMAGE="" + IMAGE_UID="" + + # Find container (any state: running, stopped, created, etc.) + CONTAINER_ID=$(docker ps -aq --filter "label=$label" 2>/dev/null | head -1) + + if [[ -z "$CONTAINER_ID" ]]; then + return 0 + fi + + # Get container status + CONTAINER_STATUS=$(docker inspect "$CONTAINER_ID" --format '{{.State.Status}}' 2>/dev/null || true) + + # Get volumes (docker volumes only, not bind mounts) + while IFS= read -r vol; do + [[ -n "$vol" ]] && VOLUMES+=("$vol") + done < <(docker inspect "$CONTAINER_ID" --format '{{json .Mounts}}' 2>/dev/null | + jq -r '.[] | select(.Type == "volume") | .Name' 2>/dev/null) + + # Get image and its -uid variant + IMAGE=$(docker inspect "$CONTAINER_ID" --format '{{.Config.Image}}' 2>/dev/null || true) + if [[ -n "$IMAGE" ]]; then + if [[ "$IMAGE" == *-uid ]]; then + IMAGE_UID="$IMAGE" + IMAGE="${IMAGE%-uid}" + else + IMAGE_UID="${IMAGE}-uid" + fi + fi +} + +print_destroy_summary() { + echo "" + log_warn "The following resources will be permanently removed:" + echo "" + + if [[ -n "$CONTAINER_ID" ]]; then + local container_name + container_name=$(docker inspect "$CONTAINER_ID" --format '{{.Name}}' 2>/dev/null | sed 's|^/||') + echo " Container: ${container_name:-$CONTAINER_ID}" + if [[ "$CONTAINER_STATUS" == "running" ]]; then + echo " (currently running -- will be force-stopped)" + fi + fi + + if [[ ${#VOLUMES[@]} -gt 0 ]]; then + echo " Volumes:" + for vol in "${VOLUMES[@]}"; do + echo " $vol" + done + fi + + if [[ -n "$IMAGE" ]]; then + echo " Image: $IMAGE" + if docker image inspect "$IMAGE_UID" &>/dev/null; then + echo " $IMAGE_UID" + fi + fi + + echo "" +} + +cmd_destroy() { + local force=false + + # Parse flags + while [[ $# -gt 0 ]]; do + case "$1" in + -f | --force) + force=true + shift + ;; + *) + break + ;; + esac + done + + local workspace_folder + workspace_folder="$(get_workspace_folder "${1:-}")" + + discover_resources "$workspace_folder" + + # No resources found (idempotent behavior) + if [[ -z "$CONTAINER_ID" ]]; then + log_info "No devcontainer found for $workspace_folder" + return 0 + fi + + print_destroy_summary + + # Running container warning + if [[ "$CONTAINER_STATUS" == "running" && "$force" != true ]]; then + log_warn "Container is currently running!" + read -p "Force-stop the running container? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Aborted." + return 0 + fi + fi + + # Main confirmation prompt + if [[ "$force" != true ]]; then + read -p "Destroy these resources? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Aborted." + return 0 + fi + fi + + # Deletion, in order: stop, remove container, volumes, images + if [[ -n "$CONTAINER_ID" && "$CONTAINER_STATUS" == "running" ]]; then + log_info "Stopping container..." + docker stop "$CONTAINER_ID" >/dev/null 2>&1 || true + fi + + if [[ -n "$CONTAINER_ID" ]]; then + log_info "Removing container..." + docker rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true + fi + + for vol in "${VOLUMES[@]}"; do + log_info "Removing volume: $vol" + docker volume rm -f "$vol" >/dev/null 2>&1 || true + done + + if [[ -n "$IMAGE" ]]; then + log_info "Removing image: $IMAGE" + docker rmi -f "$IMAGE" >/dev/null 2>&1 || true + if docker image inspect "$IMAGE_UID" &>/dev/null 2>&1; then + log_info "Removing image: $IMAGE_UID" + docker rmi -f "$IMAGE_UID" >/dev/null 2>&1 || true + fi + fi + + log_success "All resources destroyed for $workspace_folder" +} + cmd_self_install() { local install_dir="$HOME/.local/bin" local install_path="$install_dir/devc" @@ -406,6 +818,9 @@ main() { down) cmd_down "$@" ;; + destroy) + cmd_destroy "$@" + ;; shell) cmd_shell ;; @@ -419,6 +834,12 @@ main() { mount) cmd_mount "$@" ;; + sync) + cmd_sync "$@" + ;; + cp) + cmd_cp "$@" + ;; self-install) cmd_self_install ;; diff --git a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/post_install.py b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/post_install.py index 31a1e02..88ebffd 100644 --- a/plugins/devcontainer-setup/skills/devcontainer-setup/resources/post_install.py +++ b/plugins/devcontainer-setup/skills/devcontainer-setup/resources/post_install.py @@ -2,6 +2,7 @@ """Post-install configuration for Claude Code devcontainer. Runs on container creation to set up: +- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set) - Claude settings (bypassPermissions mode) - Tmux configuration (200k history, mouse support) - Directory ownership fixes for mounted volumes @@ -15,9 +16,83 @@ import sys from pathlib import Path +def setup_onboarding_bypass(): + """Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set. + + Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess + writes the config file during startup before the API call completes, so + a timeout is expected and acceptable. After the subprocess finishes (or + times out), we check whether ~/.claude.json was populated and only then + set hasCompletedOnboarding. + + Workaround for https://github.com/anthropics/claude-code/issues/8938. + """ + token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if not token: + print( + "[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass", + file=sys.stderr, + ) + return + + # When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`, + # `claude` unexpectedly looks for `.claude.json` in *that* folder, + # instead of in `~`, contradicting the documentation. + # See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874 + claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home())) + claude_json = claude_json_dir / ".claude.json" + + print("[post_install] Running claude -p to populate auth state...", file=sys.stderr) + try: + result = subprocess.run( + ["claude", "-p", "ok"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + print( + f"[post_install] claude -p exited {result.returncode}: {result.stderr.strip()}", + file=sys.stderr, + ) + except subprocess.TimeoutExpired: + print( + "[post_install] claude -p timed out (expected on cold start)", + file=sys.stderr, + ) + except (FileNotFoundError, OSError) as e: + print( + f"[post_install] Warning: could not run claude ({e}) — onboarding bypass skipped", + file=sys.stderr, + ) + return + + if not claude_json.exists(): + print( + f"[post_install] Warning: {claude_json} not created by claude -p — " + "onboarding bypass skipped", + file=sys.stderr, + ) + return + + config: dict = {} + try: + config = json.loads(claude_json.read_text()) + except json.JSONDecodeError as e: + print( + f"[post_install] Warning: {claude_json} has invalid JSON ({e}), starting fresh", + file=sys.stderr, + ) + + config["hasCompletedOnboarding"] = True + + claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + print(f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr) + + def setup_claude_settings(): """Configure Claude Code with bypassPermissions enabled.""" - claude_dir = Path.home() / ".claude" + claude_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) claude_dir.mkdir(parents=True, exist_ok=True) settings_file = claude_dir / "settings.json" @@ -199,6 +274,9 @@ node_modules/ [diff] colorMoved = default + +[gpg "ssh"] + program = /usr/bin/ssh-keygen """ local_gitconfig.write_text(local_config, encoding="utf-8") print(f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr) @@ -208,6 +286,7 @@ def main(): """Run all post-install configuration.""" print("[post_install] Starting post-install configuration...", file=sys.stderr) + setup_onboarding_bypass() setup_claude_settings() setup_tmux_config() fix_directory_ownership()