Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ LABEL org.opencontainers.image.source="https://github.com/NeoLabHQ/sandbox"
LABEL org.opencontainers.image.description="NeoLabHQ sandbox: fully configured devcontainer image with Claude Code, AI agents, LSPs, MCP servers, pre-bootstrapped ~/.claude settings, and an entrypoint that autodetects project mise/devbox files and gates MCP registration on env-var presence"
LABEL org.opencontainers.image.licenses="MIT"

###############################################################################
# devcontainer.metadata — bake setup.sh as a postStartCommand for the
# devcontainer flow.
#
# The devcontainer CLI / VS Code read this label and merge it into the
# effective devcontainer config (image metadata + the user's
# devcontainer.json). It is what makes runtime autodetection + MCP registration
# work when the image is consumed as a devcontainer, because:
#
# - For IMAGE-based devcontainers, `overrideCommand` defaults to true: the
# CLI replaces the image's ENTRYPOINT+CMD with its own sleep loop, so
# entrypoint.sh never runs and its setup never happens.
# - `postStartCommand` runs on EVERY container start, in the workspace
# folder, as the remoteUser. That workspace-folder CWD is a better PWD for
# project-file detection than the PID-1 entrypoint's WORKDIR /workspaces
# (which sits one level above the actual project).
#
# The named-object form (`{"sandbox-setup": "..."}`) is used deliberately: the
# spec collects lifecycle commands from image metadata AND user config and
# merges named entries by key, so a consumer-defined `postStartCommand` in
# their own devcontainer.json composes with ours instead of colliding with /
# overwriting it.
###############################################################################
LABEL devcontainer.metadata='[{"postStartCommand": {"sandbox-setup": "/opt/devcontainer/setup.sh"}}]'

###############################################################################
# Copy the repo-root entrypoint and the `claude/` helper directory into the
# image.
Expand Down Expand Up @@ -104,11 +129,67 @@ LABEL org.opencontainers.image.licenses="MIT"
USER root

COPY entrypoint.sh /opt/devcontainer/
COPY setup.sh /opt/devcontainer/
COPY claude/ /opt/devcontainer/claude/
COPY --chown=vscode:vscode claude/justfile /home/vscode/justfile
COPY --chown=vscode:vscode claude/claude-helpers.sh /home/vscode/claude-helpers.sh

