Skip to content

Commit bcf2851

Browse files
jmelahmanclaude
andauthored
chore(devtools): introduce a .devcontainer (onyx-dot-app#10035)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5a59bd commit bcf2851

16 files changed

Lines changed: 904 additions & 0 deletions

.devcontainer/Dockerfile

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
FROM ubuntu:26.04@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1
2+
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
acl \
5+
curl \
6+
fd-find \
7+
fzf \
8+
git \
9+
jq \
10+
less \
11+
make \
12+
neovim \
13+
openssh-client \
14+
python3-venv \
15+
ripgrep \
16+
sudo \
17+
ca-certificates \
18+
iptables \
19+
ipset \
20+
iproute2 \
21+
dnsutils \
22+
unzip \
23+
wget \
24+
zsh \
25+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
26+
&& apt-get install -y nodejs \
27+
&& install -m 0755 -d /etc/apt/keyrings \
28+
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
29+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
30+
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
31+
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
32+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
33+
&& apt-get update \
34+
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin gh \
35+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
36+
37+
# fd-find installs as fdfind on Debian/Ubuntu — symlink to fd
38+
RUN ln -sf "$(which fdfind)" /usr/local/bin/fd
39+
40+
# Install uv (Python package manager)
41+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
42+
43+
# Create non-root dev user with passwordless sudo
44+
RUN useradd -m -s /bin/zsh dev && \
45+
echo "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && \
46+
chmod 0440 /etc/sudoers.d/dev
47+
48+
ENV DEVCONTAINER=true
49+
50+
RUN mkdir -p /workspace && \
51+
chown -R dev:dev /workspace
52+
53+
WORKDIR /workspace
54+
55+
# Install Claude Code
56+
ARG CLAUDE_CODE_VERSION=latest
57+
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
58+
59+
# Configure zsh — source the repo-local zshrc so shell customization
60+
# doesn't require an image rebuild.
61+
RUN chsh -s /bin/zsh root && \
62+
for rc in /root/.zshrc /home/dev/.zshrc; do \
63+
echo '[ -f /workspace/.devcontainer/zshrc ] && . /workspace/.devcontainer/zshrc' >> "$rc"; \
64+
done && \
65+
chown dev:dev /home/dev/.zshrc

.devcontainer/README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Onyx Dev Container
2+
3+
A containerized development environment for working on Onyx.
4+
5+
## What's included
6+
7+
- Ubuntu 26.04 base image
8+
- Node.js 20, uv, Claude Code
9+
- Docker CLI, GitHub CLI (`gh`)
10+
- Neovim, ripgrep, fd, fzf, jq, make, wget, unzip
11+
- Zsh as default shell (sources host `~/.zshrc` if available)
12+
- Python venv auto-activation
13+
- Network firewall (default-deny, whitelists npm, GitHub, Anthropic APIs, Sentry, and VS Code update servers)
14+
15+
## Usage
16+
17+
### VS Code
18+
19+
1. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
20+
2. Open this repo in VS Code
21+
3. "Reopen in Container" when prompted
22+
23+
### CLI (`ods dev`)
24+
25+
The [`ods` devtools CLI](../tools/ods/README.md) provides workspace-aware wrappers
26+
for all devcontainer operations (also available as `ods dc`):
27+
28+
```bash
29+
# Start the container
30+
ods dev up
31+
32+
# Open a shell
33+
ods dev into
34+
35+
# Run a command
36+
ods dev exec npm test
37+
38+
# Stop the container
39+
ods dev stop
40+
```
41+
42+
If you don't have `ods` installed, use the `devcontainer` CLI directly:
43+
44+
```bash
45+
npm install -g @devcontainers/cli
46+
47+
devcontainer up --workspace-folder .
48+
devcontainer exec --workspace-folder . zsh
49+
```
50+
51+
## Restarting the container
52+
53+
### VS Code
54+
55+
Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run:
56+
57+
- **Dev Containers: Reopen in Container** — restarts the container without rebuilding
58+
59+
### CLI
60+
61+
```bash
62+
# Restart the container
63+
ods dev restart
64+
65+
# Pull the latest published image and recreate
66+
ods dev rebuild
67+
```
68+
69+
Or without `ods`:
70+
71+
```bash
72+
devcontainer up --workspace-folder . --remove-existing-container
73+
```
74+
75+
## Image
76+
77+
The devcontainer uses a prebuilt image published to `onyxdotapp/onyx-devcontainer`.
78+
The tag is pinned in `devcontainer.json` — no local build is required.
79+
80+
To build the image locally (e.g. while iterating on the Dockerfile):
81+
82+
```bash
83+
docker buildx bake devcontainer
84+
```
85+
86+
The `devcontainer` target is defined in `docker-bake.hcl` at the repo root.
87+
88+
## User & permissions
89+
90+
The container runs as the `dev` user by default (`remoteUser` in devcontainer.json).
91+
An init script (`init-dev-user.sh`) runs at container start to ensure `dev` has
92+
read/write access to the bind-mounted workspace:
93+
94+
- **Standard Docker**`dev`'s UID/GID is remapped to match the workspace owner,
95+
so file permissions work seamlessly.
96+
- **Rootless Docker** — The workspace appears as root-owned (UID 0) inside the
97+
container due to user-namespace mapping. The init script grants `dev` access via
98+
POSIX ACLs (`setfacl`), which adds a few seconds to the first container start on
99+
large repos.
100+
101+
## Docker socket
102+
103+
The container mounts the host's Docker socket so you can run `docker` commands
104+
from inside. `ods dev` auto-detects the socket path and sets `DOCKER_SOCK`:
105+
106+
| Environment | Socket path |
107+
| ----------------------- | ------------------------------ |
108+
| Linux (rootless Docker) | `$XDG_RUNTIME_DIR/docker.sock` |
109+
| macOS (Docker Desktop) | `~/.docker/run/docker.sock` |
110+
| Linux (standard Docker) | `/var/run/docker.sock` |
111+
112+
To override, set `DOCKER_SOCK` before running `ods dev up`. When using the
113+
VS Code extension or `devcontainer` CLI directly (without `ods`), you must set
114+
`DOCKER_SOCK` yourself.
115+
116+
## Firewall
117+
118+
The container starts with a default-deny firewall (`init-firewall.sh`) that only allows outbound traffic to:
119+
120+
- npm registry
121+
- GitHub
122+
- Anthropic API
123+
- Sentry
124+
- VS Code update servers
125+
126+
This requires the `NET_ADMIN` and `NET_RAW` capabilities, which are added via `runArgs` in `devcontainer.json`.

.devcontainer/devcontainer.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "Onyx Dev Sandbox",
3+
"image": "onyxdotapp/onyx-devcontainer@sha256:12184169c5bcc9cca0388286d5ffe504b569bc9c37bfa631b76ee8eee2064055",
4+
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
5+
"mounts": [
6+
"source=${localEnv:DOCKER_SOCK},target=/var/run/docker.sock,type=bind",
7+
"source=${localEnv:HOME}/.claude,target=/home/dev/.claude,type=bind",
8+
"source=${localEnv:HOME}/.claude.json,target=/home/dev/.claude.json,type=bind",
9+
"source=${localEnv:HOME}/.zshrc,target=/home/dev/.zshrc.host,type=bind,readonly",
10+
"source=${localEnv:HOME}/.gitconfig,target=/home/dev/.gitconfig.host,type=bind,readonly",
11+
"source=${localEnv:HOME}/.ssh,target=/home/dev/.ssh.host,type=bind,readonly",
12+
"source=${localEnv:HOME}/.config/nvim,target=/home/dev/.config/nvim.host,type=bind,readonly",
13+
"source=onyx-devcontainer-local,target=/home/dev/.local,type=volume"
14+
],
15+
"remoteUser": "dev",
16+
"updateRemoteUserUID": false,
17+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
18+
"workspaceFolder": "/workspace",
19+
"postStartCommand": "sudo bash /workspace/.devcontainer/init-dev-user.sh && sudo bash /workspace/.devcontainer/init-firewall.sh",
20+
"waitFor": "postStartCommand"
21+
}

