From b02d9ca63149a66bc52ef8b62386ce7ceb17f5a3 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Thu, 11 Jun 2026 19:05:01 +0200 Subject: [PATCH 1/2] fix: disable mise trust prompts --- Dockerfile.base | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile.base b/Dockerfile.base index f7878a1..b76465d 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -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" \ From 57f5215f674a5b6ff9620ccb1c471adb4d0c4636 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Fri, 12 Jun 2026 00:41:56 +0200 Subject: [PATCH 2/2] fix: runtime setup logic --- Dockerfile | 83 +++++++++++++++++++++++++- README.md | 10 ++-- entrypoint.sh | 87 ++++++++++++++------------- setup.sh | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 50 deletions(-) create mode 100755 setup.sh diff --git a/Dockerfile b/Dockerfile index 5d07fa1..4d2ce48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. @@ -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. diff --git a/README.md b/README.md index 242a790..2117d5d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index e0ae968..f6e081a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 ... ` argv). # Never silently swallows CMD — every code path ends with `exec`. @@ -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" @@ -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. # diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..d296d49 --- /dev/null +++ b/setup.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +############################################################################### +# setup.sh — once-per-container project setup for neolabhq/sandbox:latest. +# +# Side-effects-only counterpart to entrypoint.sh. Where entrypoint.sh selects +# an activator and `exec`s the CMD as PID 1, this script performs the +# environment-mutating work that must also run in the two contexts the +# ENTRYPOINT never reaches: +# +# 1. ENTRYPOINT — entrypoint.sh calls this before its `exec`. +# 2. devcontainer flow — baked as a `postStartCommand` via the image's +# `devcontainer.metadata` LABEL. The devcontainer +# CLI / VS Code set `overrideCommand: true` for +# image-based devcontainers, replacing ENTRYPOINT+CMD +# with a sleep loop, so the ENTRYPOINT never runs. +# 3. `docker exec` shells — interactive bash/zsh rc hooks (installed into +# /etc/bash.bashrc and /etc/zsh/zshrc) invoke this, +# since `docker exec` always bypasses the ENTRYPOINT. +# +# Because it runs from three unrelated contexts — none of which may be broken +# or noticeably slowed — this script: +# - NEVER `exec`s and NEVER hands off a shell. It only performs side effects. +# - ALWAYS exits 0. Internal failures are logged to stderr and swallowed. +# - Is idempotent via sentinel files so repeat invocations are near-instant +# no-ops (see "Idempotency" below). +# +# Responsibilities (in order): +# +# 1. Project-runtime install. 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: `devbox install` in that project directory so the +# project's pinned nixpkgs packages are materialized. No shell handoff +# (that is entrypoint.sh's job). +# - mise project file found: `mise install` (no-op when versions already +# match). +# - Nothing found: log and continue. +# Keyed by a per-container + per-project sentinel so cd-ing between +# projects re-runs the install for each new project dir, but a second +# invocation in the same dir is a no-op. +# +# 2. MCP-server registration. Delegated to the standalone +# `claude/install-mcp.sh`, resolved relative to this file via $BASH_SOURCE +# so it works both in-repo and inside the image (where this file lives at +# /opt/devcontainer/setup.sh and the script at +# /opt/devcontainer/claude/install-mcp.sh). Best-effort: a non-zero exit +# is logged and ignored. Keyed by a single container-wide sentinel +# (PWD-independent) so it registers at most once per container. +# +# Idempotency: sentinels live under /tmp (cleared on container restart, giving +# once-per-container semantics). The runtime-install sentinel embeds a stable +# hash of $PWD so each project dir is keyed independently; the MCP sentinel is +# a single fixed path. Each sentinel is written only AFTER its step has run — +# success or best-effort failure both count, since the goal is once-per- +# container execution, not retry-until-success. +# +# Logging contract: all diagnostic output goes to stderr prefixed with +# `[sandbox-setup]` so consumers piping stdout (CI, tooling that parses shell +# output) are not polluted by setup chatter. +############################################################################### + +# Intentionally NOT `set -e`: a failure in any step must never abort this +# script, because it runs from rc hooks and a postStartCommand that must not +# break. We guard each step explicitly and always exit 0. +set -uo pipefail + +log() { + printf '[sandbox-setup] %s\n' "$*" >&2 +} + +# ----------------------------------------------------------------------------- +# 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. +# Mirrors entrypoint.sh's find_up so detection is identical across both scripts. +# ----------------------------------------------------------------------------- +find_up() { + local name="$1" dir="$PWD" + while [ "$dir" != "/" ]; do + if [ -e "$dir/$name" ]; then + printf '%s\n' "$dir/$name" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +# Stable, collision-resistant-enough key for the current project directory. +# cksum is in coreutils and always present; we only need a per-$PWD token, not +# a cryptographic digest. +pwd_key() { + printf '%s' "$PWD" | cksum | cut -d' ' -f1 +} + +# ----------------------------------------------------------------------------- +# (1) Project-runtime install (per-container, per-project-dir). +# ----------------------------------------------------------------------------- +runtime_sentinel="/tmp/.sandbox-setup.$(pwd_key)" + +install_project_runtime() { + local devbox_path mise_path + if devbox_path="$(find_up devbox.json)"; then + local devbox_dir + devbox_dir="$(dirname "${devbox_path}")" + log "devbox.json detected at ${devbox_path}; running 'devbox install' in ${devbox_dir}." + # `--config ` points devbox at the directory holding devbox.json, so + # the project's pinned packages install regardless of $PWD within the tree. + if devbox install --config "${devbox_dir}" >&2; then + log "devbox install completed." + else + log "devbox install reported a non-zero status; continuing." + fi + 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'." + if mise install >&2; then + log "mise install completed." + else + log "mise install reported a non-zero status; continuing." + fi + else + log "No mise/devbox project file detected — relying on image-global runtime versions." + fi +} + +if [ -e "$runtime_sentinel" ]; then + log "Runtime already set up for this project dir this container session; skipping." +else + install_project_runtime + : > "$runtime_sentinel" 2>/dev/null \ + || log "Could not write runtime sentinel ${runtime_sentinel}; runtime install may repeat." +fi + +# ----------------------------------------------------------------------------- +# (2) MCP-server registration (per-container, PWD-independent). +# +# Delegated to claude/install-mcp.sh, resolved relative to this file so the +# same invocation works in-repo and inside the image. The script's exit status +# is logged but never propagated — MCP registration is best-effort. +# ----------------------------------------------------------------------------- +mcp_sentinel="/tmp/.sandbox-setup-mcp" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -e "$mcp_sentinel" ]; then + log "MCP registration already attempted this container session; skipping." +else + if "$script_dir/claude/install-mcp.sh"; then + : + else + log "claude/install-mcp.sh exited non-zero; continuing." + fi + : > "$mcp_sentinel" 2>/dev/null \ + || log "Could not write MCP sentinel ${mcp_sentinel}; MCP registration may repeat." +fi + +# Always succeed: see the header's "ALWAYS exits 0" contract. +exit 0