RUN chmod +x /opt/devcontainer/entrypoint.sh /opt/devcontainer/claude/*.sh
RUN chmod +x /opt/devcontainer/entrypoint.sh /opt/devcontainer/setup.sh /opt/devcontainer/claude/*.sh

###############################################################################
# Interactive-shell setup hook for the `docker exec` path.
#
# `docker exec` ALWAYS bypasses the ENTRYPOINT, and devcontainer attach shells
# arrive this way too — so neither entrypoint.sh nor the baked
# `postStartCommand` runs for an interactive `docker exec ... bash`/`zsh`. This
# block appends a guarded snippet to the system-wide rc files (Debian trixie:
# /etc/bash.bashrc for bash, /etc/zsh/zshrc for zsh — both shipped by the base
# image per the README's "bash, fish, zsh") so the first interactive shell in a
# fresh container triggers the same setup.sh as the other two paths.
#
# The snippet is written as root (we are still USER root here, before the
# `USER vscode` switch below) so it lands in the system rc files that apply to
# every user's interactive shells.
#
# Design constraints baked into the snippet:
# - Interactive-only: the `case $- in *i*` guard means non-interactive shells
# (scripts, `bash -c`, tooling) skip it entirely — no startup cost there.
# - Fast-path on the sentinels: when both /tmp sentinels already exist (the
# common case after the first shell), it returns before invoking anything,
# so it never noticeably slows shell startup. setup.sh self-guards on the
# same sentinels anyway; this is purely an extra fast exit.
# - No stdout: setup.sh logs only to stderr with a `[sandbox-setup]` prefix,
# so the hook cannot corrupt output for tools that parse a shell's stdout.
# - Marker-guarded + idempotent append: the `# >>> ... >>>` / `# <<< ... <<<`
# pattern (matching the p-alias block below) means re-running this RUN, or
# layering atop an image that already has it, will not duplicate the block.
###############################################################################
RUN <<'OUTER'
# Build the snippet once, then append it to each present rc file under a
# marker guard so re-runs / re-layers never duplicate it.
snippet="$(cat <<'RC_EOF'

# >>> sandbox setup-hook >>>
# Run once-per-container project setup on the first interactive shell. Needed
# because `docker exec` bypasses the image ENTRYPOINT. setup.sh self-guards via
# /tmp sentinels and only logs to stderr; the sentinel fast-path below avoids
# even invoking it once setup has already run this container session.
case $- in
*i*)
if [ ! -e "/tmp/.sandbox-setup-mcp" ] || [ ! -e "/tmp/.sandbox-setup.$(printf '%s' "$PWD" | cksum | cut -d' ' -f1)" ]; then
[ -x /opt/devcontainer/setup.sh ] && /opt/devcontainer/setup.sh || true
fi
;;
esac
# <<< sandbox setup-hook <<<
RC_EOF
)"
for rc in /etc/bash.bashrc /etc/zsh/zshrc; do
[ -f "$rc" ] || continue
grep -q '# >>> sandbox setup-hook >>>' "$rc" 2>/dev/null && continue
printf '%s\n' "$snippet" >> "$rc"
done
OUTER

###############################################################################
# Runtime marker for in-container detection.
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ ENV MISE_INSTALL_PATH=/usr/local/bin/mise
ENV MISE_DATA_DIR=/usr/local/share/mise
ENV MISE_CONFIG_DIR=/etc/mise
ENV MISE_CACHE_DIR=/var/cache/mise
# Disable trust prompts, they useless in containerized environments
ENV MISE_TRUSTED_CONFIG_PATHS=/

RUN curl -fsSL https://mise.run | sh \
&& mkdir -p "$MISE_DATA_DIR" "$MISE_CONFIG_DIR" "$MISE_CACHE_DIR" \
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,12 +317,14 @@ The image-level defaults (set via `mise use --global`) resolve at build time. Ex

### Language runtime autodetection

The image wires `/opt/devcontainer/entrypoint.sh` as the Dockerfile `ENTRYPOINT`. The entrypoint runs as the `vscode` user before the user's shell or command starts.
The image runs the same setup logic (`/opt/devcontainer/setup.sh`) from three triggers so autodetection works regardless of how you start the container: `docker run`, `devcontainer flow`, `docker exec`.

It automatically detects following:
`setup.sh` automatically detects the following and apply only once in container:

- Project runtimes is based on the presence of `mise.toml` / `.mise.toml` / `.tool-versions` / `devbox.json`. If any of the files present, it will invoke mise or devbox cli to install runtimes and innject in shell. Mise invoked first. If not files present, it fallbacks to latest versions.
- MCP based on the presence of the listed environment variables.
- **Project runtimes** based on the presence of `devbox.json` / `mise.toml` / `.mise.toml` / `.tool-versions`, found by walking up from the working directory. `devbox.json` is checked first (runs `devbox install`), then the mise files (runs `mise install`). If no file is present, the image-global runtime versions apply.
- **MCP servers** based on the presence of the `CONTEXT7_API_KEY` and `DOCKER_MCP_SERVER` environment variables (delegated to `claude/install-mcp.sh`).

**Limitation.** Only the `ENTRYPOINT` path wraps your command in `mise exec --` / `devbox shell --`, and that only applies to PID 1. An interactive `docker exec` shell does not inherit that wrapper — it relies on the `mise` shims being first on `PATH` (so `node`, `python3`, `go`, `java` resolve through the project-pinned versions that `setup.sh` already installed). For `devbox`-pinned system CLIs, run `devbox shell` in the project directory to enter the project's nix profile.

#### Example project files

Expand Down
87 changes: 42 additions & 45 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,30 @@
#
# Responsibilities (in order):
#
# 1. Project-runtime autodetection. Walks from $PWD up to / looking, at each
# directory, for `devbox.json` first, then `mise.toml`, then `.mise.toml`,
# then `.tool-versions`. First match wins.
# 1. Once-per-container project setup. Delegated to the side-effects-only
# `setup.sh` (resolved relative to this file via $BASH_SOURCE so it works
# both in-repo and inside the image at /opt/devcontainer/setup.sh).
# setup.sh performs the project-runtime install (`mise install` /
# `devbox install`) and env-var-gated MCP registration; it is shared with
# the devcontainer `postStartCommand` and the interactive-shell rc hooks
# so those contexts get the same setup the ENTRYPOINT cannot reach. It is
# best-effort and always exits 0 — a non-zero exit is logged and ignored,
# since setup failures must NOT prevent the container from starting.
#
# 2. Activator selection. Walks from $PWD up to / looking, at each directory,
# for `devbox.json` first, then `mise.toml`, then `.mise.toml`, then
# `.tool-versions`. First match wins.
# - devbox.json found: hand off to `devbox shell --` so the project's
# pinned nixpkgs profile is prepended to PATH.
# - mise.toml / .mise.toml / .tool-versions found: run `mise install`
# (no-op when versions already match) and hand off to `mise exec --`.
# - mise project file found: hand off to `mise exec --` (setup.sh already
# ran `mise install`).
# - Nothing found: fall through to a plain `exec "$@"` — the image-
# global mise pins from Dockerfile.base (node@lts, python@latest,
# go@latest, java@temurin-lts) apply.
#
# 2. MCP-server autodetection. Delegated to the standalone script
# `claude/install-mcp.sh` (resolved relative to this file via
# $BASH_SOURCE so it works both in-repo and inside the image at
# /opt/devcontainer/claude/install-mcp.sh). The contract is unchanged
# from the previous inline implementation — env-var-gated registration
# mirrors `.devcontainer/install-mcps.sh` (preserved unchanged for the
# local devcontainer flow):
# - CONTEXT7_API_KEY set: register the Context7 MCP server via
# `claude mcp add`.
# - DOCKER_MCP_SERVER set: activate the baked docker-mcp CLI plugin
# by running, in order, `docker mcp feature enable profiles`,
# `docker mcp catalog pull mcp/docker-mcp-catalog`, and
# `docker mcp profile create --name dev-tools --server
# "$DOCKER_MCP_SERVER" --connect claude-code`. Each command is
# best-effort: failures are logged and do not abort startup.
# - Neither set: log a single skip line and move on.
# A non-zero exit from install-mcp.sh is logged and ignored — MCP
# registration failures must NOT prevent the container from starting.
# The find_up detection is kept here (a small, readable duplication of
# setup.sh's identical walk) because the activator decision is unique to
# the ENTRYPOINT's `exec` hand-off and has no place in the side-effects
# script.
#
# 3. Hand off to the CMD (or any explicit `docker run ... <cmd>` argv).
# Never silently swallows CMD — every code path ends with `exec`.
Expand All @@ -57,10 +52,31 @@ log() {
}

# -----------------------------------------------------------------------------
# (1) Project-runtime autodetection.
# (1) Once-per-container project setup.
#
# Delegated to setup.sh — see that script for the full contract. Resolved
# relative to this file via BASH_SOURCE so the same invocation works both for
# local repo runs and inside the image (where this file lives at
# /opt/devcontainer/entrypoint.sh and the script at
# /opt/devcontainer/setup.sh). setup.sh always exits 0, but we still guard the
# call so an unexpected failure (e.g. a missing script) is logged rather than
# aborting the hand-off in section (3).
# -----------------------------------------------------------------------------
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if "$script_dir/setup.sh"; then
:
else
log "setup.sh exited non-zero; continuing."
fi

# -----------------------------------------------------------------------------
# (2) Activator selection.
#
# find_up walks from $PWD up to / (exclusive) and returns the first path that
# contains a file named $1. Returns 1 with no output when no match is found.
# This mirrors setup.sh's identical walk; the small duplication is intentional
# (see the header) — the activator decision belongs only to this ENTRYPOINT's
# `exec` hand-off, not to the side-effects script.
# -----------------------------------------------------------------------------
find_up() {
local name="$1" dir="$PWD"
Expand All @@ -81,31 +97,12 @@ if devbox_path="$(find_up devbox.json)"; then
elif mise_path="$(find_up mise.toml)" \
|| mise_path="$(find_up .mise.toml)" \
|| mise_path="$(find_up .tool-versions)"; then
log "mise project file detected at ${mise_path}; running 'mise install' and activating 'mise exec --'."
mise install >&2 || log "mise install reported a non-zero status; continuing."
log "mise project file detected at ${mise_path}; activating 'mise exec --'."
activator=(mise exec --)
else
log "No mise/devbox project file detected — falling back to image-global runtime versions."
fi

# -----------------------------------------------------------------------------
# (2) MCP-server autodetection (env-var-gated).
#
# Delegated to claude/install-mcp.sh — see the script for the full contract.
# Resolved relative to this file via BASH_SOURCE so the same invocation works
# both for local repo runs and inside the image (where this file lives at
# /opt/devcontainer/entrypoint.sh and the script at
# /opt/devcontainer/claude/install-mcp.sh). The script's exit status is
# logged but not propagated — MCP registration is best-effort and must not
# block the hand-off in section (3).
# -----------------------------------------------------------------------------
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if "$script_dir/claude/install-mcp.sh"; then
:
else
log "claude/install-mcp.sh exited non-zero; continuing."
fi

# -----------------------------------------------------------------------------
# (3) Hand off to CMD / explicit argv.
#
Expand Down
Loading
Loading