From 08685ea0fc85205fa84da52ca6ac99a705e3b4e8 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Sun, 20 Sep 2026 21:59:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9A=9A=20refactor:=20replace=20legacy?= =?UTF-8?q?=20.devcontainer=20Dockerfile=20with=20published=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the standalone .devcontainer Dockerfile and its provisioning scripts (configure-claude.sh, install-mcps.sh, statusline.sh) — this logic now lives in the neolabhq/sandbox image build (Dockerfile.agents, claude/*.sh) - Point devcontainer.json at the published `neolabhq/sandbox:latest` image instead of building locally, simplifying container startup - Add devcontainer-lock.json to pin the docker-outside-of-docker feature --- .devcontainer/Dockerfile | 125 --------------------------- .devcontainer/configure-claude.sh | 93 -------------------- .devcontainer/devcontainer-lock.json | 9 ++ .devcontainer/devcontainer.json | 75 +++++----------- .devcontainer/install-mcps.sh | 14 --- .devcontainer/statusline.sh | 86 ------------------ 6 files changed, 33 insertions(+), 369 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/configure-claude.sh create mode 100644 .devcontainer/devcontainer-lock.json delete mode 100644 .devcontainer/install-mcps.sh delete mode 100755 .devcontainer/statusline.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 62d2366..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,125 +0,0 @@ -############################################## -# Stage 1: Base image with all tool installs -############################################## -FROM mcr.microsoft.com/devcontainers/javascript-node:24-bullseye AS base - -# Install common utils -RUN apt-get update && apt-get install -y \ - apt-utils \ - bash-completion \ - openssh-client \ - gnupg2 \ - dirmngr \ - iproute2 \ - procps \ - lsof \ - htop \ - net-tools \ - psmisc \ - curl \ - tree \ - wget \ - rsync \ - ca-certificates \ - unzip \ - bzip2 \ - xz-utils \ - zip \ - nano \ - vim-tiny \ - less \ - jq \ - lsb-release \ - apt-transport-https \ - dialog \ - libc6 \ - libgcc1 \ - libkrb5-3 \ - libgssapi-krb5-2 \ - libicu[0-9][0-9] \ - liblttng-ust[0-9] \ - libstdc++6 \ - zlib1g \ - locales \ - sudo \ - ncdu \ - man-db \ - strace \ - manpages \ - manpages-dev \ - init-system-helpers \ - build-essential \ - procps \ - file \ - curl \ - retry \ - git \ - python3 \ - python3-pip - -# Upgrade packages and remove unused dependencies -RUN apt-get -y upgrade --no-install-recommends && apt-get autoremove -y - - -# Install Github CLI -RUN (type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \ - && sudo mkdir -p -m 755 /etc/apt/keyrings \ - && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ - && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && sudo mkdir -p -m 755 /etc/apt/sources.list.d \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && sudo apt update \ - && sudo apt install gh -y - -# Install Golang -RUN ARCH=$(dpkg --print-architecture) && \ - curl -fsSL "https://go.dev/dl/go1.26.1.linux-${ARCH}.tar.gz" | tar -C /usr/local -xz -ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}" - -# Install Codemap -RUN git clone --depth 1 https://github.com/JordanCoin/codemap.git /tmp/codemap \ - && cd /tmp/codemap && /usr/local/go/bin/go build -o /usr/local/bin/codemap . \ - && rm -rf /tmp/codemap - -RUN pip install dvc yq - -############################################## -# Stage 2: Install non-root tools -############################################## -FROM base AS tools - -USER node - -# Install Claude Code -RUN curl -fsSL https://claude.ai/install.sh | bash -# Need for claude code to work properly -ENV PATH="/home/node/.local/bin:${PATH}" - -RUN NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" - -# Install TypeScript LSP and Additional Tools -RUN npm install -g typescript-language-server typescript rust-just bun - -############################################## -# Stage 3: Prepare scripts -############################################## -FROM tools AS scripts - -USER root - -COPY configure-claude.sh statusline.sh install-mcps.sh /opt/devcontainer/ -RUN chmod +x /opt/devcontainer/*.sh - -############################################## -# Stage 4: Configure Claude Code -############################################## - -FROM scripts - -USER node - -ENV DOCKER_MCP_IN_CONTAINER=1 - -RUN /opt/devcontainer/configure-claude.sh \ No newline at end of file diff --git a/.devcontainer/configure-claude.sh b/.devcontainer/configure-claude.sh deleted file mode 100644 index c1ccac9..0000000 --- a/.devcontainer/configure-claude.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -echo "🔧 Configuring Claude Code environment..." - -# -- Copy statusline script to ~/.claude -- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -mkdir -p ~/.claude -cp "$SCRIPT_DIR/statusline.sh" ~/.claude/statusline.sh -chmod +x ~/.claude/statusline.sh -echo "✅ Copied statusline.sh to ~/.claude/" - -if [ ! -f ~/.claude/settings.json ]; then - echo "🔧 Creating ~/.claude/settings.json..." - cat > ~/.claude/settings.json << 'EOF' -{ - "permissions": { - "defaultMode": "auto" - }, - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "echo 'codemap . && git diff:' && git diff --stat && echo 'git status:' && git status" - } - ] - } - ] - }, - "statusLine": { - "type": "command", - "command": "bash ~/.claude/statusline.sh" - }, - "alwaysThinkingEnabled": true, - "skipDangerousModePermissionPrompt": true, - "effortLevel": "high", - "autoUpdatesChannel": "stable", - "companyAnnouncements": ["Thank you for using NeoLab Dev Container Sandbox", "Happy engineering!"] -} -EOF - echo "✅ Created ~/.claude/settings.json with bypass permissions." -fi - -retry() { - local max_attempts="${RETRY_ATTEMPTS:-3}" - local delay="${RETRY_DELAY:-1}" - local attempt=1 - while [ "$attempt" -le "$max_attempts" ]; do - if "$@"; then - return 0 - fi - echo "⚠️ Attempt $attempt/$max_attempts failed: $*" - if [ "$attempt" -lt "$max_attempts" ]; then - echo " Retrying in ${delay}s..." - sleep "$delay" - fi - attempt=$((attempt + 1)) - done - echo "❌ All $max_attempts attempts failed: $*" - return 1 -} - -# echo "🔧 Installing typescript lsp..." - -retry claude plugin marketplace add anthropics/claude-plugins-official -retry claude plugin install typescript-lsp@claude-plugins-official - -# echo "🔧 Installing context-engineering-kit plugins..." - -retry claude plugin marketplace add NeoLabHQ/context-engineering-kit -retry claude plugin install sdd@context-engineering-kit -retry claude plugin install sadd@context-engineering-kit -retry claude plugin install git@context-engineering-kit -retry claude plugin install ddd@context-engineering-kit -retry claude plugin install review@context-engineering-kit -retry claude plugin install tech-stack@context-engineering-kit - -# Merge only autoUpdates / autoCompactEnabled so we never replace the whole file (preserves other keys). -CLAUDE_JSON="$HOME/.claude.json" -echo "🔧 Ensuring ${CLAUDE_JSON} has autoUpdates and autoCompactEnabled..." -tmp="$(mktemp)" -if [ -f "$CLAUDE_JSON" ]; then - jq '. + {autoUpdates: true, autoCompactEnabled: false, hasCompletedOnboarding: true}' "$CLAUDE_JSON" >"$tmp" -else - jq -n '{autoUpdates: true, autoCompactEnabled: false, hasCompletedOnboarding: true}' >"$tmp" -fi -mv "$tmp" "$CLAUDE_JSON" -echo "✅ ${CLAUDE_JSON} updated (autoUpdates=true, autoCompactEnabled=false; other keys preserved)." - -echo "🚀 Claude Code environment ready." -echo "Use 'claude' to run Claude Code" \ No newline at end of file diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..05b2ad3 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "1.10.1", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0", + "integrity": "sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f8a1536..442254b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,42 +1,35 @@ + // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node { - "name": "NeoLabHQ Sandbox (Ubuntu 24.04)", - // use a Dockerfile. More info: https://containers.dev/guide/dockerfile - "build": { - "dockerfile": "Dockerfile" - }, - - - // Features to add to the dev container. More info: https://containers.dev/features. - // docker-outside-of-docker: mounts the host Docker socket and installs the Docker CLI. - // This MUST remain a features entry because it depends on host-level configuration - // (socket path, group GID mapping) that only the devcontainer CLI / VS Code can wire up. - // The docker-mcp CLI plugin (previously a bash-command feature) is now baked into - // the :agents image layer — see Dockerfile.agents and the migration table in - // .specs/tasks/todo/setup-docker-image.chore.md. + "name": "Agent Sandbox", + "image": "neolabhq/sandbox:latest", "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, - "ghcr.io/devcontainers-extra/features/bash-command:1": { - // Install docker mcp - "command": "git clone https://github.com/docker/mcp-gateway.git /tmp/mcp-gateway && cd /tmp/mcp-gateway && mkdir -p '/home/node/.docker/cli-plugins/' && DOCKER_MCP_CLI_PLUGIN_DST=/home/node/.docker/cli-plugins/docker-mcp && HOME=/home/node && make docker-mcp && chown -R node:node /home/node/" - } + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "moby": false + } + }, + "remoteUser": "vscode", + "containerEnv": { + "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}", + "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}", + "CONTEXT7_API_KEY": "${localEnv:CONTEXT7_API_KEY}", + "NODE_ENV": "development" }, - // Configure tool-specific properties. "customizations": { // Configure properties specific to VS Code. "vscode": { - "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" - }, + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh" + }, "extensions": [ "streetsidesoftware.code-spell-checker", - "anthropic.claude-code", - "dbaeumer.vscode-eslint", - "redhat.vscode-yaml", - "redhat.vscode-xml", - "redhat.vscode-json" + "anthropic.claude-code", + "dbaeumer.vscode-eslint", + "redhat.vscode-yaml", + "redhat.vscode-xml", + "redhat.vscode-json" ] } }, @@ -44,34 +37,14 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [3000, 8080], - // Use 'portsAttributes' to set default properties for specific forwarded ports. + // Use 'portsAttributes' to set default properties for specific forwarded ports. // More info: https://containers.dev/implementors/json_reference/#port-attributes "portsAttributes": { "3000": { "label": "Dev Server", "onAutoForward": "notify" } - }, - - // -- Environment variables -- - "containerEnv": { - "NODE_ENV": "development", - "COLORTERM": "truecolor" - }, - "remoteEnv": { - // if anthropic api key is set, will skip login - "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}", - "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}", - "CONTEXT7_API_KEY": "${localEnv:CONTEXT7_API_KEY}" - }, - - // Install MCP servers and plugins after the container starts (needs runtime env vars) - "postCreateCommand": { - // TODO: backe install-mcps.sh in Dockerfile to run automatically after the container starts. It should check for avaiable env vars and run the related mcp setup commands based on them. - "install-mcps": "/opt/devcontainer/install-mcps.sh" - }, - - "remoteUser": "node" + } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" -} +} \ No newline at end of file diff --git a/.devcontainer/install-mcps.sh b/.devcontainer/install-mcps.sh deleted file mode 100644 index 1039229..0000000 --- a/.devcontainer/install-mcps.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# TODO: add check for CONTEXT7_API_KEY, and run it only if it is set -echo "🔧 Adding Context7 MCP server..." -claude mcp add --scope user --transport http context7 https://mcp.context7.com/mcp --header "CONTEXT7_API_KEY: ${CONTEXT7_API_KEY:-}" - -# TODO: add check for DOCKER_MCP_SERVER, and run it only if it is set -# echo "🔧 Adding Docker MCP servers..." -# docker mcp feature enable profiles -# docker mcp catalog pull mcp/docker-mcp-catalog -# docker mcp profile create --name dev-tools --server catalog://mcp/docker-mcp-catalog/paper-search --connect claude-code - -echo "✅ MCP servers configured." diff --git a/.devcontainer/statusline.sh b/.devcontainer/statusline.sh deleted file mode 100755 index b0f2bfa..0000000 --- a/.devcontainer/statusline.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -input=$(cat) - -# === ORIGINAL FIRST LINE (with 2 text substitutions) === -model=$(echo "$input" | jq -r '.model.display_name // .model.id') -style=$(echo "$input" | jq -r '.output_style.name // "default"') -total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // 0') -total_output=$(echo "$input" | jq -r '.context_window.total_output_tokens // 0') -context_size=$(echo "$input" | jq -r '.context_window.context_window_size // 0') -total_used=$((total_input + total_output)) -if [ "$context_size" -gt 0 ]; then - percentage=$(awk "BEGIN {printf \"%.1f\", ($total_used / $context_size) * 100}") -else - percentage="0.0" -fi -if [ "$total_used" -ge 1000 ]; then - used_display=$(awk "BEGIN {printf \"%.1fK\", $total_used / 1000}") -else - used_display="${total_used}" -fi -if [ "$context_size" -ge 1000 ]; then - size_display=$(awk "BEGIN {printf \"%.0fK\", $context_size / 1000}") -else - size_display="${context_size}" -fi -git_branch=$(git -c core.fileMode=false config advice.detachedHead false 2>/dev/null && git branch --show-current 2>/dev/null || echo "no-git") - -# Session duration -duration_ms=$(echo "$input" | jq -r '.cost.total_duration_ms // 0') -duration_sec=$((duration_ms / 1000)) -dur_mins=$((duration_sec / 60)) -dur_secs=$((duration_sec % 60)) -session_dur="${dur_mins}m ${dur_secs}s" - -printf '\033[01;36m%s\033[00m \033[90m|\033[00m \033[01;33mStyle: %s\033[00m \033[90m|\033[00m \033[01;35m%s/%s (%s%%)\033[00m \033[90m|\033[00m \033[01;32m%s\033[00m \033[90m|\033[00m \033[2;37mEsc: interrupt\033[00m \033[90m|\033[00m \033[2;37mShift + Enter: new line\033[00m \033[90m|\033[00m \033[2;37m⏱️ %s\033[00m\n' "$model" "$style" "$used_display" "$size_display" "$percentage" "$git_branch" "$session_dur" - -# === Change 3: Add current/weekly usage lines === - -dot_bar() { - local p=$1 t=10 b="" i - local f=$(((p*t+50)/100)) - for i in $(seq 1 $t); do - if [ $i -le $f ]; then b="${b}● "; else b="${b}○ "; fi - done - printf "%s" "${b% }" -} - -fmt_dur() { - local s=$1 - if [ $s -lt 60 ]; then echo "${s}s" - elif [ $s -lt 3600 ]; then echo "$((s/60))min" - else local h=$((s/3600)) m=$(((s%3600)/60)); [ $m -eq 0 ] && echo "${h}hr" || echo "${h}hr ${m}min" - fi -} - -# Session line - uses ccusage blocks --active for real data -block_json=$(npx -y ccusage blocks --json --active 2>/dev/null) -block_cost=$(echo "$block_json" | jq -r '.blocks[0].costUSD // 0') -block_remaining=$(echo "$block_json" | jq -r '.blocks[0].projection.remainingMinutes // 0') -block_total_cost=$(echo "$block_json" | jq -r '.blocks[0].projection.totalCost // 0') - -# Calculate block usage percentage (projected cost out of a 5hr block) -# Use actual cost vs projected total as progress indicator -if [ "$block_total_cost" != "0" ] && [ "$block_total_cost" != "null" ]; then - block_pct=$(awk "BEGIN {v=($block_cost / $block_total_cost) * 100; printf \"%.0f\", v}") -else - block_pct=0 -fi -[ "$block_pct" -gt 100 ] 2>/dev/null && block_pct=100 - -cur_bar=$(dot_bar "$block_pct") - -# Format remaining time -remaining_int=$(printf "%.0f" "$block_remaining") -if [ "$remaining_int" -ge 60 ]; then - rem_h=$((remaining_int / 60)) - rem_m=$((remaining_int % 60)) - [ $rem_m -eq 0 ] && rem_display="${rem_h}hr" || rem_display="${rem_h}hr ${rem_m}min" -else - rem_display="${remaining_int}min" -fi - -# Format cost -block_cost_fmt=$(printf '$%.2f' "$block_cost") - -printf 'session %s %d%% ↻ %s till refresh\n' "$cur_bar" "$block_pct" "$rem_display" From 35ad9816638d46adff24b85e6db9476f86736237 Mon Sep 17 00:00:00 2001 From: leovs09 Date: Mon, 21 Sep 2026 22:13:54 +0200 Subject: [PATCH 2/2] feat: add options to keep claude code allways updated Claude Code's baked-in version and marketplace clone go stale over time since publishes only fire on push to master. Add a weekly scheduled rebuild that skips the GHA cache so all upstream deps re-resolve, skip CI for docs-only pushes, repair the `claude` symlink in setup.sh when a shared runtime volume doesn't contain the baked version, and document the optional shared-install volumes in the README. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/publish.yaml | 58 +++++++++---- .../keep-claude-code-always-green.chore.md | 87 +++++++++++++++++++ README.md | 57 ++++++++++-- setup.sh | 39 +++++++++ 4 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 .specs/tasks/done/keep-claude-code-always-green.chore.md diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 08cf332..eccbf98 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -54,6 +54,19 @@ on: push: branches: - master + paths-ignore: + - 'README.md' + - 'CONTRIBUTING.md' + - 'LICENSE' + - '.specs/**' + - '.claude/**' + # Weekly dependency-refreshing rebuild (see "Caching" below) so the + # published moving tags never drift too far behind upstream Claude Code + # releases, marketplace updates, and OS package security fixes. The repo's + # default branch is `master`, matching `push.branches` above, which + # satisfies GitHub's rule that `schedule:` only fires on the default branch. + schedule: + - cron: '0 3 * * 1' # Mondays 03:00 UTC # Least-privilege defaults at the workflow level. # - contents: read — checkout source @@ -99,6 +112,19 @@ concurrency: # 9. Build multi-arch (linux/amd64,linux/arm64) and push to GHCR. Captures # the resulting digest as a job output so the next layer can pin to it. # +# Caching. Step 5 sets `no-cache: true`, so every upstream dependency +# (claude.ai/install.sh, marketplace clones, apt, mise, brew, nix) is +# re-resolved — that is what makes the weekly cron publish a genuinely new +# image rather than a byte-identical one. Step 9 deliberately omits it: both +# steps run against the same `docker-container` builder created by +# docker/setup-buildx-action, whose BuildKit state lives in a volume for the +# job's lifetime, so step 9 reuses the amd64 layers step 5 just built. The +# published amd64 bits are therefore the exact bits Trivy scanned, and the +# amd64 work is done once instead of twice. Nothing survives the job: the +# runner is ephemeral and no cache backend is configured. Do NOT add +# `cache-from: type=gha` / `cache-to: type=gha` — that would let a scheduled +# rebuild on an unchanged commit republish a stale image. +# # Action versions are pinned to MAJOR per # /workspaces/sandbox/.claude/rules/research-version-claims.md — MAJOR pins # absorb maintainer-issued minor security fixes without code churn while still @@ -201,8 +227,8 @@ jobs: load: true push: false tags: ${{ env.IMAGE_NAME }}:base-scan-${{ github.sha }} - cache-from: type=gha,scope=base - cache-to: type=gha,mode=max,scope=base + # Force every upstream dependency to be re-resolved + no-cache: true # Phase 2: scan the image we just loaded. Fails the job on any fixable # CRITICAL/HIGH CVE (ignore-unfixed: true). SARIF output is uploaded to @@ -243,6 +269,7 @@ jobs: # Phase 3: multi-arch build + push. Only runs if every previous step # (including the Trivy gate) succeeded. The push step's `digest` output # is exported as a job output for build-agents to consume. + # Build reuses the amd64 layers the scan step just wrote to the job's builder cache - name: Build & push multi-arch id: push uses: docker/build-push-action@v6 @@ -255,8 +282,6 @@ jobs: sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=base - cache-to: type=gha,mode=max,scope=base - name: Summary run: | @@ -332,8 +357,8 @@ jobs: build-args: | BASE_IMAGE=${{ env.IMAGE_NAME }}:base@${{ needs.build-base.outputs.digest }} tags: ${{ env.IMAGE_NAME }}:agents-scan-${{ github.sha }} - cache-from: type=gha,scope=agents - cache-to: type=gha,mode=max,scope=agents + # Force every upstream dependency to be re-resolved + no-cache: true # trivy-action pinned to v0.36.0; see anchor in build-base job. - name: Trivy vulnerability scan @@ -363,6 +388,7 @@ jobs: upload-artifact: true upload-artifact-retention: 30 + # Build reuses the amd64 layers the scan step just wrote to the job's builder cache - name: Build & push multi-arch id: push uses: docker/build-push-action@v6 @@ -377,8 +403,6 @@ jobs: BASE_IMAGE=${{ env.IMAGE_NAME }}:base@${{ needs.build-base.outputs.digest }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=agents - cache-to: type=gha,mode=max,scope=agents - name: Summary run: | @@ -455,8 +479,8 @@ jobs: build-args: | AGENTS_IMAGE=${{ env.IMAGE_NAME }}:agents@${{ needs.build-agents.outputs.digest }} tags: ${{ env.IMAGE_NAME }}:latest-scan-${{ github.sha }} - cache-from: type=gha,scope=final - cache-to: type=gha,mode=max,scope=final + # Force every upstream dependency to be re-resolved + no-cache: true # trivy-action pinned to v0.36.0; see anchor in build-base job. - name: Trivy vulnerability scan @@ -486,6 +510,7 @@ jobs: upload-artifact: true upload-artifact-retention: 30 + # Build reuses the amd64 layers the scan step just wrote to the job's builder cache - name: Build & push multi-arch id: push uses: docker/build-push-action@v6 @@ -500,8 +525,6 @@ jobs: AGENTS_IMAGE=${{ env.IMAGE_NAME }}:agents@${{ needs.build-agents.outputs.digest }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=final - cache-to: type=gha,mode=max,scope=final - name: Summary run: | @@ -528,9 +551,7 @@ jobs: # # `:universal` is the opt-in drop-in replacement for the legacy # `devcontainers/universal` image — adds Ruby/PHP/.NET/Rust/Zig on top of - # the minimal :latest. Same build/scan/push shape as the other jobs but - # with `scope=universal` so a universal-only change doesn't invalidate the - # final-layer cache. + # the minimal :latest. Same build/scan/push shape as the other jobs. # ─────────────────────────────────────────────────────────────────────────── build-universal: name: Build & publish :universal @@ -581,8 +602,8 @@ jobs: build-args: | FINAL_IMAGE=${{ env.IMAGE_NAME }}:latest@${{ needs.build-final.outputs.digest }} tags: ${{ env.IMAGE_NAME }}:universal-scan-${{ github.sha }} - cache-from: type=gha,scope=universal - cache-to: type=gha,mode=max,scope=universal + # Force every upstream dependency to be re-resolved + no-cache: true # trivy-action pinned to v0.36.0; see anchor in build-base job. - name: Trivy vulnerability scan @@ -612,6 +633,7 @@ jobs: upload-artifact: true upload-artifact-retention: 30 + # Build reuses the amd64 layers the scan step just wrote to the job's builder cache - name: Build & push multi-arch id: push uses: docker/build-push-action@v6 @@ -626,8 +648,6 @@ jobs: FINAL_IMAGE=${{ env.IMAGE_NAME }}:latest@${{ needs.build-final.outputs.digest }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=universal - cache-to: type=gha,mode=max,scope=universal - name: Summary run: | diff --git a/.specs/tasks/done/keep-claude-code-always-green.chore.md b/.specs/tasks/done/keep-claude-code-always-green.chore.md new file mode 100644 index 0000000..7d1a108 --- /dev/null +++ b/.specs/tasks/done/keep-claude-code-always-green.chore.md @@ -0,0 +1,87 @@ +--- +title: Add shared-volume Claude install and cache-free weekly rebuilds to keep Claude Code and marketplace green +--- + +## Initial User Prompt + +this project have issue. Claude code becomes outdated quite fast, so it need to be updated regular. Which means every time starting container need to write `claude update && claude` when entering container. Possible to add update as start command in devcontainer, but it will slowdown container launch. then this container need to be periodically updated, to keep base claude code version safe. But I not see good way to implement it. Plus also need update context-enginering-kit plugin marketplace, together with claude code, as it also quite often updated. Suggest options to reach desired state: close to allways green claude code and marketplace. +Options that I named have minuses, but still considered, if you not suggest something better. + +### Requirements + +#### Context and findings + +Observed live in a running `neolabhq/sandbox:latest` container on 2026-09-20 (verify at build time with `claude --version` and `ls ~/.local/share/claude/versions`): + +- Claude Code's native background updater already works. The image was built with one version baked in and the container had self-updated to a newer one; `~/.local/bin/claude` had been re-pointed by the updater. Stale-binary-on-first-launch is therefore **accepted as-is** and explicitly out of scope. +- `~/.local/share/claude/` contains **only** `versions/` binaries (~230 MB each, two present = 446 MB). No config, no credentials, no sessions. +- `~/.claude/plugins/` holds `marketplaces/` (~40 MB), `cache/` (~3.8 MB), `installed_plugins.json`, `known_marketplaces.json`, `plugin-catalog-cache.json` (~516 KB) — ~45 MB total. +- `.github/workflows/publish.yaml` triggers only on `push` to `master`. There is no scheduled rebuild, so the published moving tags accumulate an ever-older baked Claude Code and marketplace clone. +- All eight `docker/build-push-action` steps in `publish.yaml` use `cache-from: type=gha` / `cache-to: type=gha`. + +#### Scope + +Three changes. The default (no-volume) behaviour of every documented usage pattern must remain byte-for-byte identical to today. + +**1. `.github/workflows/publish.yaml` — cache-free scheduled and push builds** + +- Add a weekly `schedule:` trigger (`cron: '0 3 * * 1'`, Mondays 03:00 UTC) alongside the existing `push`. The repo default branch is `master`, which matches the existing `push.branches`, so GitHub's default-branch-only rule for `schedule:` is satisfied. +- Remove **all** GHA build cache: delete the 16 `cache-from` / `cache-to` lines across the eight build steps, and add an explicit `no-cache: true` with a comment stating the intent (every build must re-resolve upstream dependencies — `claude.ai/install.sh`, marketplace clones, apt, mise, brew, nix). The comment exists so a future contributor does not reintroduce caching as an "optimization"; with cache enabled a scheduled rebuild on an unchanged commit would reuse every layer and republish a byte-identical stale image, making the cron pointless. +- Add `paths-ignore` to the `push` trigger for docs-only paths (`README.md`, `CONTRIBUTING.md`, `LICENSE`, `.specs/**`, `.claude/**`) so a documentation commit does not trigger a full cold build of the four-image chain. The weekly cron rebuilds regardless, so skipping docs commits cannot make the published image stale. +- Trivy scanning and the SHA-suffixed immutable rollback tags already run per build, so weekly CVE re-scanning of the moving tags comes for free. + +**2. `setup.sh` — repair a dangling `claude` symlink** + +`~/.local/bin/claude` is a symlink to a specific version directory and lives **outside** the runtime volume. Docker seeds a named volume only when it is empty and never re-seeds a populated one. So once a user has the runtime volume configured, the first `docker pull` of a newer image leaves the image's symlink pointing at a version that the volume does not contain, and `claude` fails with "No such file or directory". The weekly cron makes this fire on a weekly cadence for every user with the volume configured — it is the normal path, not an edge case. + +Add a repair step to the existing per-container section of `setup.sh`: if `~/.local/bin/claude` does not resolve, re-point it at the highest `sort -V` entry under `~/.local/share/claude/versions`. `[ ! -e ]` is false for a dangling symlink, so the step fires exactly when broken and is a no-op otherwise. It must honour `setup.sh`'s existing contracts — always exit 0, log to stderr with the `[sandbox-setup]` prefix, never `exec`. It cooperates with Claude Code's own updater, which re-points the same symlink on its next update. + +**3. `README.md` — document the shared-install variant** + +Each usage example (ephemeral/CI, devcontainer quick setup, devcontainer with Docker MCP, persistent Claude state, multiple project directories) gains an **optional** shared-install variant adding two named volumes: + +``` +-v sandbox-claude-runtime:/home/vscode/.local/share/claude +-v sandbox-claude-plugins:/home/vscode/.claude/plugins +``` + +and the devcontainer equivalent: + +```jsonc +"mounts": [ + "source=sandbox-claude-runtime,target=/home/vscode/.local/share/claude,type=volume", + "source=sandbox-claude-plugins,target=/home/vscode/.claude/plugins,type=volume" +] +``` + +Document explicitly: + +- These volumes hold **binaries and marketplace clones only** — no credentials, no sessions, no project history. Mounting them does not leak the host Claude profile, so they are safe in the ephemeral/CI pattern. +- One Claude Code install per machine, shared by every sandbox container. A marketplace updated once by hand applies everywhere. +- In persistent mode the `~/.claude/plugins` named volume is nested inside the `~/.claude` host bind mount. Docker resolves by path depth so this works, but the host's `plugins/` directory is shadowed — plugin state becomes machine-shared rather than host-profile-bound. +- To pick up plugins newly added to `configure-claude.sh`, run `docker volume rm sandbox-claude-plugins`. Docker seeds a volume only once, so an already-populated volume will not gain them otherwise. +- The image keeps its baked Claude Code binary as a **seed floor**: with no volume configured the container behaves exactly as today, and with a volume configured Docker auto-seeds it on first use. + +#### Explicitly out of scope (considered and rejected during brainstorming) + +Do not reintroduce these without a new decision: + +- **Any install or "ensure-latest" step at container start.** The seeded volume already has a binary and Claude Code self-updates. An install step is redundant and would slow container launch, which is the cost the user set out to avoid. +- **Strict install-on-first-use with no baked binary.** Rejected because it makes the no-mount ephemeral/CI pattern pay a ~230 MB download per run and hard-require network. +- **Re-applying `autoUpdate: true` to `known_marketplaces.json` at runtime.** Claude Code rewrites that file and drops the key. With a shared marketplace volume the user updates the marketplace once by hand and it applies everywhere, so the flag is unnecessary. The existing build-time jq patch in `configure-claude.sh` is therefore dead weight but is left untouched by this task. +- **A console banner announcing the shared install.** Rejected — it would describe behaviour the script no longer performs. +- **Pruning `~/.local/share/claude/versions`.** Claude Code runs this layout on every machine and is assumed to manage its own version retention. +- **`VOLUME` in the Dockerfile.** Yields an anonymous per-container volume, not the machine-wide shared install, and litters `docker volume ls`. +- **A background/detached updater, a pre-warm sidecar, and event-driven `repository_dispatch` rebuilds.** All rejected as more machinery than the outcome justifies. + +#### Acceptance criteria + +- With no volumes configured, every documented usage pattern starts as fast as today and `claude` runs the baked version. No regression. +- With the runtime volume configured: seed it from one image, pull a newer image, start a container, and `claude --version` still works — proving the symlink repair. +- A scheduled workflow run produces a different image digest than the preceding push-triggered run on the same commit, proving the cache removal took effect. +- A docs-only push does not trigger the publish workflow. +- Two containers started concurrently against the same volumes both run `claude` successfully. + +## Description + +// Will be filled in future stages by business analyst diff --git a/README.md b/README.md index 2117d5d..ec03bef 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,6 @@ docker run -it --rm \ bash ``` -Then launch your prefered agent - -```bash -claude -``` **What each flag does:** @@ -164,6 +159,25 @@ touch ~/.claude.json **Trade-off.** Mounting `~/.claude*` binds the container to your host machine's Claude profile. That is ideal for interactive daily development but undesirable for CI runners or shared environments. For those use cases, see the ephemeral pattern below. +### Auto-update claude between containers + +Claude Code updates itself into `~/.local/share/claude`, which lives in the container's writable layer. The update dies with the container, and the next container starts again on the version baked into the image. + +Use this command to mount the runtime and plugin directories as named volumes and the updated binary and the marketplace clones carry over to every later container on the machine. + +```bash +docker run -it --rm \ + -v "$PWD:/workspaces/$(basename "$PWD")" \ + -v sandbox-claude-runtime:/home/vscode/.local/share/claude \ + -v sandbox-claude-plugins:/home/vscode/.claude/plugins \ + -e CLAUDE_CODE_OAUTH_TOKEN \ + -e ANTHROPIC_API_KEY \ + -e CONTEXT7_API_KEY \ + -w "/workspaces/$(basename "$PWD")" \ + neolabhq/sandbox:latest \ + bash +``` + --- @@ -200,6 +214,31 @@ touch ~/.claude.json # run once on the host if the file does not exist yet The `:ro` flag prevents the container from modifying your host keys or config. +### Shared Claude install (optional) + +Two named volumes hold the Claude Code runtime and the plugin marketplace clones. Mounting them shares one install across every sandbox container on the machine instead of re-downloading it into each container's writable layer: + +```bash +-v sandbox-claude-runtime:/home/vscode/.local/share/claude +-v sandbox-claude-plugins:/home/vscode/.claude/plugins +``` + +Devcontainer equivalent: + +```jsonc +"mounts": [ + "source=sandbox-claude-runtime,target=/home/vscode/.local/share/claude,type=volume", + "source=sandbox-claude-plugins,target=/home/vscode/.claude/plugins,type=volume" +] +``` + +The details: + +- The volumes hold the `~/.local/share/claude/versions/*` binaries and the `~/.claude/plugins/marketplaces/` clones. Credentials (`~/.claude/.credentials.json`), session state, and project history (`~/.claude.json`) live outside them, so mounting them in the ephemeral/CI pattern leaves the container hermetic with respect to the host Claude profile. +- Every sandbox container on the machine then resolves to the same Claude Code binary and marketplace clone. Update the marketplace once and the update applies everywhere. +- Combined with the persistent `-v "$HOME/.claude:/home/vscode/.claude"` bind mount, the `sandbox-claude-plugins` volume mounts inside it at `/home/vscode/.claude/plugins`. Docker resolves mounts by path depth, so both apply, but the volume shadows the host's `~/.claude/plugins/` directory — plugin state becomes machine-shared rather than host-profile-bound. +- Docker seeds a named volume from the image once, while the volume is still empty; it never re-seeds a populated one. To pick up plugins added to `configure-claude.sh` in a later image, remove the volume so it re-seeds: `docker volume rm sandbox-claude-plugins`. + --- ## Mounting multiple project directories @@ -380,6 +419,10 @@ Minimal configuration. The `docker-outside-of-docker` feature connects container "moby": false } }, + "mounts": [ + "source=sandbox-claude-runtime,target=/home/vscode/.local/share/claude,type=volume", + "source=sandbox-claude-plugins,target=/home/vscode/.claude/plugins,type=volume" + ], "remoteUser": "vscode", "containerEnv": { "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}", @@ -405,7 +448,9 @@ For projects that want MCP servers proxied from the host's [Docker MCP Catalog]( } }, "mounts": [ - "source=${localEnv:HOME}/.docker/mcp,target=/home/vscode/.docker/mcp,type=bind,consistency=cached" + "source=${localEnv:HOME}/.docker/mcp,target=/home/vscode/.docker/mcp,type=bind,consistency=cached", + "source=sandbox-claude-runtime,target=/home/vscode/.local/share/claude,type=volume", + "source=sandbox-claude-plugins,target=/home/vscode/.claude/plugins,type=volume" ], "remoteUser": "vscode", "containerEnv": { diff --git a/setup.sh b/setup.sh index d296d49..5717bd8 100755 --- a/setup.sh +++ b/setup.sh @@ -47,6 +47,15 @@ # is logged and ignored. Keyed by a single container-wide sentinel # (PWD-independent) so it registers at most once per container. # +# 3. Claude symlink repair. `~/.local/bin/claude` is a symlink to a specific +# version under `~/.local/share/claude/versions/` and lives outside the +# optional shared runtime volume. Docker seeds a named volume only when +# empty and never re-seeds a populated one, so a `docker pull` of a newer +# image can leave the baked symlink pointing at a version the (already +# populated) volume does not contain. When that happens, re-point it at +# the highest `sort -V` version present. A no-op when the symlink already +# resolves or `versions/` is absent/empty. +# # 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 @@ -154,5 +163,35 @@ else || log "Could not write MCP sentinel ${mcp_sentinel}; MCP registration may repeat." fi +# ----------------------------------------------------------------------------- +# (3) Claude symlink repair (per-container, PWD-independent). +# +# `~/.local/bin/claude` is a symlink to a version under +# `~/.local/share/claude/versions/`. `[ ! -e "$claude_bin" ]` is false for a +# healthy symlink and true for a dangling one (its target is gone), so this +# only fires when the symlink is actually broken. Cooperates with Claude +# Code's own updater, which re-points the same symlink on its next update. +# ----------------------------------------------------------------------------- +claude_bin="$HOME/.local/bin/claude" +claude_versions_dir="$HOME/.local/share/claude/versions" + +if [ ! -e "$claude_bin" ]; then + log "${claude_bin} does not resolve; attempting repair from ${claude_versions_dir}." + if [ -d "$claude_versions_dir" ]; then + latest_claude_version="$(ls -1 "$claude_versions_dir" 2>/dev/null | sort -V | tail -n1)" + if [ -n "$latest_claude_version" ]; then + if ln -sf "$claude_versions_dir/$latest_claude_version" "$claude_bin" 2>/dev/null; then + log "Re-pointed ${claude_bin} -> ${claude_versions_dir}/${latest_claude_version}." + else + log "Failed to re-point ${claude_bin}; leaving as-is." + fi + else + log "No entries under ${claude_versions_dir}; cannot repair ${claude_bin}." + fi + else + log "${claude_versions_dir} does not exist; cannot repair ${claude_bin}." + fi +fi + # Always succeed: see the header's "ALWAYS exits 0" contract. exit 0