.devcontainer/init-dev-user.sh

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Remap the dev user's UID/GID to match the workspace owner so that
5+
# bind-mounted files are accessible without running as root.
6+
#
7+
# Standard Docker: Workspace is owned by the host user's UID (e.g. 1000).
8+
# We remap dev to that UID — fast and seamless.
9+
#
10+
# Rootless Docker: Workspace appears as root-owned (UID 0) inside the
11+
# container due to user-namespace mapping. We can't remap
12+
# dev to UID 0 (that's root), so we grant access with
13+
# POSIX ACLs instead.
14+
15+
WORKSPACE=/workspace
16+
TARGET_USER=dev
17+
18+
WS_UID=$(stat -c '%u' "$WORKSPACE")
19+
WS_GID=$(stat -c '%g' "$WORKSPACE")
20+
DEV_UID=$(id -u "$TARGET_USER")
21+
DEV_GID=$(id -g "$TARGET_USER")
22+
23+
DEV_HOME=/home/"$TARGET_USER"
24+
25+
# Ensure directories that tools expect exist under ~dev.
26+
# ~/.local is a named Docker volume — ensure subdirs exist and are owned by dev.
27+
mkdir -p "$DEV_HOME"/.local/state "$DEV_HOME"/.local/share
28+
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME"/.local
29+
30+
# Copy host configs mounted as *.host into their real locations.
31+
# This gives the dev user owned copies without touching host originals.
32+
if [ -d "$DEV_HOME/.ssh.host" ]; then
33+
cp -a "$DEV_HOME/.ssh.host" "$DEV_HOME/.ssh"
34+
chmod 700 "$DEV_HOME/.ssh"
35+
chmod 600 "$DEV_HOME"/.ssh/id_* 2>/dev/null || true
36+
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.ssh"
37+
fi
38+
if [ -d "$DEV_HOME/.config/nvim.host" ]; then
39+
mkdir -p "$DEV_HOME/.config"
40+
cp -a "$DEV_HOME/.config/nvim.host" "$DEV_HOME/.config/nvim"
41+
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.config/nvim"
42+
fi
43+
44+
# Already matching — nothing to do.
45+
if [ "$WS_UID" = "$DEV_UID" ] && [ "$WS_GID" = "$DEV_GID" ]; then
46+
exit 0
47+
fi
48+
49+
if [ "$WS_UID" != "0" ]; then
50+
# ── Standard Docker ──────────────────────────────────────────────
51+
# Workspace is owned by a non-root UID (the host user).
52+
# Remap dev's UID/GID to match.
53+
if [ "$DEV_GID" != "$WS_GID" ]; then
54+
if ! groupmod -g "$WS_GID" "$TARGET_USER" 2>&1; then
55+
echo "warning: failed to remap $TARGET_USER GID to $WS_GID" >&2
56+
fi
57+
fi
58+
if [ "$DEV_UID" != "$WS_UID" ]; then
59+
if ! usermod -u "$WS_UID" -g "$WS_GID" "$TARGET_USER" 2>&1; then
60+
echo "warning: failed to remap $TARGET_USER UID to $WS_UID" >&2
61+
fi
62+
fi
63+
if ! chown -R "$TARGET_USER":"$TARGET_USER" /home/"$TARGET_USER" 2>&1; then
64+
echo "warning: failed to chown /home/$TARGET_USER" >&2
65+
fi
66+
else
67+
# ── Rootless Docker ──────────────────────────────────────────────
68+
# Workspace is root-owned inside the container. Grant dev access
69+
# via POSIX ACLs (preserves ownership, works across the namespace
70+
# boundary).
71+
if command -v setfacl &>/dev/null; then
72+
setfacl -Rm "u:${TARGET_USER}:rwX" "$WORKSPACE"
73+
setfacl -Rdm "u:${TARGET_USER}:rwX" "$WORKSPACE" # default ACL for new files
74+
75+
# Git refuses to operate in repos owned by a different UID.
76+
# Host gitconfig is mounted readonly as ~/.gitconfig.host.
77+
# Create a real ~/.gitconfig that includes it plus container overrides.
78+
printf '[include]\n\tpath = %s/.gitconfig.host\n[safe]\n\tdirectory = %s\n' \
79+
"$DEV_HOME" "$WORKSPACE" > "$DEV_HOME/.gitconfig"
80+
chown "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.gitconfig"
81+
82+
# If this is a worktree, the main .git dir is bind-mounted at its
83+
# host absolute path. Grant dev access so git operations work.
84+
GIT_COMMON_DIR=$(git -C "$WORKSPACE" rev-parse --git-common-dir 2>/dev/null || true)
85+
if [ -n "$GIT_COMMON_DIR" ] && [ "$GIT_COMMON_DIR" != "$WORKSPACE/.git" ]; then
86+
[ ! -d "$GIT_COMMON_DIR" ] && GIT_COMMON_DIR="$WORKSPACE/$GIT_COMMON_DIR"
87+
if [ -d "$GIT_COMMON_DIR" ]; then
88+
setfacl -Rm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
89+
setfacl -Rdm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
90+
git config -f "$DEV_HOME/.gitconfig" --add safe.directory "$(dirname "$GIT_COMMON_DIR")"
91+
fi
92+
fi
93+
94+
# Also fix bind-mounted dirs under ~dev that appear root-owned.
95+
for dir in /home/"$TARGET_USER"/.claude; do
96+
[ -d "$dir" ] && setfacl -Rm "u:${TARGET_USER}:rwX" "$dir" && setfacl -Rdm "u:${TARGET_USER}:rwX" "$dir"
97+
done
98+
[ -f /home/"$TARGET_USER"/.claude.json ] && \
99+
setfacl -m "u:${TARGET_USER}:rw" /home/"$TARGET_USER"/.claude.json
100+
else
101+
echo "warning: setfacl not found; dev user may not have write access to workspace" >&2
102+
echo " install the 'acl' package or set remoteUser to root" >&2
103+
fi
104+
fi

0 commit comments

Comments
 (0)