diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 34302a76..f26dca9d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -# Install firewall dependencies and pixi +# System packages: firewall tooling (iptables/ipset/dns/jq/aggregate), curl, and +# the sandbox helpers bubblewrap + socat. RUN apt-get update && apt-get install -y --no-install-recommends \ iptables \ ipset \ @@ -8,35 +9,52 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jq \ aggregate \ curl \ + ca-certificates \ + gnupg \ + bubblewrap \ + socat \ && rm -rf /var/lib/apt/lists/* -# Install Playwright browser dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - libglib2.0-0t64 \ - libnspr4 \ - libnss3 \ - libdbus-1-3 \ - libatk1.0-0t64 \ - libatk-bridge2.0-0t64 \ - libcups2t64 \ - libxcb1 \ - libxkbcommon0 \ - libatspi2.0-0t64 \ - libx11-6 \ - libxcomposite1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxrandr2 \ - libgbm1 \ - libcairo2 \ - libpango-1.0-0 \ - libasound2t64 \ +# Node.js, build-time only: needed to install the Codex CLI and the Playwright +# browser at image-build time (when no credentials are mounted). The project +# itself uses pixi's own Node for builds at runtime. +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* # Install pixi system-wide RUN curl -fsSL https://pixi.sh/install.sh | PIXI_HOME=/usr/local bash -# Copy firewall init script +# Playwright browser + OS deps, pinned to the project's version, in a shared path +# readable by the vscode user. The config defines no projects, so only chromium +# is needed. Done at build time so the runtime (firewalled) phase never needs the +# dynamic Playwright CDN. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright +RUN npx --yes playwright@1.56.1 install --with-deps chromium \ + && rm -rf /var/lib/apt/lists/* + +# Codex CLI (global). Build-time install -> no credentials present to leak. +RUN npm install -g @openai/codex + +# Claude Code (native installer) as the vscode user so it lands in +# /home/vscode/.local/bin and matches installMethod "native". +USER vscode +RUN curl -fsSL https://claude.ai/install.sh | HOME=/home/vscode bash +USER root + +# Firewall script + root entrypoint that brings it up before any workload runs. COPY init-firewall.sh /usr/local/bin/init-firewall.sh -RUN chmod +x /usr/local/bin/init-firewall.sh +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/entrypoint.sh + +# Bake "no sudo for vscode" into the image: the agent runs unprivileged and can +# never escalate or modify the firewall. Privileged setup (firewall init, volume +# chown) happens in the root entrypoint instead. For maintenance, exec as root +# from the host: `podman exec -u root bash`. +RUN rm -f /etc/sudoers.d/vscode + +# Start as root so the entrypoint can set up the firewall, then exec the +# long-running command. The devcontainers CLI execs all lifecycle commands and +# shells as the unprivileged remoteUser (vscode). +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b973676f..23bcd5bb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,8 +40,22 @@ "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=2048", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", - "CLAUDE_CODE_DISABLE_ANALYTICS": "1" + "CLAUDE_CODE_DISABLE_ANALYTICS": "1", + "PLAYWRIGHT_BROWSERS_PATH": "/opt/ms-playwright" + }, + "containerEnv": { + // Keep pixi's cache on the xfs .pixi volume so it applies to every process + // in the container (entrypoint, postCreate, and all exec sessions). The + // container rootfs (~/.cache) is fuse-overlayfs under rootless Podman, which + // pixi flags as a network FS and redirects per-run; the .pixi volume is + // local xfs and persists. ${containerWorkspaceFolder} = /workspaces/fileglancer. + "PIXI_CACHE_DIR": "${containerWorkspaceFolder}/.pixi/.pixi-cache" }, "postCreateCommand": "bash .devcontainer/post-create.sh", + // Start the container as root so the entrypoint can initialize the firewall + // and fix volume ownership; all lifecycle commands and shells still run as the + // unprivileged vscode user. overrideCommand=false lets our ENTRYPOINT/CMD run. + "overrideCommand": false, + "containerUser": "root", "remoteUser": "vscode" } diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh new file mode 100755 index 00000000..0280113b --- /dev/null +++ b/.devcontainer/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Root entrypoint: runs at container start, BEFORE any workload or agent. Brings +# up the egress firewall and fixes volume ownership, then execs the long-running +# command. The devcontainers CLI execs all lifecycle commands and shells as the +# unprivileged vscode user, which has no sudo (removed in the image). +set -euo pipefail + +WORKSPACE="/workspaces/fileglancer" +READY_FLAG="/run/fg-firewall-ready" + +rm -f "$READY_FLAG" + +# Fix ownership of the mounted .pixi volume if it isn't already the dev user's. +# On rootless Podman (keep-id) it's already 1000:1000 -> no-op. On Docker/Colima +# named volumes are created root-owned, so chown them to vscode. +if [ -d "$WORKSPACE/.pixi" ] && [ "$(stat -c %u "$WORKSPACE/.pixi")" != "1000" ]; then + echo "entrypoint: fixing ownership of $WORKSPACE/.pixi" + chown -R 1000:1000 "$WORKSPACE/.pixi" || true +fi + +# Bring up the egress allowlist firewall before the agent can run. Fail closed: +# if firewall setup fails, the entrypoint exits and the container does not start. +echo "entrypoint: initializing egress firewall" +/usr/local/bin/init-firewall.sh + +# Signal readiness so post-create (run by the CLI as vscode) waits for the +# firewall before doing any network access. +touch "$READY_FLAG" + +echo "entrypoint: setup complete, starting: $*" +exec "$@" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 332af251..badaeb9f 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,50 +1,35 @@ #!/bin/bash set -e -# Fix ownership of .pixi volume (created as root by Docker) -sudo chown -R "$(id -u):$(id -g)" .pixi 2>/dev/null || true +# This phase runs as the unprivileged vscode user, AFTER the root entrypoint has +# brought up the egress firewall. It has NO sudo and NO access to non-allowlisted +# CDNs: Claude, Codex, the Playwright browser, and the firewall are all handled +# at image-build / entrypoint time. Only steps that need the mounted workspace +# remain, and they hit only allowlisted endpoints (pypi/conda/prefix.dev/npm). + +# Wait for the entrypoint's firewall to be in place before any network access. +echo "Waiting for egress firewall to come up..." +for _ in $(seq 1 60); do + [ -f /run/fg-firewall-ready ] && break + sleep 1 +done +if [ ! -f /run/fg-firewall-ready ]; then + echo "ERROR: firewall readiness flag not found; aborting setup" >&2 + exit 1 +fi -# Initialize pixi environment and install package dependencies -echo "Installing pixi environment..." -pixi install +# Install the pixi environment from the lockfile (no resolution drift). +echo "Installing pixi environment (locked)..." +pixi install --locked -# Install fileglancer in development mode (builds frontend + installs Python package) -echo "Running dev-install (this builds frontend and installs the package)..." +# Build the frontend and install the Python package in development mode. +echo "Running dev-install (frontend build + Python package)..." pixi run dev-install -# Install Playwright browsers for UI tests (before firewall, as CDN IPs are dynamic) -echo "Installing Playwright browsers..." +# Install UI-test JS deps (the @playwright/test package). The matching browser +# is already baked into the image at PLAYWRIGHT_BROWSERS_PATH. +echo "Installing UI-test dependencies..." pixi run node-install-ui-tests -cd frontend/ui-tests && pixi run npx playwright install - -# Install Claude Code via the native installer (before firewall, since claude.ai -# CDN isn't in the allowlist). Installs to ~/.local/bin/claude, matching the -# "installMethod: native" recorded in the bind-mounted ~/.claude.json from the host. -if ! [ -x "$HOME/.local/bin/claude" ]; then - echo "Installing Claude Code (native)..." - curl -fsSL https://claude.ai/install.sh | bash -fi - -# Initialize network firewall (restricts outbound to allowed domains) -# This must happen AFTER Playwright and Claude installs since their CDN IPs are dynamic -echo "Initializing network firewall..." -sudo /usr/local/bin/init-firewall.sh - -# Install Codex CLI globally via npm (provided by pixi) -if ! command -v codex &> /dev/null; then - echo "Installing Codex CLI..." - pixi run npm install -g @openai/codex -fi - -# Lock down the firewall: now that setup is complete, revoke the vscode user's -# passwordless sudo. The agent runs as unprivileged vscode, which cannot touch -# iptables/ipset without root, so the egress allowlist can no longer be flushed -# or bypassed from inside the container. NET_ADMIN/NET_RAW remain in the image -# but are unusable without root, so this neutralizes them for the agent. -# For maintenance you can still get a root shell from the HOST side: -# podman exec -u root bash (or: docker exec -u root ...) -echo "Revoking in-container sudo to lock the firewall..." -sudo rm -f /etc/sudoers.d/vscode echo "" echo "==========================================" diff --git a/docs/DevContainer.md b/docs/DevContainer.md index d409c728..62b2f19d 100644 --- a/docs/DevContainer.md +++ b/docs/DevContainer.md @@ -1,30 +1,26 @@ # Dev Container Usage -The devcontainer provides a complete development environment for Fileglancer with Python 3.14, Node.js, and pixi, configured to run Claude Code and Codex with network isolation. +The devcontainer provides a complete development environment for Fileglancer with Python, Node.js, and pixi, configured to run Claude Code and Codex as an unprivileged user under network isolation. -## Prerequisites +The same `devcontainer.json` works with two container runtimes: **Docker/Colima** (the default, used on macOS) and **rootless Podman** (used on Linux for a stronger, daemon-less security posture). The runtime is selected per machine via the `FG_CONTAINER_RUNTIME` environment variable. -- Container runtime (see platform-specific setup below) -- Pixi (provides Node.js for the devcontainer CLI) -- Claude Code configuration at `~/.claude` (API keys and auth) -- Codex configuration at `~/.codex` (config and auth) +## How runtime selection works -### Linux (Docker) +The `container-*` pixi tasks call `.devcontainer/dc.sh`, a thin wrapper around the devcontainers CLI. It passes `--docker-path "$FG_CONTAINER_RUNTIME"` (default: `docker`), so: -Install Docker using your distribution's package manager or [Docker's official instructions](https://docs.docker.com/engine/install/). +- On macOS, leave `FG_CONTAINER_RUNTIME` unset — the tasks use Docker (typically backed by Colima). +- On Linux, set `FG_CONTAINER_RUNTIME=podman` — the tasks use rootless Podman, and `dc.sh` additionally routes Podman through a small shim and disables the CLI's uid-renumber step (see [Architecture](#architecture)). -Post-installation steps: -```bash -# Add yourself to the docker group -sudo usermod -aG docker $USER +Nothing in `devcontainer.json` is runtime-specific, so the same project config works on both. -# Log out and back in, or run: -newgrp docker +## Prerequisites -# Enable Docker on startup -sudo systemctl enable docker.service -sudo systemctl enable containerd.service -``` +- A container runtime (see platform-specific setup below) +- Pixi (provides Node.js for the devcontainer CLI) +- Claude Code configuration at `~/.claude` (auth) and `~/.claude.json` +- Codex configuration at `~/.codex` (config and auth) + +## Platform setup ### macOS (Colima) @@ -41,6 +37,8 @@ colima start --cpu 4 --memory 8 --disk 60 docker ps ``` +No `FG_CONTAINER_RUNTIME` is needed on macOS — the default (`docker`) targets Colima. + #### Manual vs Auto-Start | Aspect | `colima start` | `brew services start colima` | @@ -50,62 +48,166 @@ docker ps | **Battery/memory** | Saves resources when not developing | Constant background overhead | | **Startup** | Manual, ~10-20 seconds | Automatic on login | -**Recommendation:** Use `colima start` directly unless you use containers daily. Start when needed, stop when done: +**Recommendation:** Use `colima start` directly unless you use containers daily. Start when needed, stop when done (`colima stop`). If you prefer auto-start, use `brew services start colima`. + +To save your preferred settings so you don't need flags each time, create `~/.colima/default/colima.yaml`: + +```yaml +cpu: 4 +memory: 8 +disk: 60 +``` + +### Linux (rootless Podman) — recommended + +Rootless Podman runs the container without a root-owned daemon, so a container/agent compromise can at most act as your own user rather than host root. This is the recommended Linux setup. + +#### 1. Install Podman and rootless dependencies (sudo) ```bash -colima stop +sudo dnf install -y podman fuse-overlayfs slirp4netns containernetworking-plugins +# Debian/Ubuntu: sudo apt-get install -y podman fuse-overlayfs slirp4netns containernetworking-plugins ``` -If you prefer auto-start (for frequent container use): +#### 2. Grant a subordinate UID/GID range (sudo) + +Rootless Podman needs subuid/subgid ranges to map the container's `vscode` user. Check first: ```bash -brew services start colima +grep "^$(whoami):" /etc/subuid /etc/subgid ``` -To save your preferred settings so you don't need flags each time, create `~/.colima/default/colima.yaml`: +If there are no entries, add a small range. For a local account use `usermod`; for an LDAP/NIS account (not in local `/etc/passwd`) append directly: -```yaml -cpu: 4 -memory: 8 -disk: 60 +```bash +echo "$(whoami):100000:65536" | sudo tee -a /etc/subuid +echo "$(whoami):100000:65536" | sudo tee -a /etc/subgid +podman system migrate # pick up the new ranges ``` -## Security Features +Keep this range small (65536). Do not enlarge it to cover your host UID — on a shared host with high LDAP UIDs that would overlap real users' UIDs. The setup deliberately avoids needing that (see [Architecture](#architecture)). -### Network Firewall +#### 3. Per-user shell settings -The container uses iptables to restrict outbound network access to allowed domains only: +Add these to your `~/.bashrc` (Linux host only — leave both unset on macOS): -- **Anthropic**: api.anthropic.com, statsig.anthropic.com, sentry.io -- **OpenAI**: api.openai.com -- **GitHub**: All GitHub IP ranges (fetched from api.github.com/meta) -- **npm**: registry.npmjs.org -- **Python/Pixi/Conda**: pypi.org, files.pythonhosted.org, conda.anaconda.org, conda-mapping.prefix.dev, prefix.dev, repo.prefix.dev -- **VS Code**: marketplace.visualstudio.com, vscode.blob.core.windows.net +```bash +# Select rootless Podman for the container-* pixi tasks +export FG_CONTAINER_RUNTIME=podman + +# Keep the HOST pixi's cache off NFS. Required only if your home directory is on +# a network filesystem (NFS/SMB/etc.); pixi otherwise redirects its cache per-run +# and prints a warning. Point it at fast local storage instead. +export PIXI_CACHE_DIR=/scratch/$USER/pixi-cache +``` + +`FG_CONTAINER_RUNTIME=podman` makes `pixi run container-*` use Podman. `PIXI_CACHE_DIR` fixes the **host** pixi (the one that runs the `container-*` tasks); the **container** pixi is handled separately in `devcontainer.json` (see [pixi cache](#pixi-cache)). + +#### 4. Podman storage on network-home machines (optional) -To add more allowed domains, edit `.devcontainer/init-firewall.sh`. +If your home directory is on a network filesystem, point Podman's storage at fast local storage so image/layer operations are not slow. Create `~/.config/containers/storage.conf`: + +```toml +[storage] +driver = "overlay" +runroot = "/scratch/$USER/podman-run" +graphroot = "/scratch/$USER/podman-storage" + +[storage.options] +mount_program = "/usr/bin/fuse-overlayfs" +``` + +Make sure the target directory (e.g. `/scratch/$USER`) exists and is writable. On a machine with a roomy local home you can skip this and use Podman's defaults. + +#### 5. (Optional) Persist ipset kernel modules + +The firewall uses `ipset`. The modules are usually already loaded, but this ensures they survive a reboot (otherwise the firewall fails to initialize after a reboot): -To disable the firewall at runtime: ```bash -sudo iptables -F && sudo iptables -P INPUT ACCEPT && sudo iptables -P OUTPUT ACCEPT +printf 'ip_set\nxt_set\nip_set_hash_net\n' | sudo tee /etc/modules-load.d/ipset.conf ``` -To skip it entirely on container startup, comment out the firewall line in `.devcontainer/post-create.sh` and rebuild. +### Linux (Docker) — alternative -### Privacy Settings +If you prefer Docker on Linux, install it per [Docker's instructions](https://docs.docker.com/engine/install/), add yourself to the `docker` group, and leave `FG_CONTAINER_RUNTIME` unset. Note this uses a root-owned daemon and does not get the rootless isolation benefits above. -The container sets environment variables to disable telemetry: -- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` -- `CLAUDE_CODE_DISABLE_ANALYTICS=1` +## Security model -## Host Mounts +The agent (`claude`/`codex`) runs with its approval gates disabled, so the container itself is the sandbox. The setup is structured so that no phase is simultaneously networked, credentialed, privileged, and unfirewalled. -The container mounts these directories from the host: -- `~/.claude` - Claude Code API keys and authentication -- `~/.codex` - Codex config and authentication +### Unprivileged agent (no sudo) + +The image removes the `vscode` user's passwordless sudo (`/etc/sudoers.d/vscode`). The agent runs as the unprivileged `vscode` user and therefore cannot modify the firewall, escalate, or alter root-owned files. All privileged setup happens in a root entrypoint instead (below). + +For maintenance you can still get a root shell from the **host**: + +```bash +podman exec -u root bash # rootless Podman +docker exec -u root bash # Docker/Colima +``` + +### Build-time installs (credential-free) + +Node, the Claude and Codex CLIs, and the pinned Playwright browser are installed at **image build time**, when no credentials are mounted. A poisoned dependency during these installs therefore cannot read your tokens. Only steps that need the mounted workspace remain at runtime, and they hit allowlisted endpoints only. + +### Root entrypoint brings up the firewall first + +The container starts as root and runs `.devcontainer/entrypoint.sh`, which fixes the `.pixi` volume ownership if needed, initializes the egress firewall (fail-closed — if it fails, the container does not start), signals readiness, and then drops to the long-running command. All lifecycle commands and shells run as the unprivileged `vscode` user. The `postCreate` step waits for the firewall to be up before doing any network access. + +### Network firewall (tamper-proof) + +`iptables` restricts outbound access to an allowlist (default-DROP otherwise): + +- **Anthropic**: api.anthropic.com, statsig.anthropic.com, statsig.com, sentry.io +- **OpenAI**: api.openai.com, chatgpt.com +- **GitHub**: all GitHub IP ranges (fetched from api.github.com/meta) +- **npm**: registry.npmjs.org +- **Python/Pixi/Conda**: pypi.org, files.pythonhosted.org, conda.anaconda.org, conda-mapping.prefix.dev, prefix.dev, repo.prefix.dev +- **VS Code**: marketplace.visualstudio.com, vscode.blob.core.windows.net, update.code.visualstudio.com +- **Fileglancer**: fileglancer.int.janelia.org, s3.janelia.org, neuroglancer-demo.appspot.com + +Hardening details: +- **DNS** is allowed only to the resolvers in `/etc/resolv.conf`, not to any host (closes arbitrary-resolver tunneling). +- **No blanket outbound SSH**: there is no "port 22 to anywhere" rule. SSH to GitHub still works because GitHub's ranges are in the allowlist; SSH to other hosts is blocked. +- **No host-subnet allow**: the local `/24` is not opened, preventing lateral movement to neighboring machines. +- Because `vscode` has no sudo, the agent cannot flush or weaken these rules from inside the container. + +To add allowed domains, edit `.devcontainer/init-firewall.sh` and rebuild. To disable the firewall (maintenance), comment out the `init-firewall.sh` call in `.devcontainer/entrypoint.sh` and rebuild, or flush it from a host root shell (`podman exec -u root iptables -F`). + +### Credential mapping (rootless Podman) + +The devcontainers CLI forces `--userns=keep-id`, which would map your host user to the same UID inside the container rather than to `vscode`. The Podman shim upgrades this to `keep-id:uid=1000,gid=1000` so the host user maps onto the container's `vscode` user (UID 1000), making the bind-mounted credentials readable/writable using only the small subuid range — no UID renumbering required. + +### Privacy settings + +The container disables telemetry via `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and `CLAUDE_CODE_DISABLE_ANALYTICS=1`. + +## Host mounts + +The container mounts these from the host: + +- `~/.claude` — Claude Code config and auth (read-write) +- `~/.claude.json` — Claude Code state (read-write) +- `~/.codex` — Codex config and auth (read-write) +- `~/.gitconfig` — git config (read-only) +- the `fileglancer-pixi` named volume at `/.pixi` + +## Environment and caches + +### pixi cache + +The container rootfs (`~/.cache`) is fuse-overlayfs under rootless Podman, which pixi flags as a network filesystem and redirects per-run (with a warning, and losing the cache across rebuilds). `devcontainer.json` sets `PIXI_CACHE_DIR` (via `containerEnv`) to `/.pixi/.pixi-cache`, which is on the local xfs `.pixi` volume — so the in-container pixi cache is quiet and persistent. + +This is separate from the **host** `PIXI_CACHE_DIR` shell setting above: the two pixis run on different machines/filesystems and are configured independently. + +### Playwright + +The chromium browser is baked into the image at `/opt/ms-playwright` (pinned to the project's `@playwright/test` version), and `PLAYWRIGHT_BROWSERS_PATH` points there. UI tests run without downloading browsers at runtime. ## Quick Start +On Linux, ensure `FG_CONTAINER_RUNTIME=podman` is exported first (see [Linux setup](#linux-rootless-podman--recommended)). + ```bash # Build and start the container (rebuilds from scratch) pixi run container-rebuild @@ -113,10 +215,10 @@ pixi run container-rebuild # Get a shell inside the container pixi run container-shell -# Or run Claude Code directly +# Run Claude Code directly pixi run container-claude -# Or run Codex directly +# Run Codex directly pixi run container-codex ``` @@ -140,41 +242,16 @@ pixi run container-shell pixi run dev-launch # Starts on port 7878 ``` -### Run Claude Code - -```bash -# Run Claude Code directly in the container -pixi run container-claude - -# Or from inside a container shell -pixi run container-shell -pixi run claude --permission-mode auto -``` - -### Run Codex - -```bash -# Run Codex directly in the container -pixi run container-codex - -# Or from inside a container shell -pixi run container-shell -pixi run codex --full-auto -``` - ### Stop the Container ```bash -# Find the container ID -docker ps | grep fileglancer - -# Stop it -docker stop +podman ps | grep fileglancer # or: docker ps | grep fileglancer +podman stop # or: docker stop ``` ### Rebuild from Scratch -Use this after modifying Dockerfile or devcontainer.json: +Use this after modifying the Dockerfile, devcontainer.json, or the firewall/entrypoint scripts: ```bash pixi run container-rebuild @@ -190,47 +267,29 @@ pixi run container-rebuild | `pixi run dev-watch` | Watch frontend for changes | | `pixi run test-backend` | Run Python tests with coverage | | `pixi run test-frontend` | Run frontend tests | +| `pixi run test-ui` | Run Playwright E2E tests (browser is pre-installed) | | `pixi run node-check` | TypeScript type checking | -| `pixi run node-eslint-check` | Run ESLint | -| `pixi run node-prettier-check` | Run Prettier check | | `claude` | Claude Code CLI | | `codex` | Codex CLI | +Note: `sudo` is intentionally unavailable inside the container; use a host root shell for any privileged maintenance (see [Unprivileged agent](#unprivileged-agent-no-sudo)). + ## VS Code / Cursor -You can also open the project in VS Code or Cursor and use the "Reopen in Container" command for a GUI-based experience. +You can also open the project in VS Code or Cursor and use the "Reopen in Container" command for a GUI-based experience. On Linux with Podman, configure the editor's Dev Containers extension to use the `podman` path (Dev Containers: "Docker Path" / `dev.containers.dockerPath` = `podman`). -## Standalone CLI (without devcontainer) +## Architecture -You can run Claude Code or Codex in a container without using the devcontainer CLI: +Relevant files under `.devcontainer/`: -```bash -# Build the image (from repo root) -docker build -t fileglancer-dev .devcontainer/ - -# Run interactively -docker run \ - --cap-add=NET_ADMIN \ - --cap-add=NET_RAW \ - -v ~/.claude:/home/vscode/.claude \ - -v ~/.codex:/home/vscode/.codex \ - -v "$(pwd)":/workspace \ - -w /workspace \ - -e NODE_OPTIONS="--max-old-space-size=4096" \ - -e CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - -e CLAUDE_CODE_DISABLE_ANALYTICS=1 \ - -p 7878:7878 \ - -it fileglancer-dev bash - -# Inside the container, initialize firewall and set up the project -sudo /usr/local/bin/init-firewall.sh -pixi install -pixi run dev-install -pixi run npm install -g @anthropic-ai/claude-code -pixi run npm install -g @openai/codex -pixi run claude --permission-mode auto -pixi run codex --full-auto -``` +- **`dc.sh`** — wrapper that selects the runtime from `FG_CONTAINER_RUNTIME` (default `docker`) and passes `--docker-path` to the devcontainers CLI. On the Podman path it routes through `podman-shim/podman` and adds `--update-remote-user-uid-default never`. +- **`podman-shim/podman`** — a shim (named `podman` so the CLI still detects Podman) that rewrites the CLI's forced `--userns=keep-id` to `--userns=keep-id:uid=1000,gid=1000`, mapping the host user onto `vscode`. Used only on the Podman path; Docker/Colima never sees it. +- **`Dockerfile`** — installs system packages, bubblewrap/socat, Node (build-time), the Claude/Codex CLIs, and the pinned Playwright browser; removes `vscode`'s sudo; sets the root entrypoint. +- **`entrypoint.sh`** — root entrypoint: fixes volume ownership, brings up the firewall, signals readiness, execs the long-running command. +- **`init-firewall.sh`** — builds the egress allowlist (run by the entrypoint as root). +- **`post-create.sh`** — runs as `vscode` after the firewall is up: `pixi install --locked`, `dev-install`, UI-test deps. No sudo, no non-allowlisted CDN access. + +`devcontainer.json` sets `overrideCommand: false` (so the image ENTRYPOINT/CMD run), `containerUser: root` (for the entrypoint), and `remoteUser: vscode` (for lifecycle commands and shells). ## GPU Support (Linux only) @@ -239,27 +298,29 @@ To enable GPU passthrough for CUDA workloads: ### 1. Install nvidia-container-toolkit ```bash -# Add NVIDIA repository curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list -# Install sudo apt-get update sudo apt-get install -y nvidia-container-toolkit ``` -### 2. Configure Docker runtime +### 2. Configure the runtime ```bash +# Docker sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker + +# Podman (rootless): nvidia-ctk supports CDI; generate a CDI spec +sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml ``` ### 3. Enable GPU in devcontainer -Edit `.devcontainer/devcontainer.json` to add `--gpus all` to `runArgs`: +Edit `.devcontainer/devcontainer.json` to add the GPU flag to `runArgs` (`--gpus all` for Docker, or `--device nvidia.com/gpu=all` for Podman CDI): ```json "runArgs": [ @@ -274,6 +335,5 @@ Edit `.devcontainer/devcontainer.json` to add `--gpus all` to `runArgs`: After rebuilding the container: ```bash -# Inside the container -nvidia-smi +nvidia-smi # inside the container ```