diff --git a/.github/hooks/devcontainer-guard.json b/.github/hooks/devcontainer-guard.json new file mode 100644 index 0000000..a08ef1c --- /dev/null +++ b/.github/hooks/devcontainer-guard.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "./.github/hooks/devcontainer-guard.sh", + "timeoutSec": 5 + } + ] + } +} diff --git a/.github/hooks/devcontainer-guard.sh b/.github/hooks/devcontainer-guard.sh new file mode 100755 index 0000000..430e308 --- /dev/null +++ b/.github/hooks/devcontainer-guard.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# devcontainer-guard.sh — PreToolUse hook for Claude Code & GitHub Copilot CLI +# +# Blocks bash/shell tool calls when .devcontainer/devcontainer.json exists in +# the working directory, forcing agents to use devcontainer-mcp MCP tools +# instead of running commands directly on the host. +# +# Read-only tools (view, grep, glob) and file edits are allowed through — only +# command execution is blocked. +# +# Host-safe commands (git, gh, curl, etc.) are allowlisted and always permitted +# since they operate on the repo/host, not the project's build environment. +# +# Bypass: include USER_CONFIRMED_HOST_OPERATION=1 in the command. +# +# Supports both agent payload formats: +# Claude Code: { tool_name, tool_input, cwd, ... } +# Copilot CLI: { toolName, toolArgs, cwd, ... } + +set -euo pipefail + +INPUT=$(cat) + +# --- Detect agent format and extract fields --- + +# Try Claude Code fields first (snake_case), fall back to Copilot CLI (camelCase) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // empty') +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') + +# Only guard bash/shell tool calls — allow everything else through +case "$TOOL_NAME" in + Bash|bash|shell|powershell|Shell|PowerShell) ;; + *) exit 0 ;; +esac + +TOOL_INPUT=$(echo "$INPUT" | jq -r '(.tool_input // .toolArgs // {}) | tostring') + +# Check for the bypass string anywhere in the tool input +if echo "$TOOL_INPUT" | grep -q 'USER_CONFIRMED_HOST_OPERATION=1'; then + exit 0 +fi + +# Check if a devcontainer exists in the working directory +if [ -z "$CWD" ]; then + # No cwd in payload — can't determine context, allow through + exit 0 +fi + +if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then + # No devcontainer — allow through + exit 0 +fi + +# --- Devcontainer exists: check allowlist before blocking --- + +# Extract the command string from tool input (handles both formats) +COMMAND=$(echo "$INPUT" | jq -r '(.tool_input.command // .toolArgs.command // "") | tostring') + +# Commands that are safe to run on the host even when a devcontainer exists. +# These operate on the repo/host itself, not on the project's build environment. +ALLOWED_HOST_COMMANDS=( + git + gh +) + +# Extract all meaningful commands from a shell string, skipping env vars +# (KEY=VALUE) and cd/pushd/popd. Splits on &&, ||, ;, and | to catch every +# command in a chain or pipeline. +all_commands() { + local cmd="$1" + while IFS= read -r segment; do + segment="${segment#"${segment%%[![:space:]]*}"}" + [ -z "$segment" ] && continue + for token in $segment; do + if [[ "$token" == *=* && "$token" != -* ]]; then + continue + fi + case "$token" in + cd|pushd|popd) break ;; + esac + basename "$token" + break + done + done < <(echo "$cmd" | sed 's/ *&& */\n/g; s/ *|| */\n/g; s/ *; */\n/g; s/ *| */\n/g') +} + +# Every command in the chain must be on the allowlist +ALL_ALLOWED=true +while IFS= read -r cmd_name; do + [ -z "$cmd_name" ] && continue + FOUND=false + for allowed in "${ALLOWED_HOST_COMMANDS[@]}"; do + if [ "$cmd_name" = "$allowed" ]; then + FOUND=true + break + fi + done + if [ "$FOUND" = false ]; then + ALL_ALLOWED=false + break + fi +done < <(all_commands "$COMMAND") + +if [ "$ALL_ALLOWED" = true ] && [ -n "$(all_commands "$COMMAND")" ]; then + exit 0 +fi + +# --- Not on the allowlist: block the tool call --- + +DENY_REASON="Host execution blocked. This project has a devcontainer. Use devcontainer-mcp tools (devcontainer_exec, devpod_ssh, codespaces_ssh, and file operation tools) instead of running commands directly on the host." + +# Detect which agent format to use for the response +if echo "$INPUT" | jq -e '.tool_name // empty' >/dev/null 2>&1 && \ + [ -n "$(echo "$INPUT" | jq -r '.tool_name // empty')" ]; then + # Claude Code format + jq -n --arg reason "$DENY_REASON" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' +else + # Copilot CLI format + jq -n --arg reason "$DENY_REASON" '{ + permissionDecision: "deny", + permissionDecisionReason: $reason + }' +fi diff --git a/.github/hooks/devcontainer-skill-loader.json b/.github/hooks/devcontainer-skill-loader.json new file mode 100644 index 0000000..1a6a8ce --- /dev/null +++ b/.github/hooks/devcontainer-skill-loader.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "./.github/hooks/devcontainer-skill-loader.sh", + "timeoutSec": 5 + } + ] + } +} diff --git a/.github/hooks/devcontainer-skill-loader.sh b/.github/hooks/devcontainer-skill-loader.sh new file mode 100755 index 0000000..5dcfbc9 --- /dev/null +++ b/.github/hooks/devcontainer-skill-loader.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# devcontainer-skill-loader.sh — SessionStart hook for Claude Code & Copilot CLI +# +# When a session starts in a directory with .devcontainer/devcontainer.json, +# injects the devcontainer-mcp SKILL.md content as additionalContext so the +# agent automatically knows how to use devcontainer-mcp tools. +# +# Supports both agent payload formats: +# Claude Code: { tool_name, tool_input, cwd, ... } +# Copilot CLI: { toolName, toolArgs, cwd, ... } + +set -euo pipefail + +INPUT=$(cat) + +# Extract working directory from the payload +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') + +if [ -z "$CWD" ]; then + exit 0 +fi + +if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then + exit 0 +fi + +# Look for SKILL.md in order of preference +SKILL_PATH="" +SEARCH_PATHS=( + "${HOME}/.local/share/devcontainer-mcp/SKILL.md" + "${HOME}/.copilot/skills/devcontainer-mcp/SKILL.md" + "${HOME}/.claude/skills/devcontainer-mcp/SKILL.md" + "${HOME}/.agents/skills/devcontainer-mcp/SKILL.md" +) + +for p in "${SEARCH_PATHS[@]}"; do + if [ -f "$p" ]; then + SKILL_PATH="$p" + break + fi +done + +if [ -z "$SKILL_PATH" ]; then + exit 0 +fi + +SKILL_CONTENT=$(cat "$SKILL_PATH") + +jq -n --arg ctx "$SKILL_CONTENT" '{ "additionalContext": $ctx }' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4eea12..461d9f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,12 @@ on: release: types: [created] -permissions: - contents: write - jobs: build: name: Build release binaries runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v5 @@ -49,6 +48,8 @@ jobs: build-macos: name: Build ${{ matrix.artifact }} runs-on: macos-latest + permissions: + contents: write strategy: matrix: include: @@ -80,6 +81,8 @@ jobs: upload-install-script: name: Upload install scripts runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v5 - name: Upload install.sh and install.ps1 @@ -88,3 +91,49 @@ jobs: files: | install.sh install.ps1 + + publish-mcp-registry: + name: Publish to MCP Registry + runs-on: ubuntu-latest + needs: [build, build-macos] + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v5 + + - name: Install mcp-publisher + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \ + | tar xz mcp-publisher + + - name: Authenticate to MCP Registry (OIDC) + run: ./mcp-publisher login github-oidc + + - name: Patch server.json with version, URLs, and SHA-256 hashes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + BASE="https://github.com/aniongithub/devcontainer-mcp/releases/download/${TAG}" + + # Set top-level version + jq --arg v "$VERSION" '.version = $v' server.json > server.tmp && mv server.tmp server.json + + # Download each platform artifact, compute SHA-256, patch identifier + hash + PLATFORMS=("linux-x64" "linux-arm64" "darwin-x64" "darwin-arm64") + for platform in "${PLATFORMS[@]}"; do + FILE="devcontainer-mcp-${platform}.tar.gz" + curl -fsSL --retry 5 --retry-delay 3 -o "$FILE" "${BASE}/${FILE}" + SHA="$(sha256sum "$FILE" | awk '{print $1}')" + URL="${BASE}/${FILE}" + jq --arg platform "$platform" --arg url "$URL" --arg sha "$SHA" \ + '(.packages[] | select(.identifier | contains($platform))) |= (.identifier = $url | .fileSha256 = $sha)' \ + server.json > server.tmp && mv server.tmp server.json + done + + - name: Publish to MCP Registry + run: ./mcp-publisher publish diff --git a/Cargo.lock b/Cargo.lock index 2987616..06dd48c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,13 +298,16 @@ name = "devcontainer-mcp" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", "devcontainer-mcp-core", "rmcp", "schemars 1.2.1", "serde", "serde_json", + "shlex", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] @@ -317,11 +320,15 @@ dependencies = [ "base64", "bollard", "futures-util", + "jsonc-parser", + "libc", "serde", "serde_json", - "shell-escape", + "shlex", + "tempfile", "thiserror", "tokio", + "tokio-util", "tracing", ] @@ -358,12 +365,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -461,12 +480,34 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -707,6 +748,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -779,18 +826,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonc-parser" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4" +dependencies = [ + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -930,6 +998,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -948,6 +1026,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1029,6 +1113,19 @@ dependencies = [ "syn", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1085,6 +1182,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1189,12 +1292,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-escape" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" - [[package]] name = "shlex" version = "1.3.0" @@ -1267,6 +1364,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1457,6 +1567,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -1502,6 +1618,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -1547,6 +1681,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1637,6 +1805,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/README.md b/README.md index f1931ac..e920864 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,18 @@ # devcontainer-mcp [![CI](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml) +[![Website](https://img.shields.io/badge/website-devcontainer--mcp-blue)](https://www.anionline.me/devcontainer-mcp) **Give your AI agent its own dev environment — not yours.** `devcontainer-mcp` is an MCP server that lets AI coding agents create, manage, and work inside [dev containers](https://containers.dev/) across three backends: local Docker, [DevPod](https://devpod.sh/), and [GitHub Codespaces](https://github.com/features/codespaces). The agent builds, tests, and ships code in an isolated container — your laptop stays clean. +

+ devcontainer-mcp local Docker demo +

+ +> Works with **GitHub Copilot**, **Claude**, **Cursor**, **opencode**, and any MCP-compatible client. + ## The Problem When AI agents write code, they need to run it somewhere. Today that means your host machine: @@ -14,6 +21,7 @@ When AI agents write code, they need to run it somewhere. Today that means your - 🔴 **"Works on my machine"** — agents assume your local toolchain matches production - 🔴 **No isolation** — one project's dependencies break another - 🔴 **Security risk** — agents run arbitrary commands with your user privileges +- 🔴 **Hardware constraints** — you're limited to your local machine's resources ## The Solution @@ -36,6 +44,10 @@ Agent: "Let me build this project..." ## Quick Install +

+ devcontainer-mcp install demo +

+ ### Linux / macOS ```bash @@ -79,6 +91,10 @@ graph TD ## Three Backends, One Interface +

+ devcontainer-mcp Codespaces demo +

+ | Backend | Best for | Requires | Auth needed? | |---------|----------|----------|:---:| | **devcontainer CLI** (`devcontainer_*`) | Local Docker — fast, simple | [@devcontainers/cli](https://github.com/devcontainers/cli) + Docker | No | @@ -98,7 +114,7 @@ Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP s Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** -## MCP Tools (45 total) +## MCP Tools (46 total) ### Auth (4 tools) @@ -133,7 +149,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devpod_file_edit` | Surgical string replacement — old_str → new_str | | `devpod_file_list` | List directory contents (non-hidden, 2 levels deep) | -### devcontainer CLI (11 tools) +### devcontainer CLI (12 tools) | Tool | Description | |------|-------------| @@ -141,6 +157,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devcontainer_exec` | Execute a command inside a running dev container | | `devcontainer_build` | Build a dev container image | | `devcontainer_read_config` | Read merged devcontainer configuration as JSON | +| `devcontainer_list_configs` | Discover all devcontainer.json files in a workspace (single + multi-container) | | `devcontainer_stop` | Stop a dev container (via Docker API) | | `devcontainer_remove` | Remove a dev container and its resources | | `devcontainer_status` | Get dev container state by workspace folder | @@ -205,6 +222,15 @@ Install backend CLIs as needed — the MCP server detects them at runtime and re When `devcontainer_up`, `devpod_up`, or `codespaces_create` fails, the full build output (including errors) is returned to the agent. The agent can read the error, fix the `Dockerfile` or `devcontainer.json`, and retry — making the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite. +## Multi-container workspaces + +The devcontainer spec supports [connecting to multiple containers](https://code.visualstudio.com/remote/advancedcontainers/connect-multiple-containers) in one workspace by placing per-service configs at `.devcontainer//devcontainer.json`, each pointing at a shared `docker-compose.yml`. `devcontainer-mcp` supports this pattern end-to-end: + +- **Discovery** — `devcontainer_list_configs` returns every config it finds (root `.devcontainer.json`, `.devcontainer/devcontainer.json`, and each `.devcontainer/*/devcontainer.json`) with its kind (`image` / `dockerfile` / `compose`), service name, and absolute path. +- **Targeting** — Every devcontainer tool (`up`, `exec`, `build`, `stop`, `remove`, `status`, `read_config`, `file_*`) accepts an optional `config` parameter pointing at a specific `devcontainer.json`. Single-container workflows continue to work unchanged — `config` defaults to whatever the devcontainer CLI auto-detects. +- **Ambiguity handling** — When a workspace has multiple configs and no `config` is provided, lookup-style tools (`status`, `exec`, `stop`, `remove`, `file_*`) return a structured `Ambiguous` result listing every matching container so the agent can pick the right one. `status` reports this as `{"state":"Ambiguous","candidates":[...],"hint":"..."}`. +- **Robust container matching** — Sibling compose containers are identified via `com.docker.compose.service` + `com.docker.compose.project.config_files` (not the unreliable `devcontainer.local_folder` label, which is only stamped on the first container). + ## Development This project eats its own dogfood — development happens inside its own devcontainer. diff --git a/SKILL.md b/SKILL.md index 367de62..9e09880 100644 --- a/SKILL.md +++ b/SKILL.md @@ -185,6 +185,8 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: - ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything - ✅ DO check `.devcontainer/devcontainer.json` first +> **Note:** Host-protection hooks are installed for supported agent environments (Claude Code, GitHub Copilot CLI) that automatically block shell commands when a devcontainer is detected. If a command is blocked, use the appropriate MCP tool instead. + ## File Operations **All backends support built-in file operations — no need to construct shell commands.** diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml index b737647..18c6f2e 100644 --- a/crates/devcontainer-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] tokio = { workspace = true } +tokio-util = "0.7" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -16,4 +17,11 @@ tracing = { workspace = true } futures-util = "0.3" async-trait = "0.1" base64 = "0.22" -shell-escape = "0.1" +shlex = "1" +jsonc-parser = { version = "0.26", features = ["serde"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs index cee82ec..18e72c8 100644 --- a/crates/devcontainer-mcp-core/src/cli.rs +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -1,7 +1,10 @@ use serde::Serialize; use std::collections::HashMap; use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; +use tokio_util::sync::CancellationToken; use crate::error::{Error, Result}; @@ -57,8 +60,38 @@ impl CliBinary { } } +/// Which output stream a chunk came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, +} + +/// A chunk of output from a streaming CLI invocation. +#[derive(Debug, Clone)] +pub struct OutputChunk { + pub stream: OutputStream, + /// One line of output (without trailing newline). + pub line: String, +} + +/// Sink for streamed output chunks. +/// +/// Implementations should be fast — they're awaited inline by the +/// background dispatcher and slow sinks will buffer up in the bounded +/// channel. Errors must be swallowed inside the sink (we don't want to +/// abort the underlying process because progress reporting failed). +#[async_trait::async_trait] +pub trait ChunkSink: Send + Sync + 'static { + async fn on_chunk(&self, chunk: OutputChunk); +} + /// Run a CLI command, capturing stdout/stderr/exit_code. /// If `parse_json` is true, attempts to parse stdout as JSON. +/// +/// Convenience wrapper around [`run_cli_streaming`] with no progress sink +/// and a fresh (never-fired) cancellation token. Callers that want +/// cancellation or progress should use [`run_cli_streaming`] directly. pub async fn run_cli(binary: &CliBinary, args: &[&str], parse_json: bool) -> Result { run_cli_with_env(binary, args, parse_json, None).await } @@ -69,9 +102,48 @@ pub async fn run_cli_with_env( args: &[&str], parse_json: bool, env: Option<&HashMap>, +) -> Result { + run_cli_streaming( + binary, + args, + parse_json, + env, + &CancellationToken::new(), + None, + ) + .await +} + +/// Run a CLI command with full control over cancellation and progress. +/// +/// - `cancel`: if cancelled while the child is running, the child is killed +/// (SIGTERM, then SIGKILL after a 2s grace period via `kill_on_drop`) +/// and [`Error::Cancelled`] is returned. Any output produced before +/// cancellation is forwarded to the sink but discarded from the return +/// value. +/// - `on_chunk`: optional sink invoked for every line of stdout/stderr as +/// it arrives. Useful for emitting MCP progress notifications. +/// +/// Regardless of streaming, the full stdout/stderr is also accumulated +/// and returned in [`CliOutput`] for callers that need the final result. +pub async fn run_cli_streaming( + binary: &CliBinary, + args: &[&str], + parse_json: bool, + env: Option<&HashMap>, + cancel: &CancellationToken, + on_chunk: Option>, ) -> Result { let mut cmd = Command::new(binary.command_name()); - cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Belt-and-braces: if this future is dropped (e.g. caller bails + // before we finish handling cancel), tokio will SIGKILL the + // child for us. Without this, a cancelled but un-awaited child + // would survive as a zombie / runaway process. + .kill_on_drop(true); if let Some(env_vars) = env { for (k, v) in env_vars { @@ -79,7 +151,7 @@ pub async fn run_cli_with_env( } } - let output = cmd.output().await.map_err(|e| { + let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { binary.not_found_error() } else { @@ -87,20 +159,452 @@ pub async fn run_cli_with_env( } })?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let exit_code = output.status.code().unwrap_or(-1); + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + let stdout_task = tokio::spawn(drain_stream( + stdout, + OutputStream::Stdout, + on_chunk.clone(), + )); + let stderr_task = tokio::spawn(drain_stream( + stderr, + OutputStream::Stderr, + on_chunk.clone(), + )); + + // Wait for either: child exits, or cancellation fires. + let status = tokio::select! { + biased; + _ = cancel.cancelled() => { + tracing::warn!( + bin = binary.command_name(), + "cancellation received; reaping process tree" + ); + // SIGTERM the entire descendant tree (including any + // in-container processes spawned via `devcontainer exec` + // → `docker exec` → containerd-shim). Container PID + // namespacing hides PIDs from each other but not from + // the host, so /proc walking sees through it. + if let Some(root_pid) = child.id() { + crate::process_tree::reap(root_pid, std::time::Duration::from_secs(2)).await; + } + // The root child should now be dead; wait briefly to + // collect its exit status and let the reader tasks observe + // EOF on stdout/stderr. `kill_on_drop(true)` is our + // backstop if any of this fails. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + child.wait(), + ) + .await; + let _ = stdout_task.await; + let _ = stderr_task.await; + return Err(Error::Cancelled); + } + status = child.wait() => status?, + }; + + // Child exited; collect the accumulated output. + let stdout_buf = stdout_task.await.unwrap_or_default(); + let stderr_buf = stderr_task.await.unwrap_or_default(); + let exit_code = status.code().unwrap_or(-1); let json = if parse_json { - serde_json::from_str(&stdout).ok() + serde_json::from_str(&stdout_buf).ok() } else { None }; Ok(CliOutput { exit_code, + stdout: stdout_buf, + stderr: stderr_buf, + json, + }) +} + +/// Read `reader` line-by-line, forwarding each line to `sink` (if set) +/// and accumulating into the returned buffer. EOF or error ends the loop. +async fn drain_stream( + reader: R, + stream: OutputStream, + sink: Option>, +) -> String { + let mut lines = BufReader::new(reader).lines(); + let mut buf = String::new(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(&line); + if let Some(sink) = sink.as_ref() { + sink.on_chunk(OutputChunk { + stream, + line, + }) + .await; + } + } + Ok(None) => break, + Err(e) => { + tracing::debug!(%e, ?stream, "error reading child output stream"); + break; + } + } + } + if !buf.is_empty() { + buf.push('\n'); + } + buf +} + +/// Drain a stream that may contain the [`crate::exec_shim`] sentinel. +/// +/// Lines matching `__DCMCP_PGID=N__` are intercepted: `N` is stored +/// into `pgid_out` and the line is suppressed from both the sink and +/// the accumulated buffer. All other lines flow through normally. +async fn drain_stream_with_sentinel( + reader: R, + stream: OutputStream, + sink: Option>, + pgid_out: Arc>>, +) -> String { + let mut lines = BufReader::new(reader).lines(); + let mut buf = String::new(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if let Some(found) = crate::exec_shim::try_parse_sentinel(&line) { + *pgid_out.lock().expect("pgid lock") = Some(found); + tracing::debug!(pgid = found, ?stream, "captured remote PGID"); + continue; + } + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(&line); + if let Some(sink) = sink.as_ref() { + sink.on_chunk(OutputChunk { stream, line }).await; + } + } + Ok(None) => break, + Err(e) => { + tracing::debug!(%e, ?stream, "error reading child output stream"); + break; + } + } + } + if !buf.is_empty() { + buf.push('\n'); + } + buf +} + +/// Trait for a backend-specific "send a kill into the remote target". +/// +/// Used by [`run_with_shim`] to deliver SIGTERM/SIGKILL to a remote +/// process group when cancellation fires. The runner calls this with +/// the PGID captured from the shim's sentinel; the implementor knows +/// how to reach into the target (docker exec, devpod ssh, gh ssh, …). +/// +/// Implementations should be *idempotent* and *forgiving*: a kill of +/// an already-exited process is fine, and the trait is invoked on a +/// best-effort basis. +#[async_trait::async_trait] +pub trait RemoteKiller: Send + Sync { + /// Send `signal` (a POSIX signal name like "TERM" or "KILL") to + /// the entire process group `pgid` on the remote target. + async fn kill_pgid(&self, pgid: i32, signal: &str); +} + +/// Run a CLI command whose remote target requires the [`crate::exec_shim`] +/// shim for cancellation. +/// +/// Differences from [`run_cli_streaming`]: +/// +/// - The stderr reader filters out the sentinel line emitted by the +/// shim and stores the captured remote PGID in shared state. +/// - On cancel, before reaping the host-side process tree, the helper +/// asks the supplied [`RemoteKiller`] to deliver SIGTERM then SIGKILL +/// to the captured PGID (with a 2-second grace between them). This +/// reaches workloads that have been reparented to the remote's init +/// (docker daemon's containerd-shim, sshd, …) and would otherwise +/// survive the death of our host-side wrapper. +/// +/// If the sentinel never arrives (extremely fast cancellation, or a +/// target without `setsid`/base64), the helper falls back to host-tree +/// reaping only — the remote process *may* leak in that narrow window. +pub async fn run_with_shim( + binary: &CliBinary, + args: &[&str], + env: Option<&HashMap>, + cancel: &CancellationToken, + on_chunk: Option>, + killer: Arc, +) -> Result { + let mut cmd = Command::new(binary.command_name()); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + if let Some(env_vars) = env { + for (k, v) in env_vars { + cmd.env(k, v); + } + } + + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + binary.not_found_error() + } else { + Error::Io(e) + } + })?; + + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + // Shared captured PGID. Filled in by the stderr reader as soon as + // the shim's sentinel line arrives; consumed by the cancel branch. + let pgid: Arc>> = Arc::new(std::sync::Mutex::new(None)); + + // stdout: no sentinel to scrub (the shim writes it to stderr). + let stdout_task = tokio::spawn(drain_stream( stdout, + OutputStream::Stdout, + on_chunk.clone(), + )); + // stderr: filter the sentinel line, forward everything else. + let stderr_task = tokio::spawn(drain_stream_with_sentinel( stderr, - json, + OutputStream::Stderr, + on_chunk.clone(), + pgid.clone(), + )); + + let status = tokio::select! { + biased; + _ = cancel.cancelled() => { + tracing::warn!( + bin = binary.command_name(), + "cancellation received; killing remote process group then host wrapper" + ); + + let captured = *pgid.lock().expect("pgid lock"); + match captured { + Some(pgid_val) => { + // Remote kill first: if we kill the host wrapper + // first, the transport to the target closes and + // the remote work keeps running. + killer.kill_pgid(pgid_val, "TERM").await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + killer.kill_pgid(pgid_val, "KILL").await; + } + None => { + tracing::warn!( + bin = binary.command_name(), + "no remote PGID captured; relying on host reap (may leak)" + ); + } + } + + // Host-side reap of the wrapper and any host descendants + // we *can* see. For SSH-based backends this catches the + // local SSH client process. + if let Some(root_pid) = child.id() { + crate::process_tree::reap(root_pid, std::time::Duration::from_secs(2)).await; + } + + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + child.wait(), + ) + .await; + let _ = stdout_task.await; + let _ = stderr_task.await; + return Err(Error::Cancelled); + } + status = child.wait() => status?, + }; + + let stdout_buf = stdout_task.await.unwrap_or_default(); + let stderr_buf = stderr_task.await.unwrap_or_default(); + + Ok(CliOutput { + exit_code: status.code().unwrap_or(-1), + stdout: stdout_buf, + stderr: stderr_buf, + json: None, }) } + +#[cfg(test)] +mod shim_runner_tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Mutex; + + /// Records every `kill_pgid` invocation so tests can assert on + /// the order and arguments. + struct RecordingKiller { + calls: Mutex>, + } + + #[async_trait::async_trait] + impl RemoteKiller for RecordingKiller { + async fn kill_pgid(&self, pgid: i32, signal: &str) { + self.calls.lock().unwrap().push((pgid, signal.to_string())); + } + } + + /// Sink that just counts received chunks. + struct CountingSink { + stdout_lines: AtomicU64, + stderr_lines: AtomicU64, + } + #[async_trait::async_trait] + impl ChunkSink for CountingSink { + async fn on_chunk(&self, chunk: OutputChunk) { + match chunk.stream { + OutputStream::Stdout => { + self.stdout_lines.fetch_add(1, Ordering::Relaxed); + } + OutputStream::Stderr => { + self.stderr_lines.fetch_add(1, Ordering::Relaxed); + } + } + } + } + + /// Create a `devpod` shim script in `dir` that ignores all args + /// except the value after `--command` and runs that under `sh -c`. + /// This lets us drive `run_with_shim` end-to-end without a real + /// DevPod install — `binary.command_name()` is `devpod`, and our + /// shim picks it up from `PATH`. + fn install_devpod_shim(dir: &std::path::Path) -> std::path::PathBuf { + let script = dir.join("devpod"); + // The fake devpod: parse out `--command ` and exec it + // via `sh -c`. Everything else (workspace name, --user, etc.) + // is discarded — we only care that the shim's body runs. + let body = r#"#!/bin/sh +cmd="" +while [ $# -gt 0 ]; do + case "$1" in + --command) + shift + cmd="$1" + shift + ;; + *) + shift + ;; + esac +done +exec sh -c "$cmd" +"#; + std::fs::write(&script, body).expect("write shim"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + } + script + } + + /// End-to-end: shim wraps a sleep, runner captures PGID from + /// stderr, cancellation triggers RemoteKiller in the right order + /// (TERM then KILL with a 2s gap), and the call returns + /// Error::Cancelled. + /// + /// This exercises the full `run_with_shim` flow without docker + /// or any real backend — the `RemoteKiller` is a recording fake + /// and the "remote" is just a local shell invoked by a stub + /// `devpod` shim on PATH. + #[tokio::test] + async fn run_with_shim_invokes_killer_in_order_on_cancel() { + let tmp = tempfile::tempdir().expect("tmpdir"); + install_devpod_shim(tmp.path()); + let new_path = format!( + "{}:{}", + tmp.path().display(), + std::env::var("PATH").unwrap_or_default() + ); + // Safe because tests in this module are single-threaded by + // tokio::test's default runtime; if we move to multi_thread + // we'd need a process-wide PATH lock. + // SAFETY: see comment above. + unsafe { + std::env::set_var("PATH", &new_path); + } + + let killer = Arc::new(RecordingKiller { + calls: Mutex::new(Vec::new()), + }); + let sink = Arc::new(CountingSink { + stdout_lines: AtomicU64::new(0), + stderr_lines: AtomicU64::new(0), + }); + let cancel = CancellationToken::new(); + + // The "user command" — backgrounded sleeps + wait. The shim + // wraps this so the runner sees the sentinel on stderr. + let user_cmd = "sleep 30 & sleep 30 & wait"; + let wrapped = crate::exec_shim::wrap_self_contained(user_cmd); + + // Spawn the call and cancel it shortly after. + let killer_for_spawn: Arc = killer.clone(); + let sink_for_spawn: Arc = sink.clone(); + let cancel_for_spawn = cancel.clone(); + let handle = tokio::spawn(async move { + let args = vec!["ssh", "fake-ws", "--command", wrapped.as_str()]; + run_with_shim( + &CliBinary::DevPod, + &args, + None, + &cancel_for_spawn, + Some(sink_for_spawn), + killer_for_spawn, + ) + .await + }); + + // Give the shim time to emit the sentinel. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + cancel.cancel(); + + let result = handle.await.expect("join"); + match result { + Err(Error::Cancelled) => { /* expected */ } + other => panic!("expected Error::Cancelled, got {:?}", other), + } + + // Killer should have been called once with TERM, then once + // with KILL, both targeting the same PGID (>0). The 2s gap + // is checked separately below. + let calls = killer.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 2, "expected exactly 2 kill calls, got {calls:?}"); + let (pgid1, sig1) = &calls[0]; + let (pgid2, sig2) = &calls[1]; + assert_eq!(sig1, "TERM"); + assert_eq!(sig2, "KILL"); + assert!(*pgid1 > 0, "captured PGID should be positive, got {pgid1}"); + assert_eq!(pgid1, pgid2, "both signals should target the same PGID"); + + // Sentinel must NOT have leaked into the sink's stderr count + // — if it had, sink.stderr_lines would include at least the + // sentinel line for every chunk. Concretely, the user_cmd + // produces no stderr at all, so stderr_lines should be 0. + assert_eq!( + sink.stderr_lines.load(Ordering::Relaxed), + 0, + "sentinel should be scrubbed from stderr forwarded to sink" + ); + } +} diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs index 2324f2a..4c6f92f 100644 --- a/crates/devcontainer-mcp-core/src/codespaces.rs +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -1,6 +1,11 @@ use std::collections::HashMap; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; -use crate::cli::{run_cli_with_env, CliBinary, CliOutput}; +use crate::cli::{ + run_cli_streaming, run_cli_with_env, run_with_shim, ChunkSink, CliBinary, CliOutput, + RemoteKiller, +}; use crate::error::Result; const LIST_FIELDS: &str = @@ -57,6 +62,58 @@ pub async fn create( run_gh_cs(&args, false, Some(env)).await } +/// `gh codespace create` — cancellable, streaming variant. +/// +/// Codespace creation takes 3–5 minutes (image pull, devcontainer +/// build, postCreate, …). Streams `gh codespace create` output as +/// progress notifications and honors cancellation — when the client +/// times out we kill the host `gh` process. Note: this only reaps +/// the host wrapper; if the GitHub API has already started +/// provisioning the codespace remotely, the codespace itself will +/// continue to come up and bill until the caller explicitly deletes +/// it via `codespaces_delete`. The wire-level cancel is the most we +/// can do — there's no `gh codespace cancel-create` API. +// +// `clippy::too_many_arguments` — each parameter mirrors a distinct +// `gh codespace create` flag, and the non-streaming sibling +// [`create`] has the same shape with one fewer pair. Bundling into +// a struct would obscure the call site for no real readability win. +#[allow(clippy::too_many_arguments)] +pub async fn create_streaming( + env: &HashMap, + repo: &str, + branch: Option<&str>, + machine: Option<&str>, + devcontainer_path: Option<&str>, + display_name: Option<&str>, + idle_timeout: Option<&str>, + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let mut args = vec!["codespace", "create", "--repo", repo]; + if let Some(b) = branch { + args.push("--branch"); + args.push(b); + } + if let Some(m) = machine { + args.push("--machine"); + args.push(m); + } + if let Some(d) = devcontainer_path { + args.push("--devcontainer-path"); + args.push(d); + } + if let Some(n) = display_name { + args.push("--display-name"); + args.push(n); + } + if let Some(t) = idle_timeout { + args.push("--idle-timeout"); + args.push(t); + } + run_cli_streaming(&CliBinary::Gh, &args, false, Some(env), cancel, on_chunk).await +} + /// `gh codespace list` — list codespaces. pub async fn list(env: &HashMap, repo: Option<&str>) -> Result { let mut args = vec!["list", "--json", LIST_FIELDS]; @@ -77,6 +134,64 @@ pub async fn ssh_exec( run_gh_cs(&args, false, Some(env)).await } +/// `gh codespace ssh` — cancellable, streaming variant. +/// +/// Same model as the DevPod backend: the user's command is wrapped +/// with [`crate::exec_shim::wrap_self_contained`] (the codespace +/// transport has no `--remote-env`), the captured PGID is stripped +/// from stderr, and on cancel a second `gh codespace ssh` delivers +/// `kill -TERM -` then `kill -KILL -` against the same +/// codespace. +pub async fn ssh_exec_streaming( + env: &HashMap, + codespace: &str, + command: &str, + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let wrapped = crate::exec_shim::wrap_self_contained(command); + // `gh codespace ssh -c -- ` — the `--` separates gh's + // own args from the remote command, so the wrapped shim lands as + // the single remote argument. + let full_args: Vec<&str> = vec!["codespace", "ssh", "-c", codespace, "--", &wrapped]; + + let killer: Arc = Arc::new(CodespaceKiller { + env: env.clone(), + codespace: codespace.to_string(), + }); + + run_with_shim( + &CliBinary::Gh, + &full_args, + Some(env), + cancel, + on_chunk, + killer, + ) + .await +} + +/// Delivers `kill - -` inside a Codespace by spawning a +/// fresh short-lived `gh codespace ssh`. Same trade-off as the DevPod +/// backend — every kill is its own SSH session, slow but reliable. +struct CodespaceKiller { + env: HashMap, + codespace: String, +} + +#[async_trait::async_trait] +impl RemoteKiller for CodespaceKiller { + async fn kill_pgid(&self, pgid: i32, signal: &str) { + let cmd = format!("kill -{signal} -{pgid} 2>/dev/null || true"); + let args = vec!["codespace", "ssh", "-c", &self.codespace, "--", &cmd]; + if let Err(e) = + run_cli_with_env(&CliBinary::Gh, &args, false, Some(&self.env)).await + { + tracing::debug!(%e, pgid, signal, "gh codespace ssh kill failed"); + } + } +} + /// `gh codespace stop` — stop a running codespace. pub async fn stop(env: &HashMap, codespace: &str) -> Result { let args = vec!["stop", "-c", codespace]; diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs index 167fc0d..ca037c9 100644 --- a/crates/devcontainer-mcp-core/src/devcontainer.rs +++ b/crates/devcontainer-mcp-core/src/devcontainer.rs @@ -1,12 +1,74 @@ -use crate::cli::{run_cli, CliBinary, CliOutput}; -use crate::docker; +use crate::cli::{ + run_cli, run_cli_streaming, run_with_shim, ChunkSink, CliBinary, CliOutput, RemoteKiller, +}; +use crate::devcontainer_config::{resolve_config, ResolvedConfig}; +use crate::docker::{self, DevcontainerLookup}; use crate::error::Result; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; /// Run a `devcontainer` CLI command. async fn run_devcontainer(args: &[&str], parse_json: bool) -> Result { run_cli(&CliBinary::Devcontainer, args, parse_json).await } +/// Best-effort resolve of `config` against `workspace_folder`. Returns +/// `None` if `config` is `None` *or* if parsing fails (we log + fall back +/// to no-config matching so a malformed config file never blocks lookups +/// the agent could otherwise satisfy). +fn try_resolve(workspace_folder: &str, config: Option<&str>) -> Option { + let cfg = config?; + match resolve_config(workspace_folder, cfg) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!(%e, config = %cfg, "failed to resolve devcontainer config; lookup will fall back to no-config matching"); + None + } + } +} + +/// Make `config` absolute against `workspace_folder` so the devcontainer +/// CLI resolves it correctly regardless of the MCP server's CWD. If +/// already absolute, returned unchanged. Falls back to the input when +/// canonicalize fails (e.g. file doesn't exist yet). +fn abs_config(workspace_folder: &str, config: &str) -> String { + let p = std::path::Path::new(config); + let joined = if p.is_absolute() { + p.to_path_buf() + } else { + std::path::Path::new(workspace_folder).join(p) + }; + std::fs::canonicalize(&joined) + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_else(|_| joined.to_string_lossy().into_owned()) +} + +/// Build a uniform "ambiguous match" error string for stop/remove/status +/// callers when multiple containers match `workspace_folder` and no +/// `config` was supplied. Lists each candidate's id, name, compose service, +/// and devcontainer.config_file label so the agent has everything it needs +/// to retry with the right `config`. +fn ambiguous_error(workspace_folder: &str, candidates: &[docker::ContainerInfo]) -> String { + let lines: Vec = candidates + .iter() + .map(|c| { + let svc = c.compose_service().unwrap_or("(none)"); + let cfg = c.devcontainer_config_file().unwrap_or("(unlabeled)"); + format!( + " - id={short} name={name} service={svc} config_file={cfg}", + short = c.id.chars().take(12).collect::(), + name = c.name, + ) + }) + .collect(); + format!( + "Multiple containers match workspace `{workspace_folder}`. \ + Re-run with `config` set to the devcontainer.json path of the one you want. \ + Use `devcontainer_list_configs` to enumerate. Candidates:\n{}", + lines.join("\n") + ) +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -18,7 +80,8 @@ pub async fn up( extra_args: &[&str], ) -> Result { let mut args = vec!["up", "--workspace-folder", workspace_folder]; - if let Some(c) = config { + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { args.push("--config"); args.push(c); } @@ -26,28 +89,281 @@ pub async fn up( run_devcontainer(&args, true).await } +/// `devcontainer up` — cancellable, streaming variant. +/// +/// `devcontainer up` does a docker build + container create + the +/// devcontainer lifecycle commands (postCreate, postStart, etc.) all +/// in one invocation. That can easily take several minutes on a +/// cold image. Same rationale as `crate::devpod::up_streaming`: +/// cancellation prevents leaked partial-up containers, and progress +/// streaming keeps client transports warm. +pub async fn up_streaming( + workspace_folder: &str, + config: Option<&str>, + extra_args: &[&str], + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let mut args = vec!["up", "--workspace-folder", workspace_folder]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } + args.extend_from_slice(extra_args); + run_cli_streaming( + &CliBinary::Devcontainer, + &args, + true, + None, + cancel, + on_chunk, + ) + .await +} + /// `devcontainer exec` — execute a command in a running dev container. +/// +/// Resolves the target container via our reliable label-based lookup and +/// passes its id to the CLI via `--container-id`. This works for sibling +/// compose containers that the devcontainer CLI's own workspace-folder +/// lookup can't see (because only the first container in a compose stack +/// gets stamped with `devcontainer.*` labels). pub async fn exec( workspace_folder: &str, + config: Option<&str>, command: &str, command_args: &[&str], ) -> Result { - let mut args = vec!["exec", "--workspace-folder", workspace_folder, command]; + let container = lookup_one_or_err(workspace_folder, config).await?; + let mut args = vec![ + "exec", + "--container-id", + &container.id, + "--workspace-folder", + workspace_folder, + ]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } + args.push(command); args.extend_from_slice(command_args); run_devcontainer(&args, false).await } +/// `devcontainer exec` — cancellable, streaming variant. +/// +/// `cancel` is honored at any point during the child's lifetime; if it +/// fires, every descendant inside the container is reaped via a +/// bollard `create_exec` + `start_exec` of `kill - -` +/// against the process group the shim established. `on_chunk`, if +/// supplied, receives every line of stdout/stderr as the child emits +/// it — typically wired to an MCP progress notification on the server +/// side. +/// +/// Container descendants are not in the host PID namespace lineage of +/// `devcontainer exec` (the docker daemon reparents them under +/// containerd-shim), so a `/proc` walk on the host would miss them. +/// We install a tiny `setsid` + sentinel shim around the user command +/// and use the captured PGID to reap them on cancel. +pub async fn exec_streaming( + workspace_folder: &str, + config: Option<&str>, + command: &str, + command_args: &[&str], + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + // Build the user-side command string. The MCP handler invokes us + // as `("sh", &["-c", &full_cmd])`, which is the fast path: peel + // off the `-c` arg so the shim wraps the body directly instead of + // nesting an extra shell. For any other shape, fall back to a + // best-effort shell-quoted concatenation. + let user_cmd: String = if command == "sh" && command_args.len() == 2 && command_args[0] == "-c" + { + command_args[1].to_string() + } else { + let mut parts = vec![crate::file_ops::shell_quote(command)]; + for a in command_args { + parts.push(crate::file_ops::shell_quote(a)); + } + parts.join(" ") + }; + + let wrapped = crate::exec_shim::wrap(); + // The user command is passed to the shim via an env var so it + // doesn't need to be quoted into a nested `sh -c '...'`. We use + // `--remote-env NAME=VALUE` so the devcontainer CLI propagates + // it inside the container. This is the env-var path; the + // self-contained `wrap_self_contained` variant is for backends + // (DevPod, Codespaces) that can't pass remote env vars. + let remote_env_arg = format!("{}={}", crate::exec_shim::USER_CMD_ENV, user_cmd); + + // Resolve target container up-front (see `exec` docs for rationale). + let container = lookup_one_or_err(workspace_folder, config).await?; + + let mut all_args: Vec<&str> = vec![ + "exec", + "--container-id", + &container.id, + "--workspace-folder", + workspace_folder, + ]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + all_args.push("--config"); + all_args.push(c); + } + all_args.extend_from_slice(&["--remote-env", &remote_env_arg, "sh", "-c"]); + all_args.push(&wrapped); + + let killer: Arc = Arc::new(DevcontainerKiller { + workspace_folder: workspace_folder.to_string(), + config: config.map(str::to_string), + }); + + run_with_shim( + &CliBinary::Devcontainer, + &all_args, + None, + cancel, + on_chunk, + killer, + ) + .await +} + +/// Delivers `kill - -` inside the devcontainer associated +/// with a workspace folder, using bollard's exec API. When `config` is +/// provided, the target container is disambiguated using the same +/// resolution as the rest of the lifecycle tools. +struct DevcontainerKiller { + workspace_folder: String, + config: Option, +} + +#[async_trait::async_trait] +impl RemoteKiller for DevcontainerKiller { + async fn kill_pgid(&self, pgid: i32, signal: &str) { + use bollard::exec::{CreateExecOptions, StartExecOptions}; + + let client = match docker::connect() { + Ok(c) => c, + Err(e) => { + tracing::warn!(%e, "failed to connect to docker for in-container kill"); + return; + } + }; + let resolved = try_resolve(&self.workspace_folder, self.config.as_deref()); + let container = + match docker::find_devcontainer(&client, &self.workspace_folder, resolved.as_ref()) + .await + { + Ok(DevcontainerLookup::One(c)) => c, + Ok(DevcontainerLookup::Many(candidates)) => { + // Best effort: pick the first; we may be racing the + // user's own teardown, so an imperfect kill is fine. + tracing::warn!( + workspace = %self.workspace_folder, + count = candidates.len(), + "ambiguous container lookup during in-container kill; picking first" + ); + candidates.into_iter().next().unwrap() + } + Ok(DevcontainerLookup::None) => { + tracing::warn!( + workspace = %self.workspace_folder, + "no devcontainer found for in-container kill" + ); + return; + } + Err(e) => { + tracing::warn!(%e, "container lookup failed during in-container kill"); + return; + } + }; + + // `kill - -` signals every process in the group. + // The POSIX `--` argument separator is deliberately omitted + // because BusyBox/dash `kill` (common in slim container + // images) rejects it as "Illegal number". + let cmd = format!("kill -{signal} -{pgid} 2>/dev/null || true"); + let create = CreateExecOptions { + cmd: Some(vec!["sh".to_string(), "-c".to_string(), cmd]), + attach_stdout: Some(false), + attach_stderr: Some(false), + ..Default::default() + }; + let res = match client.create_exec(&container.id, create).await { + Ok(r) => r, + Err(e) => { + tracing::debug!(%e, "create_exec for in-container kill failed"); + return; + } + }; + // `detach: true` makes start_exec return as soon as the docker + // daemon has spawned the kill; we don't need to stream its + // (empty) output. + let opts = StartExecOptions { + detach: true, + ..Default::default() + }; + if let Err(e) = client.start_exec(&res.id, Some(opts)).await { + tracing::debug!(%e, "start_exec for in-container kill failed"); + } + } +} + /// `devcontainer build` — build a dev container image. -pub async fn build(workspace_folder: &str, extra_args: &[&str]) -> Result { +pub async fn build( + workspace_folder: &str, + config: Option<&str>, + extra_args: &[&str], +) -> Result { let mut args = vec!["build", "--workspace-folder", workspace_folder]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } args.extend_from_slice(extra_args); run_devcontainer(&args, true).await } +/// `devcontainer build` — cancellable, streaming variant. See +/// [`up_streaming`]. +pub async fn build_streaming( + workspace_folder: &str, + config: Option<&str>, + extra_args: &[&str], + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let mut args = vec!["build", "--workspace-folder", workspace_folder]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } + args.extend_from_slice(extra_args); + run_cli_streaming( + &CliBinary::Devcontainer, + &args, + true, + None, + cancel, + on_chunk, + ) + .await +} + /// `devcontainer read-configuration` — read devcontainer config as JSON. pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) -> Result { let mut args = vec!["read-configuration", "--workspace-folder", workspace_folder]; - if let Some(c) = config { + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { args.push("--config"); args.push(c); } @@ -59,40 +375,65 @@ pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) -> // Lifecycle via bollard (devcontainer CLI has no stop/down) // --------------------------------------------------------------------------- +/// Look up the single container for `workspace_folder` + `config`, +/// returning a structured error for `None` / `Many`. Used by stop/remove, +/// which refuse to act on ambiguous matches. +async fn lookup_one_or_err( + workspace_folder: &str, + config: Option<&str>, +) -> Result { + let client = docker::connect()?; + let resolved = try_resolve(workspace_folder, config); + match docker::find_devcontainer(&client, workspace_folder, resolved.as_ref()).await? { + DevcontainerLookup::One(c) => Ok(c), + DevcontainerLookup::None => Err(crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("No devcontainer found for workspace: {workspace_folder}"), + ))), + DevcontainerLookup::Many(candidates) => Err(crate::error::Error::Io( + std::io::Error::other(ambiguous_error(workspace_folder, &candidates)), + )), + } +} + /// Stop a dev container found by its workspace folder label. -pub async fn stop(workspace_folder: &str) -> Result { +pub async fn stop(workspace_folder: &str, config: Option<&str>) -> Result { let client = docker::connect()?; - let container = docker::find_container_by_local_folder(&client, workspace_folder) - .await? - .ok_or_else(|| { - crate::error::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("No devcontainer found for workspace: {workspace_folder}"), - )) - })?; + let container = lookup_one_or_err(workspace_folder, config).await?; docker::stop_container(&client, &container.id).await?; Ok(format!("Stopped container {}", container.name)) } /// Remove a dev container found by its workspace folder label. -pub async fn remove(workspace_folder: &str, force: bool) -> Result { +pub async fn remove(workspace_folder: &str, config: Option<&str>, force: bool) -> Result { let client = docker::connect()?; - let container = docker::find_container_by_local_folder(&client, workspace_folder) - .await? - .ok_or_else(|| { - crate::error::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("No devcontainer found for workspace: {workspace_folder}"), - )) - })?; + let container = lookup_one_or_err(workspace_folder, config).await?; docker::remove_container(&client, &container.id, force).await?; Ok(format!("Removed container {}", container.name)) } -/// Get status of a dev container by workspace folder label. -pub async fn status(workspace_folder: &str) -> Result> { +/// Status outcome for a devcontainer lookup. `Ambiguous` is the +/// multi-container case where no `config` was supplied to disambiguate; +/// callers should surface the candidates and point the agent at +/// `devcontainer_list_configs`. +#[derive(Debug, Clone)] +pub enum StatusOutcome { + NotFound, + Found(docker::ContainerInfo), + Ambiguous(Vec), +} + +/// Get status of a dev container by workspace folder + optional config. +pub async fn status(workspace_folder: &str, config: Option<&str>) -> Result { let client = docker::connect()?; - docker::find_container_by_local_folder(&client, workspace_folder).await + let resolved = try_resolve(workspace_folder, config); + Ok( + match docker::find_devcontainer(&client, workspace_folder, resolved.as_ref()).await? { + DevcontainerLookup::None => StatusOutcome::NotFound, + DevcontainerLookup::One(c) => StatusOutcome::Found(c), + DevcontainerLookup::Many(v) => StatusOutcome::Ambiguous(v), + }, + ) } // --------------------------------------------------------------------------- @@ -100,25 +441,35 @@ pub async fn status(workspace_folder: &str) -> Result Result { +pub async fn file_read( + workspace_folder: &str, + config: Option<&str>, + path: &str, +) -> Result { let cmd = crate::file_ops::read_file_command(path); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "sh", &["-c", &cmd]).await } /// Write (create or overwrite) a file in a dev container. -pub async fn file_write(workspace_folder: &str, path: &str, content: &str) -> Result { +pub async fn file_write( + workspace_folder: &str, + config: Option<&str>, + path: &str, + content: &str, +) -> Result { let cmd = crate::file_ops::write_file_command(path, content); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "sh", &["-c", &cmd]).await } /// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. pub async fn file_edit( workspace_folder: &str, + config: Option<&str>, path: &str, old_str: &str, new_str: &str, ) -> Result { - let read_output = file_read(workspace_folder, path).await?; + let read_output = file_read(workspace_folder, config, path).await?; if read_output.exit_code != 0 { return Err(crate::error::Error::FileRead(format!( "Failed to read {path}: {}", @@ -128,7 +479,7 @@ pub async fn file_edit( let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; - let write_output = file_write(workspace_folder, path, &modified).await?; + let write_output = file_write(workspace_folder, config, path, &modified).await?; if write_output.exit_code != 0 { return Err(crate::error::Error::FileEdit(format!( "Failed to write {path}: {}", @@ -140,7 +491,11 @@ pub async fn file_edit( } /// List directory contents in a dev container. -pub async fn file_list(workspace_folder: &str, path: &str) -> Result { +pub async fn file_list( + workspace_folder: &str, + config: Option<&str>, + path: &str, +) -> Result { let cmd = crate::file_ops::list_dir_command(path); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "sh", &["-c", &cmd]).await } diff --git a/crates/devcontainer-mcp-core/src/devcontainer_config.rs b/crates/devcontainer-mcp-core/src/devcontainer_config.rs new file mode 100644 index 0000000..e7b8a7d --- /dev/null +++ b/crates/devcontainer-mcp-core/src/devcontainer_config.rs @@ -0,0 +1,445 @@ +//! Discovery and parsing of `devcontainer.json` files within a workspace. +//! +//! Implements the VS Code multi-container workspace pattern: a single repo +//! can contain several `.devcontainer//devcontainer.json` files, +//! typically referencing different services of a shared `docker-compose.yml`. +//! +//! Used by the `devcontainer_list_configs` MCP tool and as the basis for +//! disambiguating Docker container lookups when an operation targets a +//! specific config in a multi-container workspace. + +use crate::error::Result; +use jsonc_parser::ParseOptions; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// One field of a `dockerComposeFile` — either a single path or an array. +fn json_to_string_list(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + } +} + +/// Classification of a parsed devcontainer.json based on which top-level +/// fields are present. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigKind { + /// `dockerComposeFile` + `service` — multi-container compose project. + Compose, + /// `build.dockerfile` or top-level `dockerFile` — image is built locally. + Dockerfile, + /// `image` — pre-built image is used directly. + Image, + /// None of the above could be determined. + Unknown, +} + +/// One entry returned by [`list_configs`]: a discovered devcontainer.json +/// with a best-effort summary of its top-level fields. `path` is always +/// **relative** to the workspace folder so it can be fed straight back +/// into another tool's `config` parameter. +#[derive(Debug, Clone, Serialize)] +pub struct DiscoveredConfig { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub docker_compose_file: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_folder: Option, + pub kind: ConfigKind, + /// When parsing fails, the entry still appears with the error message + /// here and all other fields empty — so an agent can see *that* the + /// config exists even if it can't be parsed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Resolved view of a devcontainer.json used by the Docker container lookup +/// to disambiguate multi-container workspaces. Paths are absolute and +/// canonicalized so substring comparisons against container labels are +/// reliable across `/var/folders/...` ↔ `/private/var/folders/...` aliases +/// (macOS) and similar. +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub abs_path: PathBuf, + pub service: Option, + /// Absolute, canonicalized paths of every `dockerComposeFile` entry. + pub compose_files_abs: Vec, + pub kind: ConfigKind, +} + +/// Parse `text` as JSONC (JSON with comments + trailing commas, per the +/// devcontainer spec) into a `serde_json::Value`. +fn parse_jsonc(text: &str) -> std::result::Result { + let parsed = jsonc_parser::parse_to_serde_value(text, &ParseOptions::default()) + .map_err(|e| e.to_string())? + .ok_or_else(|| "empty document".to_string())?; + Ok(parsed) +} + +/// Determine the search paths for devcontainer configs under `workspace`. +/// Returns absolute paths in priority order: root `.devcontainer.json`, +/// `.devcontainer/devcontainer.json`, then every +/// `.devcontainer/*/devcontainer.json`. +fn candidate_paths(workspace: &Path) -> Vec { + let mut out = Vec::new(); + let root_dotfile = workspace.join(".devcontainer.json"); + if root_dotfile.is_file() { + out.push(root_dotfile); + } + let dc_dir = workspace.join(".devcontainer"); + let root_in_dir = dc_dir.join("devcontainer.json"); + if root_in_dir.is_file() { + out.push(root_in_dir); + } + if let Ok(entries) = std::fs::read_dir(&dc_dir) { + // Sort sub-folder configs deterministically so the agent sees a + // stable order across calls. + let mut subdirs: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + subdirs.sort(); + for sub in subdirs { + let candidate = sub.join("devcontainer.json"); + if candidate.is_file() { + out.push(candidate); + } + } + } + out +} + +/// Convert `abs` to a path relative to `base`, falling back to the absolute +/// path's string form if it isn't actually inside `base`. +fn rel_to(abs: &Path, base: &Path) -> String { + abs.strip_prefix(base) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| abs.to_string_lossy().into_owned()) +} + +/// Best-effort canonicalization that falls back to the input on error. +/// Used because compose files referenced from a not-yet-built devcontainer +/// might not exist at scan time, but we still want a stable absolute path. +fn canonicalize_or_keep(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +fn classify(obj: &serde_json::Map) -> ConfigKind { + if obj.get("dockerComposeFile").is_some() { + return ConfigKind::Compose; + } + let has_dockerfile = obj.get("dockerFile").is_some() + || obj + .get("build") + .and_then(|b| b.as_object()) + .is_some_and(|b| b.contains_key("dockerfile") || b.contains_key("dockerFile")); + if has_dockerfile { + return ConfigKind::Dockerfile; + } + if obj.get("image").is_some() { + return ConfigKind::Image; + } + ConfigKind::Unknown +} + +/// Build a [`DiscoveredConfig`] from a parsed JSON value. Top-level keys are +/// best-effort: missing or wrong-typed fields are skipped, not errors. +fn build_discovered(rel_path: String, value: &serde_json::Value) -> DiscoveredConfig { + let Some(obj) = value.as_object() else { + return DiscoveredConfig { + path: rel_path, + error: Some("top-level value is not an object".into()), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }; + }; + DiscoveredConfig { + path: rel_path, + name: obj.get("name").and_then(|v| v.as_str()).map(str::to_string), + image: obj + .get("image") + .and_then(|v| v.as_str()) + .map(str::to_string), + service: obj + .get("service") + .and_then(|v| v.as_str()) + .map(str::to_string), + docker_compose_file: obj + .get("dockerComposeFile") + .map(json_to_string_list) + .unwrap_or_default(), + workspace_folder: obj + .get("workspaceFolder") + .and_then(|v| v.as_str()) + .map(str::to_string), + kind: classify(obj), + error: None, + } +} + +/// Discover every devcontainer.json under `workspace_folder` and return a +/// best-effort summary of each. Parse errors are reported per-entry; the +/// call itself only fails on IO problems with `workspace_folder`. +pub fn list_configs(workspace_folder: &str) -> Result> { + let workspace = PathBuf::from(workspace_folder); + let workspace_abs = canonicalize_or_keep(&workspace); + let mut out = Vec::new(); + for abs_path in candidate_paths(&workspace_abs) { + let rel = rel_to(&abs_path, &workspace_abs); + let entry = match std::fs::read_to_string(&abs_path) { + Ok(text) => match parse_jsonc(&text) { + Ok(v) => build_discovered(rel, &v), + Err(e) => DiscoveredConfig { + path: rel, + error: Some(format!("JSONC parse error: {e}")), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }, + }, + Err(e) => DiscoveredConfig { + path: rel, + error: Some(format!("read error: {e}")), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }, + }; + out.push(entry); + } + Ok(out) +} + +/// Resolve `config` (a path to a devcontainer.json, either absolute or +/// relative to `workspace_folder`) into a [`ResolvedConfig`] used by the +/// Docker container lookup. +/// +/// `dockerComposeFile` entries are resolved against the *config file's* +/// directory (matching the devcontainer CLI's behavior) and canonicalized. +pub fn resolve_config(workspace_folder: &str, config: &str) -> Result { + let workspace = canonicalize_or_keep(&PathBuf::from(workspace_folder)); + let config_path = { + let p = PathBuf::from(config); + if p.is_absolute() { + p + } else { + workspace.join(p) + } + }; + let abs_path = canonicalize_or_keep(&config_path); + let text = std::fs::read_to_string(&abs_path)?; + let value = parse_jsonc(&text).map_err(|e| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("JSONC parse error in {}: {e}", abs_path.display()), + )) + })?; + let obj = value.as_object().ok_or_else(|| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("top-level value is not an object in {}", abs_path.display()), + )) + })?; + + let kind = classify(obj); + let service = obj + .get("service") + .and_then(|v| v.as_str()) + .map(str::to_string); + + // `dockerComposeFile` paths are resolved against the directory + // containing the devcontainer.json, per the devcontainer spec. + let config_dir = abs_path.parent().unwrap_or(&workspace).to_path_buf(); + let compose_files_abs: Vec = obj + .get("dockerComposeFile") + .map(json_to_string_list) + .unwrap_or_default() + .into_iter() + .map(|entry| { + let p = PathBuf::from(&entry); + let abs = if p.is_absolute() { + p + } else { + config_dir.join(p) + }; + canonicalize_or_keep(&abs) + }) + .collect(); + + Ok(ResolvedConfig { + abs_path, + service, + compose_files_abs, + kind, + }) +} + +/// Helper for callers that want a `BTreeMap` of common +/// label filters derived from a [`ResolvedConfig`]. Not used directly by +/// the docker layer (which builds its own filters), but handy for tests +/// and tracing. +pub fn expected_labels(resolved: &ResolvedConfig) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(svc) = &resolved.service { + out.insert("com.docker.compose.service".into(), svc.clone()); + } + out.insert( + "devcontainer.config_file".into(), + resolved.abs_path.to_string_lossy().into_owned(), + ); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write(p: &Path, contents: &str) { + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(p, contents).unwrap(); + } + + #[test] + fn discovers_root_devcontainer_in_subdir() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/devcontainer.json"), + r#"{ "name": "Root", "image": "alpine:3.20" }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, ".devcontainer/devcontainer.json"); + assert_eq!(out[0].name.as_deref(), Some("Root")); + assert_eq!(out[0].kind, ConfigKind::Image); + } + + #[test] + fn discovers_root_dotfile() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer.json"), + r#"{ "image": "alpine:3.20" }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, ".devcontainer.json"); + } + + #[test] + fn discovers_multi_subfolder_configs_sorted() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/python/devcontainer.json"), + r#"// python config + { + "name": "Python", + "dockerComposeFile": ["../../docker-compose.yml"], + "service": "python-api", + "workspaceFolder": "/workspace/py", + }"#, + ); + write( + &dir.path().join(".devcontainer/node/devcontainer.json"), + r#"{ + "name": "Node", + "dockerComposeFile": "../../docker-compose.yml", + "service": "node-app" + }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 2); + // Sorted by directory name: node, python + assert_eq!(out[0].service.as_deref(), Some("node-app")); + assert_eq!(out[1].service.as_deref(), Some("python-api")); + assert_eq!(out[0].kind, ConfigKind::Compose); + assert_eq!(out[1].kind, ConfigKind::Compose); + // Single-string dockerComposeFile is normalized to a one-element list. + assert_eq!(out[0].docker_compose_file.len(), 1); + // Trailing comma + comments parsed by JSONC. + assert_eq!(out[1].workspace_folder.as_deref(), Some("/workspace/py")); + } + + #[test] + fn malformed_config_reports_per_entry_error() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/broken/devcontainer.json"), + r#"{ "name": "broken" "#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert!(out[0].error.is_some()); + } + + #[test] + fn classifies_dockerfile_build() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/devcontainer.json"), + r#"{ "build": { "dockerfile": "Dockerfile" } }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out[0].kind, ConfigKind::Dockerfile); + } + + #[test] + fn resolve_config_canonicalizes_compose_files() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("docker-compose.yml"), + "services:\n a:\n image: alpine\n", + ); + write( + &dir.path().join(".devcontainer/a/devcontainer.json"), + r#"{ + "dockerComposeFile": ["../../docker-compose.yml"], + "service": "a" + }"#, + ); + let resolved = resolve_config( + dir.path().to_str().unwrap(), + ".devcontainer/a/devcontainer.json", + ) + .unwrap(); + assert_eq!(resolved.service.as_deref(), Some("a")); + assert_eq!(resolved.kind, ConfigKind::Compose); + assert_eq!(resolved.compose_files_abs.len(), 1); + let compose = &resolved.compose_files_abs[0]; + assert!(compose.is_absolute()); + assert!(compose.ends_with("docker-compose.yml")); + } + + #[test] + fn list_returns_empty_when_no_configs() { + let dir = tempdir().unwrap(); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert!(out.is_empty()); + } +} diff --git a/crates/devcontainer-mcp-core/src/devpod.rs b/crates/devcontainer-mcp-core/src/devpod.rs index 20667d2..99b5958 100644 --- a/crates/devcontainer-mcp-core/src/devpod.rs +++ b/crates/devcontainer-mcp-core/src/devpod.rs @@ -1,6 +1,10 @@ use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; -use crate::cli::{run_cli, CliBinary, CliOutput}; +use crate::cli::{ + run_cli, run_cli_streaming, run_with_shim, ChunkSink, CliBinary, CliOutput, RemoteKiller, +}; use crate::error::{Error, Result}; /// Run a devpod CLI command with the given args. @@ -29,6 +33,45 @@ pub async fn up(args: &[&str]) -> Result { run_devpod(&cmd_args, false).await } +/// `devpod up` — cancellable, streaming variant. +/// +/// Provisioning a cloud workspace (especially the GCP/AWS/Azure +/// providers) can take 2–10 minutes. Wrapping `devpod up` through +/// [`run_cli_streaming`] gives us two things: +/// +/// 1. **Cancellation**: when the client times out and sends +/// `notifications/cancelled`, our handler's [`CancellationToken`] +/// fires and we reap the host process tree (the `devpod` CLI and +/// its descendants). Devpod's own cleanup logic then unwinds any +/// partial cloud resources (it has rollback semantics for failed +/// `up`). +/// +/// 2. **Progress notifications**: every line of `devpod up` output +/// ("Creating devcontainer...", "npm install...", …) is forwarded +/// as an MCP progress notification, keeping the wire warm so +/// idle-based client timeouts don't trip. +/// +/// Unlike `ssh_exec_streaming` this path uses [`run_cli_streaming`] +/// directly — no shim is needed because `devpod up` runs entirely on +/// the host (it makes GCP API calls; no remote process to reap). +pub async fn up_streaming( + args: &[&str], + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let mut cmd_args = vec!["up", "--open-ide=false"]; + cmd_args.extend_from_slice(args); + run_cli_streaming( + &CliBinary::DevPod, + &cmd_args, + false, + None, + cancel, + on_chunk, + ) + .await +} + /// `devpod stop` — stop a workspace. pub async fn stop(workspace: &str) -> Result { run_devpod(&["stop", workspace], false).await @@ -50,6 +93,25 @@ pub async fn build(args: &[&str]) -> Result { run_devpod(&cmd_args, false).await } +/// `devpod build` — cancellable, streaming variant. See [`up_streaming`]. +pub async fn build_streaming( + args: &[&str], + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let mut cmd_args = vec!["build"]; + cmd_args.extend_from_slice(args); + run_cli_streaming( + &CliBinary::DevPod, + &cmd_args, + false, + None, + cancel, + on_chunk, + ) + .await +} + // --------------------------------------------------------------------------- // Workspace queries // --------------------------------------------------------------------------- @@ -108,6 +170,88 @@ pub async fn ssh_exec( run_devpod(&args, false).await } +/// `devpod ssh --command` — cancellable, streaming variant. +/// +/// Same shim treatment as [`crate::devcontainer::exec_streaming`]: the +/// user's command is wrapped with [`crate::exec_shim::wrap_self_contained`] +/// so it survives the SSH transport without quoting hazards, the +/// captured remote PGID is stripped from stderr, and on cancel a +/// second `devpod ssh --command` delivers `kill -TERM -` then +/// `kill -KILL -` to the same workspace. +/// +/// DevPod (unlike the devcontainer CLI) has no `--remote-env` flag, +/// hence the self-contained shim variant which embeds the user +/// command as a base64 blob inside the shell snippet. +pub async fn ssh_exec_streaming( + workspace: &str, + command: &str, + user: Option<&str>, + workdir: Option<&str>, + cancel: &CancellationToken, + on_chunk: Option>, +) -> Result { + let wrapped = crate::exec_shim::wrap_self_contained(command); + let mut args = vec!["ssh", workspace, "--command", &wrapped]; + if let Some(u) = user { + args.push("--user"); + args.push(u); + } + if let Some(w) = workdir { + args.push("--workdir"); + args.push(w); + } + + let killer: Arc = Arc::new(DevpodKiller { + workspace: workspace.to_string(), + user: user.map(str::to_string), + }); + + run_with_shim( + &CliBinary::DevPod, + &args, + None, + cancel, + on_chunk, + killer, + ) + .await +} + +/// Delivers `kill - -` inside a DevPod workspace by +/// spawning a fresh short-lived `devpod ssh --command "kill ..."`. +/// +/// We can't reuse the original SSH session (it's busy running the +/// workload that we're trying to interrupt), so each kill is its own +/// `devpod ssh` round trip. This is slower than the docker exec path +/// — SSH auth handshake plus DevPod's own session setup — but cancel +/// is a rare path and we'd rather pay 1–2 seconds of latency than +/// leak the workload. +struct DevpodKiller { + workspace: String, + user: Option, +} + +#[async_trait::async_trait] +impl RemoteKiller for DevpodKiller { + async fn kill_pgid(&self, pgid: i32, signal: &str) { + // `kill - -` with the same BusyBox-friendly form + // we use for the devcontainer backend (no `--`). + let cmd = format!("kill -{signal} -{pgid} 2>/dev/null || true"); + let mut args = vec!["ssh", &self.workspace, "--command", &cmd]; + if let Some(u) = self.user.as_deref() { + args.push("--user"); + args.push(u); + } + // Use the plain (non-streaming, non-cancellable) runner so we + // don't recursively pull in the whole shim machinery for a + // 50-byte one-shot. We swallow errors: a kill that fails + // because the target has already exited is fine. + if let Err(e) = run_cli(&CliBinary::DevPod, &args, false).await { + tracing::debug!(%e, pgid, signal, "devpod ssh kill failed"); + } + } +} + // --------------------------------------------------------------------------- // Logs // --------------------------------------------------------------------------- diff --git a/crates/devcontainer-mcp-core/src/docker.rs b/crates/devcontainer-mcp-core/src/docker.rs index 48fc878..34ca335 100644 --- a/crates/devcontainer-mcp-core/src/docker.rs +++ b/crates/devcontainer-mcp-core/src/docker.rs @@ -6,6 +6,7 @@ use futures_util::StreamExt; use serde::Serialize; use std::collections::HashMap; +use crate::devcontainer_config::{ConfigKind, ResolvedConfig}; use crate::error::Result; /// Summary of a container's state. @@ -18,45 +19,178 @@ pub struct ContainerInfo { pub labels: HashMap, } +impl ContainerInfo { + /// `com.docker.compose.service` label, if present — used for ambiguity + /// diagnostics in multi-container workspaces. + pub fn compose_service(&self) -> Option<&str> { + self.labels + .get("com.docker.compose.service") + .map(String::as_str) + } + + /// `devcontainer.config_file` label, if present. + pub fn devcontainer_config_file(&self) -> Option<&str> { + self.labels + .get("devcontainer.config_file") + .map(String::as_str) + } +} + +/// Outcome of [`find_devcontainer`]. `Many` is surfaced to callers so they +/// can either pick deterministically (e.g. status returning the first + +/// flagging `multipleMatches`) or refuse and ask for a `config` (e.g. stop +/// / remove). +#[derive(Debug, Clone)] +pub enum DevcontainerLookup { + None, + One(ContainerInfo), + Many(Vec), +} + +impl DevcontainerLookup { + pub fn into_one(self) -> Option { + match self { + DevcontainerLookup::One(c) => Some(c), + _ => None, + } + } + + /// Convenience: collapse `One`/`Many` to "any of them" for diagnostics. + pub fn candidates(&self) -> &[ContainerInfo] { + match self { + DevcontainerLookup::None => &[], + DevcontainerLookup::One(c) => std::slice::from_ref(c), + DevcontainerLookup::Many(v) => v.as_slice(), + } + } +} + /// Create a Docker client connected to the local socket. pub fn connect() -> Result { Ok(Docker::connect_with_local_defaults()?) } -/// Find a container by the standard `devcontainer.local_folder` label. -pub async fn find_container_by_local_folder( - docker: &Docker, - local_folder: &str, -) -> Result> { +/// Internal helper: list containers matching one label filter. +async fn list_by_label(docker: &Docker, label_eq: &str) -> Result> { let mut filters = HashMap::new(); - filters.insert( - "label".to_string(), - vec![format!("devcontainer.local_folder={local_folder}")], - ); - + filters.insert("label".to_string(), vec![label_eq.to_string()]); let options = ListContainersOptions { all: true, filters, ..Default::default() }; - let containers = docker.list_containers(Some(options)).await?; + Ok(containers.into_iter().map(container_to_info).collect()) +} - Ok(containers.into_iter().next().map(|c| { - let labels = c.labels.unwrap_or_default(); - ContainerInfo { - id: c.id.unwrap_or_default(), - name: c - .names - .and_then(|n| n.first().cloned()) - .unwrap_or_default() - .trim_start_matches('/') - .to_string(), - image: c.image.unwrap_or_default(), - state: c.state.unwrap_or_default(), - labels, +fn container_to_info(c: bollard::models::ContainerSummary) -> ContainerInfo { + let labels = c.labels.unwrap_or_default(); + ContainerInfo { + id: c.id.unwrap_or_default(), + name: c + .names + .and_then(|n| n.first().cloned()) + .unwrap_or_default() + .trim_start_matches('/') + .to_string(), + image: c.image.unwrap_or_default(), + state: c.state.unwrap_or_default(), + labels, + } +} + +/// Find devcontainer-managed container(s) for `workspace_folder`, optionally +/// scoped to a specific resolved config. +/// +/// Strategy: +/// +/// 1. **Compose config provided** — filter by `com.docker.compose.service` +/// and verify the container's `com.docker.compose.project.config_files` +/// label contains one of the resolved compose-file paths. This is the +/// only reliable path for sibling containers in a multi-service +/// workspace, because the devcontainer CLI only stamps `devcontainer.*` +/// labels on the first container of the compose project. +/// 2. **Image / Dockerfile config provided** — filter by +/// `devcontainer.config_file=`. The CLI labels these +/// consistently. +/// 3. **No config** — start with `devcontainer.local_folder=`. +/// If that misses, fall back to +/// `com.docker.compose.project.working_dir=` to catch the +/// multi-container case where sibling containers lack devcontainer.* +/// labels. +pub async fn find_devcontainer( + docker: &Docker, + workspace_folder: &str, + config: Option<&ResolvedConfig>, +) -> Result { + // Canonicalize the workspace path the same way the CLI does so + // label substring/equality checks survive macOS `/private/var/...` + // aliasing and trailing-slash variations. + let workspace_abs = std::fs::canonicalize(workspace_folder) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| workspace_folder.to_string()); + + let candidates: Vec = match config { + Some(cfg) if cfg.kind == ConfigKind::Compose => { + if let Some(service) = cfg.service.as_deref() { + let service_filter = format!("com.docker.compose.service={service}"); + let by_service = list_by_label(docker, &service_filter).await?; + let expected: Vec = cfg + .compose_files_abs + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + by_service + .into_iter() + .filter(|c| { + let Some(cfg_files) = + c.labels.get("com.docker.compose.project.config_files") + else { + return false; + }; + expected.iter().any(|exp| cfg_files.contains(exp.as_str())) + }) + .collect() + } else { + // Compose config without a service field — fall back to + // the no-config matching logic inline (avoids async + // recursion). + lookup_without_config(docker, &workspace_abs).await? + } } - })) + Some(cfg) => { + let cfg_str = cfg.abs_path.to_string_lossy(); + let filter = format!("devcontainer.config_file={cfg_str}"); + list_by_label(docker, &filter).await? + } + None => lookup_without_config(docker, &workspace_abs).await?, + }; + + Ok(match candidates.len() { + 0 => DevcontainerLookup::None, + 1 => DevcontainerLookup::One(candidates.into_iter().next().unwrap()), + _ => DevcontainerLookup::Many(candidates), + }) +} + +/// No-config matching: union of `devcontainer.local_folder` and +/// `com.docker.compose.project.working_dir` matches (deduped by container +/// id). Single-container workspaces yield exactly the primary container; +/// multi-container compose workspaces yield every sibling, even those +/// that don't carry `devcontainer.*` labels (the CLI only stamps them on +/// the first container of the compose project). +async fn lookup_without_config(docker: &Docker, workspace_abs: &str) -> Result> { + let primary = format!("devcontainer.local_folder={workspace_abs}"); + let mut hits = list_by_label(docker, &primary).await?; + let fallback = format!("com.docker.compose.project.working_dir={workspace_abs}"); + let fallback_hits = list_by_label(docker, &fallback).await?; + let seen: std::collections::HashSet = hits.iter().map(|c| c.id.clone()).collect(); + for c in fallback_hits { + if !seen.contains(&c.id) { + hits.push(c); + } + } + Ok(hits) } /// Inspect a container by name or ID. diff --git a/crates/devcontainer-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs index ec3105c..2392eab 100644 --- a/crates/devcontainer-mcp-core/src/error.rs +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -36,6 +36,9 @@ pub enum Error { #[error("File edit error: {0}")] FileEdit(String), + #[error("Operation cancelled by peer")] + Cancelled, + #[error("IO error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/devcontainer-mcp-core/src/exec_shim.rs b/crates/devcontainer-mcp-core/src/exec_shim.rs new file mode 100644 index 0000000..405e4fb --- /dev/null +++ b/crates/devcontainer-mcp-core/src/exec_shim.rs @@ -0,0 +1,277 @@ +//! In-target process-group shim for cancellable cross-boundary execs. +//! +//! When we run a command across a process boundary (host → container via +//! `docker exec`, host → VM via SSH, …) we lose direct ancestry control: +//! the target-side processes do not appear as descendants of any host PID +//! we own. SIGTERM'ing our host-side wrapper closes the stdio pipes but +//! leaves the actual workload running, reparented to the target's init. +//! +//! To regain control we wrap the user's command with a tiny shim that: +//! 1. Runs the user command in a fresh session (`setsid`), so the +//! leaf shell's PID is also its process group ID (PGID), and the +//! PGID transitively addresses every descendant. +//! 2. Emits a recognizable sentinel on stderr containing that PGID, +//! so the host-side reader can capture it. +//! +//! On cancellation, the host sends `kill -- -` *inside the target* +//! (via the backend's exec channel — `docker exec`, `devpod ssh`, etc.), +//! which reaps the entire process group atomically. + +/// Marker that brackets the PGID line so we can find and strip it from +/// stderr without false positives from user output. +const SENTINEL_PREFIX: &str = "__DCMCP_PGID="; +const SENTINEL_SUFFIX: &str = "__"; + +/// Wrap a user-supplied shell command with the shim prelude. +/// +/// The returned string is intended to be passed as a single argument to +/// `sh -c` (or any POSIX shell) on the target. It expects to find the +/// user's command in the environment variable [`USER_CMD_ENV`] (see +/// [`USER_CMD_ENV`] for the name) — passing it that way avoids the +/// quoting hazards of nesting `sh -c '...'` inside another `sh -c '...'` +/// when the user command itself contains single quotes, parentheses, or +/// other shell metacharacters. +/// +/// The shim runs the user command via `eval`, in a fresh session +/// (`setsid`) so the leaf shell's PID is also its process group ID +/// (PGID), and prints `__DCMCP_PGID=__` to stderr once for the +/// host-side reader to capture. +pub fn wrap() -> String { + // We deliberately keep this string free of any user-supplied + // content. Every variable expansion comes from the target shell's + // environment, not from string interpolation on the host. + // + // We background the `setsid` child and `wait` on it instead of + // using `setsid -w` because the `-w` flag is util-linux–only: + // busybox/alpine images ship a `setsid` without it. Backgrounding + // + `wait` is POSIX and works on every shell we care about. + // + // The trailing `exit "$?"` propagates the inner program's exit + // code back up to the outer `sh -c` so `devcontainer exec` + // returns the correct status. + format!( + r#"if command -v setsid >/dev/null 2>&1; then + setsid sh -c 'printf "{prefix}%s{suffix}\n" "$$" 1>&2; eval "${env}"' & + __dcmcp_pid=$! + wait "$__dcmcp_pid" + exit "$?" +else + printf "{prefix}%s{suffix}\n" "$$" 1>&2 + eval "${env}" +fi"#, + prefix = SENTINEL_PREFIX, + suffix = SENTINEL_SUFFIX, + env = USER_CMD_ENV, + ) +} + +/// Environment variable through which the wrapped shim receives the +/// user command. Callers must set this in the spawned process's env +/// before invoking the shim string. +pub const USER_CMD_ENV: &str = "DCMCP_USER_CMD"; + +/// Self-contained variant of [`wrap`] that embeds the user command +/// directly in the returned shell string as a base64 blob. +/// +/// Use this when the backend transport can carry a shell command but +/// cannot propagate environment variables — e.g. `devpod ssh +/// --command ` and `gh codespace ssh -- `, neither of which +/// has a `--remote-env`-style flag. +/// +/// The returned string is shell-safe regardless of what's in +/// `user_cmd`: the user command is encoded once on the host and +/// decoded once on the target, with no intermediate shell layers +/// re-interpreting its quoting. +pub fn wrap_self_contained(user_cmd: &str) -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + + let encoded = STANDARD.encode(user_cmd.as_bytes()); + // The shell decodes the blob into `DCMCP_USER_CMD` and then runs + // the standard shim, which already knows how to eval that env + // var. This keeps the two shim flavors structurally identical and + // means a single sentinel parser handles both. + // + // We require `base64` on the target. It is present in every + // container image we've encountered (coreutils, busybox, alpine) + // because the file-write path already depends on it. + let shim = wrap(); + format!( + "{env}=$(printf '%s' '{encoded}' | base64 -d) && export {env} && {shim}", + env = USER_CMD_ENV, + encoded = encoded, + shim = shim, + ) +} + +/// If `line` contains the shim's sentinel, return the captured PGID. +/// Otherwise return `None`. The caller should suppress matching lines +/// from any downstream output. +/// +/// We accept the sentinel anywhere within the line, not just as a +/// standalone match, because intermediate transports decorate remote +/// stderr with prefixes that vary by backend: +/// - `gh codespace ssh`: passes lines through unmodified. +/// - `devpod ssh`: prefixes each line with ANSI color codes, a +/// timestamp, and an `info`/`error` log-level tag. +/// - Future backends may layer on their own prefixes. +/// +/// The sentinel itself (`__DCMCP_PGID=NNN__`) is intentionally +/// distinctive — both delimiters are `__DCMCP_*__` — so the chance +/// of a false positive from user output containing that exact byte +/// sequence is vanishingly small. Bounding the digit count to 31 +/// bits keeps us safe against malformed input that would otherwise +/// trip `parse`'s overflow check. +pub fn try_parse_sentinel(line: &str) -> Option { + // Locate the prefix; bail if absent. + let after_prefix = line.find(SENTINEL_PREFIX) + .map(|i| &line[i + SENTINEL_PREFIX.len()..])?; + // The PGID runs from here to the next `__` (the suffix). Look + // forward for the suffix; if not found, this isn't our sentinel + // (could be a substring collision in user output). + let end = after_prefix.find(SENTINEL_SUFFIX)?; + let num = &after_prefix[..end]; + if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + num.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_emits_sentinel_and_runs_user_cmd() { + // Smoke test: pass user cmd via env, confirm stdout has the + // user output and stderr has the sentinel. + let wrapped = wrap(); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&wrapped) + .env(USER_CMD_ENV, "echo hello") + .output() + .expect("spawn sh"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stdout.contains("hello"), "stdout: {stdout:?}"); + assert!( + stderr.lines().any(|l| try_parse_sentinel(l).is_some()), + "no sentinel in stderr: {stderr:?}" + ); + } + + #[test] + fn wrap_handles_metacharacters_in_user_cmd() { + // The whole reason wrap() reads from env: user commands with + // single quotes, parens, subshells, etc. must not break the + // shim's own quoting. + let wrapped = wrap(); + let evil = r#"(echo "it's a (test)" & echo $((1+2)) ) | tr a-z A-Z"#; + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&wrapped) + .env(USER_CMD_ENV, evil) + .output() + .expect("spawn sh"); + assert!(out.status.success(), "stderr: {:?}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("IT'S A (TEST)"), "stdout: {stdout:?}"); + assert!(stdout.contains("3"), "stdout missing arithmetic: {stdout:?}"); + } + + #[test] + fn try_parse_sentinel_matches() { + assert_eq!(try_parse_sentinel("__DCMCP_PGID=42__"), Some(42)); + assert_eq!(try_parse_sentinel(" __DCMCP_PGID=12345__ "), Some(12345)); + } + + #[test] + fn try_parse_sentinel_accepts_decorated_lines() { + // Real-world cases observed against backend transports that + // decorate remote stderr with their own prefixes: + // + // - devpod ssh prepends ANSI color codes + timestamp + log level. + // - Some shells may inject ESC sequences from PROMPT_COMMAND + // into stderr on first connection. + // + // The sentinel itself is distinctive enough that "anywhere in + // the line" is safe in practice. + assert_eq!( + try_parse_sentinel("\u{1b}[0;1;36minfo \u{1b}[0m__DCMCP_PGID=385__"), + Some(385), + ); + assert_eq!( + try_parse_sentinel("[19:05:32] info __DCMCP_PGID=12345__"), + Some(12345), + ); + } + + #[test] + fn try_parse_sentinel_rejects_garbage() { + assert_eq!(try_parse_sentinel("hello"), None); + assert_eq!(try_parse_sentinel("__DCMCP_PGID=abc__"), None); + assert_eq!(try_parse_sentinel("PGID=42"), None); + // Truncated (no terminator) — must not match. + assert_eq!(try_parse_sentinel("__DCMCP_PGID=42"), None); + // Missing PGID number. + assert_eq!(try_parse_sentinel("__DCMCP_PGID=__"), None); + } + + #[test] + fn sentinel_pgid_actually_addresses_descendants() { + // Run a sleep in the background under the shim and verify the + // emitted PGID > 0 (round-trip parse). We can't safely kill + // the group here without affecting the test runner, so the + // end-to-end kill is tested by the cli integration test. + let wrapped = wrap(); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&wrapped) + .env(USER_CMD_ENV, "sleep 0.2 & wait") + .output() + .expect("spawn"); + let stderr = String::from_utf8_lossy(&out.stderr); + let pgid = stderr + .lines() + .find_map(try_parse_sentinel) + .expect("sentinel emitted"); + assert!(pgid > 0); + } + + #[test] + fn wrap_self_contained_runs_user_cmd_with_no_env() { + // The whole point of the self-contained variant: no env var + // setup, command flows through the shell string itself. + let wrapped = wrap_self_contained(r#"echo "it's a (self-contained) test""#); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&wrapped) + .output() + .expect("spawn"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stdout.contains("it's a (self-contained) test"), "stdout: {stdout:?}"); + assert!( + stderr.lines().any(|l| try_parse_sentinel(l).is_some()), + "no sentinel in stderr: {stderr:?}" + ); + } + + #[test] + fn wrap_self_contained_preserves_arbitrary_bytes() { + // Pathological user command: single quotes, double quotes, + // backticks, dollar signs, backslashes, newlines, parens. + let user_cmd = "echo 'a' \"b\" `echo c` $((1+1)) \\\\ \n echo done"; + let wrapped = wrap_self_contained(user_cmd); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(&wrapped) + .output() + .expect("spawn"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("a b c 2"), "stdout: {stdout:?}"); + assert!(stdout.contains("done"), "stdout: {stdout:?}"); + } +} diff --git a/crates/devcontainer-mcp-core/src/file_ops.rs b/crates/devcontainer-mcp-core/src/file_ops.rs index 0efaaab..1f24740 100644 --- a/crates/devcontainer-mcp-core/src/file_ops.rs +++ b/crates/devcontainer-mcp-core/src/file_ops.rs @@ -4,10 +4,7 @@ //! formatting, and helpers to build shell commands for reading/writing files //! through any backend (DevPod SSH, devcontainer exec, Codespaces SSH). -use std::borrow::Cow; - use base64::{engine::general_purpose::STANDARD, Engine}; -use shell_escape::escape; use crate::error::{Error, Result}; @@ -56,8 +53,15 @@ pub fn apply_edit(content: &str, old_str: &str, new_str: &str) -> Result } /// Shell-escape a string for safe embedding in a shell command. +pub fn shell_quote(s: &str) -> String { + shlex::try_quote(s) + .unwrap_or(std::borrow::Cow::Borrowed(s)) + .into_owned() +} + +/// Convenience alias for module-local use. fn quote(s: &str) -> String { - escape(Cow::Borrowed(s)).into_owned() + shell_quote(s) } /// Build a shell command that reads a file via `cat`. @@ -75,10 +79,7 @@ pub fn write_file_command(path: &str, content: &str) -> String { /// Build a shell command that lists a directory (non-hidden, up to 2 levels). pub fn list_dir_command(path: &str) -> String { - format!( - "find {} -maxdepth 2 -not -path '*/.*' | sort", - quote(path) - ) + format!("find {} -maxdepth 2 -not -path '*/.*' | sort", quote(path)) } #[cfg(test)] @@ -136,8 +137,11 @@ mod tests { #[test] fn test_quote_path_with_single_quote() { let result = quote("it's"); - // Should not break when used in a shell command - assert!(!result.contains("it's") || result.contains("\\'")); + // Result should be shell-safe: either escaped or wrapped in quotes + assert!( + result != "it's", + "single quote must be escaped or quoted, got: {result}" + ); } #[test] diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs index 3189d28..a54d566 100644 --- a/crates/devcontainer-mcp-core/src/lib.rs +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -2,7 +2,10 @@ pub mod auth; pub mod cli; pub mod codespaces; pub mod devcontainer; +pub mod devcontainer_config; pub mod devpod; pub mod docker; pub mod error; +pub mod exec_shim; pub mod file_ops; +pub mod process_tree; diff --git a/crates/devcontainer-mcp-core/src/process_tree.rs b/crates/devcontainer-mcp-core/src/process_tree.rs new file mode 100644 index 0000000..50f43d2 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/process_tree.rs @@ -0,0 +1,206 @@ +//! Process-tree reaping. +//! +//! When a CLI we spawned crosses a process boundary (e.g. `devcontainer +//! exec` → `docker exec` → containerd-shim → user command inside the +//! container), simply SIGTERM'ing our immediate child does not kill the +//! actual work — the in-container processes are reparented to the +//! container's PID 1 and keep running. They consume CPU, hold locks, +//! and never exit. +//! +//! On Linux, every process in any namespace still has a host-visible PID +//! in the root PID namespace, and `/proc//status` exposes its parent +//! as `PPid:`. So from our root child PID we can BFS the full set of +//! descendants and terminate them ourselves — no in-container shim, no +//! cooperation from the backend CLI required. +//! +//! Non-Linux platforms get a no-op fallback that only signals the root +//! PID; this is fine because dev containers are a Linux-host feature. + +use std::time::Duration; + +/// Reap a process tree rooted at `root_pid`. +/// +/// 1. Discover every descendant of `root_pid` (including `root_pid` itself) +/// by walking `/proc/*/status`. +/// 2. Send `SIGTERM` to all of them. +/// 3. Wait up to `grace` for them to exit. +/// 4. Send `SIGKILL` to any survivors. +/// +/// Best-effort: any individual `/proc` read or `kill(2)` is allowed to +/// fail silently (the process may have already exited). The function +/// returns when the grace period is up; it does not guarantee every PID +/// is gone (a process can ignore SIGKILL only if it is uninterruptible +/// in the kernel, which is out of our control). +pub async fn reap(root_pid: u32, grace: Duration) { + #[cfg(target_os = "linux")] + { + reap_linux(root_pid, grace).await; + } + #[cfg(not(target_os = "linux"))] + { + // Fallback: signal the root only. Container reaping requires + // /proc-style introspection which we don't have here. + let _ = (root_pid, grace); + #[cfg(unix)] + unsafe { + libc::kill(root_pid as i32, libc::SIGTERM); + tokio::time::sleep(grace).await; + libc::kill(root_pid as i32, libc::SIGKILL); + } + } +} + +#[cfg(target_os = "linux")] +async fn reap_linux(root_pid: u32, grace: Duration) { + // Snapshot the full descendant set. We do this once, send SIGTERM, + // wait, then re-discover for SIGKILL (new grandchildren may have + // appeared during the grace period — e.g. a `go test` runner that + // spawned its own test binaries while we were waiting). + let initial = collect_descendants(root_pid); + tracing::debug!( + root = root_pid, + n = initial.len(), + "reap: SIGTERM descendants" + ); + signal_all(&initial, libc_signal::SIGTERM); + + // Wait for graceful exit. Short-circuit if everyone is already gone. + let deadline = std::time::Instant::now() + grace; + while std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(100)).await; + if !any_alive(&initial) { + tracing::debug!(root = root_pid, "reap: all descendants exited cleanly"); + return; + } + } + + // Re-discover and SIGKILL anything still around. We re-walk /proc + // because the surviving tree may have grown. + let survivors = collect_descendants(root_pid); + if !survivors.is_empty() { + tracing::warn!( + root = root_pid, + n = survivors.len(), + "reap: SIGKILL survivors" + ); + signal_all(&survivors, libc_signal::SIGKILL); + } +} + +#[cfg(target_os = "linux")] +fn collect_descendants(root: u32) -> Vec { + // Build a parent → children map by scanning /proc/*/status. This is + // O(N) where N is the number of processes on the host. For typical + // dev machines (a few hundred procs) this is sub-millisecond. + use std::collections::HashMap; + + let mut children_of: HashMap> = HashMap::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return vec![root]; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { continue }; + let Ok(pid) = name_str.parse::() else { continue }; + if let Some(ppid) = read_ppid(pid) { + children_of.entry(ppid).or_default().push(pid); + } + } + + // BFS from `root`. Include `root` itself so the caller can kill the + // whole tree in one pass. + let mut out = Vec::new(); + let mut stack = vec![root]; + while let Some(pid) = stack.pop() { + out.push(pid); + if let Some(kids) = children_of.get(&pid) { + stack.extend_from_slice(kids); + } + } + out +} + +#[cfg(target_os = "linux")] +fn read_ppid(pid: u32) -> Option { + // `/proc//status` is line-oriented. We only need the `PPid:` + // line, which appears near the top, so reading the whole file is + // wasteful but trivial in size. + let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("PPid:") { + return rest.trim().parse().ok(); + } + } + None +} + +#[cfg(target_os = "linux")] +fn signal_all(pids: &[u32], sig: i32) { + for &pid in pids { + // kill(2) can fail with ESRCH if the process already exited; + // EPERM if we don't own it (shouldn't happen for our descendants + // unless they did a setuid). Either way, swallow. + unsafe { + libc::kill(pid as i32, sig); + } + } +} + +#[cfg(target_os = "linux")] +fn any_alive(pids: &[u32]) -> bool { + pids.iter().any(|&pid| { + // kill(pid, 0) probes existence + permission without sending a + // signal. Returns 0 if the process exists and we can signal it. + unsafe { libc::kill(pid as i32, 0) == 0 } + }) +} + +#[cfg(target_os = "linux")] +mod libc_signal { + pub const SIGTERM: i32 = libc::SIGTERM; + pub const SIGKILL: i32 = libc::SIGKILL; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn read_ppid_for_self() { + let pid = std::process::id(); + let ppid = read_ppid(pid).expect("self has a PPid"); + // Whatever launched the test runner. + assert!(ppid > 0); + } + + #[cfg(target_os = "linux")] + #[test] + fn descendants_includes_root() { + let pid = std::process::id(); + let set = collect_descendants(pid); + assert!(set.contains(&pid)); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn reap_kills_a_child_tree() { + // Spawn `sh -c 'sleep 30 & sleep 30 & wait'` and reap it. + let mut child = tokio::process::Command::new("sh") + .args(["-c", "sleep 30 & sleep 30 & wait"]) + .kill_on_drop(true) + .spawn() + .expect("spawn"); + let root = child.id().expect("pid"); + // Let the grandchildren actually exist before we reap. + tokio::time::sleep(Duration::from_millis(200)).await; + let before = collect_descendants(root); + assert!(before.len() >= 3, "expected root + 2 sleeps, got {before:?}"); + + reap(root, Duration::from_secs(1)).await; + // Give the kernel a moment to actually reap zombies. + let _ = child.wait().await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!any_alive(&before), "some descendants survived: {before:?}"); + } +} diff --git a/crates/devcontainer-mcp-core/tests/hook_guard_test.rs b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs new file mode 100644 index 0000000..e277bf2 --- /dev/null +++ b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs @@ -0,0 +1,172 @@ +//! Integration tests for the devcontainer-guard.sh hook script. +//! +//! These tests verify that the hook correctly blocks/allows tool calls +//! based on the agent format, tool name, devcontainer presence, and bypass. + +use std::process::Command; + +fn repo_root() -> std::path::PathBuf { + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .and_then(|p| p.parent()) + .expect("could not find repo root") + .to_path_buf() +} + +fn hook_path() -> std::path::PathBuf { + repo_root().join(".github/hooks/devcontainer-guard.sh") +} + +/// Run the hook script with the given JSON input and return (stdout, exit_code). +fn run_hook(json_input: &str) -> (String, i32) { + let output = Command::new("bash") + .arg(hook_path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(json_input.as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .expect("failed to run hook script"); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let code = output.status.code().unwrap_or(-1); + (stdout, code) +} + +fn cwd_with_devcontainer() -> String { + repo_root().to_string_lossy().to_string() +} + +// ----------------------------------------------------------------------- +// Copilot CLI format +// ----------------------------------------------------------------------- + +#[test] +fn copilot_cli_bash_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"bash","toolArgs":{{"command":"cargo build"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!( + stdout.contains(r#""permissionDecision":"deny""#) + || stdout.contains(r#""permissionDecision": "deny""#) + ); + // Copilot CLI format should NOT have hookSpecificOutput + assert!(!stdout.contains("hookSpecificOutput")); +} + +#[test] +fn copilot_cli_shell_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"shell","toolArgs":{{"command":"make"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, _) = run_hook(&input); + assert!(stdout.contains("deny")); +} + +#[test] +fn copilot_cli_powershell_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"powershell","toolArgs":{{"command":"dir"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, _) = run_hook(&input); + assert!(stdout.contains("deny")); +} + +#[test] +fn copilot_cli_view_tool_allows() { + let input = format!( + r#"{{"toolName":"view","toolArgs":{{"path":"src/main.rs"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "non-bash tool should produce no output"); +} + +#[test] +fn copilot_cli_no_devcontainer_allows() { + let input = r#"{"toolName":"bash","toolArgs":{"command":"ls"},"cwd":"/tmp"}"#; + let (stdout, code) = run_hook(input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +// ----------------------------------------------------------------------- +// Claude Code format +// ----------------------------------------------------------------------- + +#[test] +fn claude_code_bash_with_devcontainer_denies() { + let input = format!( + r#"{{"tool_name":"Bash","tool_input":{{"command":"npm install"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.contains("hookSpecificOutput")); + assert!(stdout.contains("deny")); +} + +#[test] +fn claude_code_edit_tool_allows() { + let input = format!( + r#"{{"tool_name":"Edit","tool_input":{{"path":"src/main.rs"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +// ----------------------------------------------------------------------- +// Bypass +// ----------------------------------------------------------------------- + +#[test] +fn bypass_string_allows_through() { + let input = format!( + r#"{{"tool_name":"Bash","tool_input":{{"command":"USER_CONFIRMED_HOST_OPERATION=1 cargo build"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "bypass should produce no output"); +} + +// ----------------------------------------------------------------------- +// Edge cases +// ----------------------------------------------------------------------- + +#[test] +fn missing_cwd_allows() { + let input = r#"{"toolName":"bash","toolArgs":{"command":"ls"}}"#; + let (stdout, code) = run_hook(input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +#[test] +fn empty_tool_name_allows() { + let input = format!( + r#"{{"toolName":"","toolArgs":{{}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} diff --git a/crates/devcontainer-mcp/Cargo.toml b/crates/devcontainer-mcp/Cargo.toml index 48e283b..46acee6 100644 --- a/crates/devcontainer-mcp/Cargo.toml +++ b/crates/devcontainer-mcp/Cargo.toml @@ -17,3 +17,6 @@ rmcp = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } schemars = "1" +shlex = "1" +async-trait = "0.1" +tokio-util = "0.7" diff --git a/crates/devcontainer-mcp/src/tools/codespaces/create.rs b/crates/devcontainer-mcp/src/tools/codespaces/create.rs index 5d94fc6..b3ee212 100644 --- a/crates/devcontainer-mcp/src/tools/codespaces/create.rs +++ b/crates/devcontainer-mcp/src/tools/codespaces/create.rs @@ -1,8 +1,11 @@ use devcontainer_mcp_core::{auth, codespaces}; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; -use crate::tools::common::format_output; +use crate::tools::common::{format_output, progress_sink_from_meta}; use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] @@ -36,12 +39,16 @@ impl DevContainerMcp { async fn codespaces_create( &self, Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, ) -> String { let env = match auth::resolve_handle_env(¶ms.auth).await { Ok(e) => e, Err(e) => return format!("Auth error: {e}"), }; - match codespaces::create( + let sink = progress_sink_from_meta(&meta, &peer); + match codespaces::create_streaming( &env, ¶ms.repo, params.branch.as_deref(), @@ -49,6 +56,8 @@ impl DevContainerMcp { params.devcontainer_path.as_deref(), params.display_name.as_deref(), params.idle_timeout.as_deref(), + &ct, + sink, ) .await { diff --git a/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs b/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs index 6ff581f..db29880 100644 --- a/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs +++ b/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs @@ -1,8 +1,11 @@ use devcontainer_mcp_core::{auth, codespaces}; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; -use crate::tools::common::format_output; +use crate::tools::common::{format_output, progress_sink_from_meta}; use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] @@ -21,12 +24,23 @@ impl DevContainerMcp { name = "codespaces_ssh", description = "Execute a command inside a GitHub Codespace via SSH. Requires a GitHub auth handle." )] - async fn codespaces_ssh(&self, Parameters(params): Parameters) -> String { + async fn codespaces_ssh( + &self, + Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, + ) -> String { let env = match auth::resolve_handle_env(¶ms.auth).await { Ok(e) => e, Err(e) => return format!("Auth error: {e}"), }; - match codespaces::ssh_exec(&env, ¶ms.codespace, ¶ms.command).await { + + let sink = progress_sink_from_meta(&meta, &peer); + + match codespaces::ssh_exec_streaming(&env, ¶ms.codespace, ¶ms.command, &ct, sink) + .await + { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/common.rs b/crates/devcontainer-mcp/src/tools/common.rs index 9ef4902..7770619 100644 --- a/crates/devcontainer-mcp/src/tools/common.rs +++ b/crates/devcontainer-mcp/src/tools/common.rs @@ -1,4 +1,10 @@ -use devcontainer_mcp_core::cli::CliOutput; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use devcontainer_mcp_core::cli::{ChunkSink, CliOutput, OutputChunk, OutputStream}; +use rmcp::model::{Meta, ProgressNotificationParam, ProgressToken}; +use rmcp::service::Peer; +use rmcp::RoleServer; /// Format a CliOutput as a JSON string for MCP responses. pub fn format_output(output: &CliOutput) -> String { @@ -10,3 +16,56 @@ pub fn format_output(output: &CliOutput) -> String { }) .to_string() } + +/// Forwards every line of child output to the MCP peer as a +/// `notifications/progress` message. Used by all long-running tools +/// (`*_exec`, `*_ssh`, `*_up`, `*_build`, `codespaces_create`) so +/// clients can render progress and so idle-based client timeouts +/// don't trip on multi-minute operations. +/// +/// The MCP spec requires a strictly-increasing `progress` value on +/// every notification with the same token, hence the per-sink +/// atomic counter. +struct ProgressChunkSink { + peer: Peer, + progress_token: ProgressToken, + counter: AtomicU64, +} + +#[async_trait::async_trait] +impl ChunkSink for ProgressChunkSink { + async fn on_chunk(&self, chunk: OutputChunk) { + let n = self.counter.fetch_add(1, Ordering::Relaxed) + 1; + let prefix = match chunk.stream { + OutputStream::Stdout => "stdout", + OutputStream::Stderr => "stderr", + }; + let param = ProgressNotificationParam::new(self.progress_token.clone(), n as f64) + .with_message(format!("{prefix}: {}", chunk.line)); + if let Err(e) = self.peer.notify_progress(param).await { + // Sink errors are non-fatal: the peer may have + // disconnected or simply not subscribed to progress. + // We still want the child to keep running and produce + // a final response (which may or may not get delivered). + tracing::debug!(%e, "failed to send progress notification"); + } + } +} + +/// Build a [`ChunkSink`] from a request's `_meta`. +/// +/// Returns `Some(Arc)` if the client supplied a +/// `progressToken`, `None` otherwise. Tool handlers should pass the +/// returned sink straight into `*_streaming` core functions. +pub fn progress_sink_from_meta( + meta: &Meta, + peer: &Peer, +) -> Option> { + meta.get_progress_token().map(|token| { + Arc::new(ProgressChunkSink { + peer: peer.clone(), + progress_token: token, + counter: AtomicU64::new(0), + }) as Arc + }) +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs index caea2c3..790fdf3 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs @@ -1,14 +1,21 @@ use devcontainer_mcp_core::devcontainer; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; -use crate::tools::common::format_output; +use crate::tools::common::{format_output, progress_sink_from_meta}; use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] struct DevcontainerBuildParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars( description = "Additional flags as space-separated args, e.g. '--no-cache --image-name my-image'" )] @@ -24,13 +31,26 @@ impl DevContainerMcp { async fn devcontainer_build( &self, Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, ) -> String { - let extra: Vec<&str> = params + let extra: Vec = params .extra_args .as_deref() - .map(|a| a.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devcontainer::build(¶ms.workspace_folder, &extra).await { + let extra_refs: Vec<&str> = extra.iter().map(|s| s.as_str()).collect(); + let sink = progress_sink_from_meta(&meta, &peer); + match devcontainer::build_streaming( + ¶ms.workspace_folder, + params.config.as_deref(), + &extra_refs, + &ct, + sink, + ) + .await + { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs index e97a521..54775ee 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs @@ -1,14 +1,21 @@ use devcontainer_mcp_core::devcontainer; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; -use crate::tools::common::format_output; +use crate::tools::common::{format_output, progress_sink_from_meta}; use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] struct DevcontainerExecParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Command to execute inside the container")] command: String, #[schemars(description = "Arguments for the command as a space-separated string")] @@ -24,13 +31,27 @@ impl DevContainerMcp { async fn devcontainer_exec( &self, Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, ) -> String { - let cmd_args: Vec<&str> = params - .args - .as_deref() - .map(|a| a.split_whitespace().collect()) - .unwrap_or_default(); - match devcontainer::exec(¶ms.workspace_folder, ¶ms.command, &cmd_args).await { + let full_cmd = match ¶ms.args { + Some(a) => format!("{} {}", params.command, a), + None => params.command, + }; + + let sink = progress_sink_from_meta(&meta, &peer); + + match devcontainer::exec_streaming( + ¶ms.workspace_folder, + params.config.as_deref(), + "sh", + &["-c", &full_cmd], + &ct, + sink, + ) + .await + { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/files.rs b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs index d22ba25..fc98e1b 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/files.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerFileReadParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the file inside the container")] path: String, #[schemars(description = "Start line number (1-based, inclusive)")] @@ -22,6 +26,10 @@ struct DevcontainerFileReadParams { struct DevcontainerFileWriteParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the file inside the container")] path: String, #[schemars(description = "File content to write")] @@ -32,6 +40,10 @@ struct DevcontainerFileWriteParams { struct DevcontainerFileEditParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the file inside the container")] path: String, #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] @@ -44,6 +56,10 @@ struct DevcontainerFileEditParams { struct DevcontainerFileListParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the directory inside the container (defaults to '.')")] path: Option, } @@ -58,7 +74,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::file_read(¶ms.workspace_folder, ¶ms.path).await { + match devcontainer::file_read( + ¶ms.workspace_folder, + params.config.as_deref(), + ¶ms.path, + ) + .await + { Ok(output) => { if output.exit_code != 0 { return format!( @@ -84,8 +106,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::file_write(¶ms.workspace_folder, ¶ms.path, ¶ms.content) - .await + match devcontainer::file_write( + ¶ms.workspace_folder, + params.config.as_deref(), + ¶ms.path, + ¶ms.content, + ) + .await { Ok(output) => { if output.exit_code != 0 { @@ -112,6 +139,7 @@ impl DevContainerMcp { ) -> String { match devcontainer::file_edit( ¶ms.workspace_folder, + params.config.as_deref(), ¶ms.path, ¶ms.old_str, ¶ms.new_str, @@ -132,7 +160,8 @@ impl DevContainerMcp { Parameters(params): Parameters, ) -> String { let dir = params.path.as_deref().unwrap_or("."); - match devcontainer::file_list(¶ms.workspace_folder, dir).await { + match devcontainer::file_list(¶ms.workspace_folder, params.config.as_deref(), dir).await + { Ok(output) => { if output.exit_code != 0 { format!( diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs b/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs new file mode 100644 index 0000000..5f22358 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs @@ -0,0 +1,30 @@ +use devcontainer_mcp_core::devcontainer_config; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerListConfigsParams { + #[schemars(description = "Path to the workspace folder to scan for devcontainer.json files")] + workspace_folder: String, +} + +#[tool_router(router = devcontainer_list_configs_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_list_configs", + description = "Enumerate devcontainer.json files in a workspace. Returns each discovered config (root `.devcontainer.json`, `.devcontainer/devcontainer.json`, and `.devcontainer/*/devcontainer.json`) with its parsed name, image, service, dockerComposeFile, workspaceFolder, and kind (compose | image | dockerfile | unknown). Use the returned `path` directly as the `config` parameter to other devcontainer tools to target a specific container in a multi-container workspace." + )] + async fn devcontainer_list_configs( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer_config::list_configs(¶ms.workspace_folder) { + Ok(entries) => { + serde_json::to_string(&entries).unwrap_or_else(|e| format!("Error: {e}")) + } + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs b/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs index 08de29b..3ba0406 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs @@ -2,6 +2,7 @@ mod build; mod config; mod exec; mod files; +mod list_configs; mod remove; mod status; mod stop; @@ -21,5 +22,6 @@ impl DevContainerMcp { + Self::devcontainer_remove_router() + Self::devcontainer_status_router() + Self::devcontainer_files_router() + + Self::devcontainer_list_configs_router() } } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs index dabfe53..72638dc 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerRemoveParams { #[schemars(description = "Path to the workspace folder (used to find the container by label)")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Force removal even if the container is running")] force: Option, } @@ -22,7 +26,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::remove(¶ms.workspace_folder, params.force.unwrap_or(false)).await { + match devcontainer::remove( + ¶ms.workspace_folder, + params.config.as_deref(), + params.force.unwrap_or(false), + ) + .await + { Ok(msg) => msg, Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/status.rs b/crates/devcontainer-mcp/src/tools/devcontainer/status.rs index c8de498..c35e530 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/status.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/status.rs @@ -1,6 +1,7 @@ -use devcontainer_mcp_core::devcontainer; +use devcontainer_mcp_core::devcontainer::{self, StatusOutcome}; use rmcp::handler::server::wrapper::Parameters; use rmcp::{tool, tool_router}; +use serde_json::json; use crate::tools::DevContainerMcp; @@ -8,23 +9,52 @@ use crate::tools::DevContainerMcp; struct DevcontainerStatusParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, } #[tool_router(router = devcontainer_status_router, vis = "pub(super)")] impl DevContainerMcp { #[tool( name = "devcontainer_status", - description = "Get the status of a local dev container. Returns container info (state, image, labels) or null if not found." + description = "Get the status of a local dev container. Returns container info (state, image, labels) for a single match, `{\"state\":\"NotFound\"}` if nothing matches, or `{\"state\":\"Ambiguous\",\"candidates\":[...]}` if multiple containers match the workspace and no `config` was supplied to disambiguate." )] async fn devcontainer_status( &self, Parameters(params): Parameters, ) -> String { - match devcontainer::status(¶ms.workspace_folder).await { - Ok(Some(info)) => { + match devcontainer::status(¶ms.workspace_folder, params.config.as_deref()).await { + Ok(StatusOutcome::Found(info)) => { serde_json::to_string(&info).unwrap_or_else(|e| format!("Error: {e}")) } - Ok(None) => r#"{"state":"NotFound"}"#.to_string(), + Ok(StatusOutcome::NotFound) => r#"{"state":"NotFound"}"#.to_string(), + Ok(StatusOutcome::Ambiguous(candidates)) => { + // Surface every candidate's identity, service, and + // config-file label so the agent has enough info to + // pick the right `config` and retry without further + // calls. + let entries: Vec<_> = candidates + .iter() + .map(|c| { + json!({ + "id": c.id, + "name": c.name, + "image": c.image, + "state": c.state, + "composeService": c.compose_service(), + "configFile": c.devcontainer_config_file(), + }) + }) + .collect(); + json!({ + "state": "Ambiguous", + "candidates": entries, + "hint": "Multiple containers match this workspace. Call devcontainer_list_configs and re-run with the `config` parameter set to the desired devcontainer.json.", + }) + .to_string() + } Err(e) => format!("Error: {e}"), } } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs b/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs index 38acf79..78c30c0 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerStopParams { #[schemars(description = "Path to the workspace folder (used to find the container by label)")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, } #[tool_router(router = devcontainer_stop_router, vis = "pub(super)")] @@ -20,7 +24,7 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::stop(¶ms.workspace_folder).await { + match devcontainer::stop(¶ms.workspace_folder, params.config.as_deref()).await { Ok(msg) => msg, Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs index 7c9955d..9c7dd79 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs @@ -1,8 +1,11 @@ use devcontainer_mcp_core::devcontainer; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; -use crate::tools::common::format_output; +use crate::tools::common::{format_output, progress_sink_from_meta}; use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] @@ -11,7 +14,9 @@ struct DevcontainerUpParams { description = "Path to the workspace folder containing .devcontainer/devcontainer.json" )] workspace_folder: String, - #[schemars(description = "Path to a specific devcontainer.json (overrides auto-detection)")] + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] config: Option, #[schemars( description = "Additional flags as space-separated args, e.g. '--remove-existing-container --build-no-cache'" @@ -28,13 +33,26 @@ impl DevContainerMcp { async fn devcontainer_up( &self, Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, ) -> String { - let extra: Vec<&str> = params + let extra: Vec = params .extra_args .as_deref() - .map(|a| a.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devcontainer::up(¶ms.workspace_folder, params.config.as_deref(), &extra).await { + let extra_refs: Vec<&str> = extra.iter().map(|s| s.as_str()).collect(); + let sink = progress_sink_from_meta(&meta, &peer); + match devcontainer::up_streaming( + ¶ms.workspace_folder, + params.config.as_deref(), + &extra_refs, + &ct, + sink, + ) + .await + { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/build.rs b/crates/devcontainer-mcp/src/tools/devpod/build.rs index 6ce4642..eccd638 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/build.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/build.rs @@ -1,8 +1,12 @@ -use crate::tools::common::format_output; -use crate::tools::DevContainerMcp; use devcontainer_mcp_core::devpod; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; + +use crate::tools::common::{format_output, progress_sink_from_meta}; +use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] struct DevpodBuildParams { @@ -18,9 +22,18 @@ impl DevContainerMcp { name = "devpod_build", description = "Build a DevPod workspace image without starting it." )] - async fn devpod_build(&self, Parameters(params): Parameters) -> String { - let parts: Vec<&str> = params.args.split_whitespace().collect(); - match devpod::build(&parts).await { + async fn devpod_build( + &self, + Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, + ) -> String { + let parts: Vec = shlex::split(¶ms.args) + .unwrap_or_else(|| params.args.split_whitespace().map(String::from).collect()); + let part_refs: Vec<&str> = parts.iter().map(|s| s.as_str()).collect(); + let sink = progress_sink_from_meta(&meta, &peer); + match devpod::build_streaming(&part_refs, &ct, sink).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/provider.rs b/crates/devcontainer-mcp/src/tools/devpod/provider.rs index 24ead82..e3ef756 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/provider.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/provider.rs @@ -37,12 +37,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - let opt_parts: Vec<&str> = params + let opt_parts: Vec = params .options .as_deref() - .map(|o| o.split_whitespace().collect()) + .and_then(shlex::split) .unwrap_or_default(); - match devpod::provider_add(¶ms.provider, &opt_parts).await { + let opt_refs: Vec<&str> = opt_parts.iter().map(|s| s.as_str()).collect(); + match devpod::provider_add(¶ms.provider, &opt_refs).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/crates/devcontainer-mcp/src/tools/devpod/ssh.rs b/crates/devcontainer-mcp/src/tools/devpod/ssh.rs index f1cf55e..9dedc95 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/ssh.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/ssh.rs @@ -1,8 +1,12 @@ -use crate::tools::common::format_output; -use crate::tools::DevContainerMcp; use devcontainer_mcp_core::devpod; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; + +use crate::tools::common::{format_output, progress_sink_from_meta}; +use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] struct DevpodSshParams { @@ -24,12 +28,22 @@ impl DevContainerMcp { name = "devpod_ssh", description = "Execute a command inside a DevPod workspace via SSH. Returns stdout, stderr, and exit code." )] - async fn devpod_ssh(&self, Parameters(params): Parameters) -> String { - match devpod::ssh_exec( + async fn devpod_ssh( + &self, + Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, + ) -> String { + let sink = progress_sink_from_meta(&meta, &peer); + + match devpod::ssh_exec_streaming( ¶ms.workspace, ¶ms.command, params.user.as_deref(), params.workdir.as_deref(), + &ct, + sink, ) .await { diff --git a/crates/devcontainer-mcp/src/tools/devpod/up.rs b/crates/devcontainer-mcp/src/tools/devpod/up.rs index b0366c5..ebf0ad3 100644 --- a/crates/devcontainer-mcp/src/tools/devpod/up.rs +++ b/crates/devcontainer-mcp/src/tools/devpod/up.rs @@ -1,8 +1,12 @@ -use crate::tools::common::format_output; -use crate::tools::DevContainerMcp; use devcontainer_mcp_core::devpod; use rmcp::handler::server::wrapper::Parameters; -use rmcp::{tool, tool_router}; +use rmcp::model::Meta; +use rmcp::service::Peer; +use rmcp::{tool, tool_router, RoleServer}; +use tokio_util::sync::CancellationToken; + +use crate::tools::common::{format_output, progress_sink_from_meta}; +use crate::tools::DevContainerMcp; #[derive(serde::Deserialize, schemars::JsonSchema)] struct DevpodUpParams { @@ -18,9 +22,18 @@ impl DevContainerMcp { name = "devpod_up", description = "Create and start a DevPod workspace. Pass the source (git URL, local path, or image) and any flags as space-separated args. Returns full build output for self-healing." )] - async fn devpod_up(&self, Parameters(params): Parameters) -> String { - let parts: Vec<&str> = params.args.split_whitespace().collect(); - match devpod::up(&parts).await { + async fn devpod_up( + &self, + Parameters(params): Parameters, + ct: CancellationToken, + peer: Peer, + meta: Meta, + ) -> String { + let parts: Vec = shlex::split(¶ms.args) + .unwrap_or_else(|| params.args.split_whitespace().map(String::from).collect()); + let part_refs: Vec<&str> = parts.iter().map(|s| s.as_str()).collect(); + let sink = progress_sink_from_meta(&meta, &peer); + match devpod::up_streaming(&part_refs, &ct, sink).await { Ok(output) => format_output(&output), Err(e) => format!("Error: {e}"), } diff --git a/install.ps1 b/install.ps1 index 9f44397..86cbdda 100644 --- a/install.ps1 +++ b/install.ps1 @@ -111,6 +111,7 @@ $skillDirs = @( "$env:USERPROFILE\.copilot\skills\devcontainer-mcp" "$env:USERPROFILE\.claude\skills\devcontainer-mcp" "$env:USERPROFILE\.agents\skills\devcontainer-mcp" + "$env:USERPROFILE\.config\opencode\skills\devcontainer-mcp" ) foreach ($dir in $skillDirs) { @@ -123,6 +124,156 @@ foreach ($dir in $skillDirs) { } } +# --------------------------------------------------------------------------- +# 3b. Install host-protection hooks +# --------------------------------------------------------------------------- + +Write-Step "Installing host-protection hook..." + +$hookUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-guard.sh" +$loaderUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-skill-loader.sh" +$WslHookDir = "~/.local/share/devcontainer-mcp/hooks" +$WslHookPath = "$WslHookDir/devcontainer-guard.sh" +$WslLoaderPath = "$WslHookDir/devcontainer-skill-loader.sh" + +$hookResult = wsl -d $WslDistro bash -c "mkdir -p '$WslHookDir' && curl -fsSL -o '$WslHookPath' '$hookUrl' && chmod +x '$WslHookPath' && echo OK" 2>&1 +if ($hookResult -match "OK") { + Write-Ok "Guard hook installed in WSL at $WslHookPath" +} else { + Write-Warn "Could not install guard hook in WSL" +} + +$loaderResult = wsl -d $WslDistro bash -c "curl -fsSL -o '$WslLoaderPath' '$loaderUrl' && chmod +x '$WslLoaderPath' && echo OK" 2>&1 +if ($loaderResult -match "OK") { + Write-Ok "Skill-loader hook installed in WSL at $WslLoaderPath" +} else { + Write-Warn "Could not install skill-loader hook in WSL" +} + +# Install SKILL.md alongside hooks for the loader to find +$WslSkillDataPath = "~/.local/share/devcontainer-mcp/SKILL.md" +wsl -d $WslDistro bash -c "curl -fsSL -o '$WslSkillDataPath' '$skillUrl'" 2>&1 | Out-Null + +# Configure Claude Code PreToolUse + SessionStart hooks (Windows-side) +Write-Step "Configuring agent hooks..." + +$claudeSettings = "$env:USERPROFILE\.claude\settings.json" +try { + $guardEntry = @{ + matcher = "Bash" + hooks = @( + @{ + type = "command" + command = "wsl $WslHookPath" + timeout = 5 + } + ) + } + $loaderEntry = @{ + hooks = @( + @{ + type = "command" + command = "wsl $WslLoaderPath" + timeout = 5 + } + ) + } + + if (Test-Path $claudeSettings) { + $content = Get-Content -Raw $claudeSettings | ConvertFrom-Json + if (-not $content.hooks) { + $content | Add-Member -NotePropertyName "hooks" -NotePropertyValue ([PSCustomObject]@{}) + } + + # PreToolUse: devcontainer-guard + if (-not $content.hooks.PreToolUse) { + $content.hooks | Add-Member -NotePropertyName "PreToolUse" -NotePropertyValue @() + } + $alreadyGuard = $false + foreach ($group in $content.hooks.PreToolUse) { + foreach ($h in $group.hooks) { + if ($h.command -match "devcontainer-guard") { $alreadyGuard = $true; break } + } + } + if (-not $alreadyGuard) { + $content.hooks.PreToolUse += [PSCustomObject]$guardEntry + Write-Ok "Claude Code — added PreToolUse hook" + } else { + Write-Ok "Claude Code — PreToolUse hook already configured" + } + + # SessionStart: skill-loader + if (-not $content.hooks.SessionStart) { + $content.hooks | Add-Member -NotePropertyName "SessionStart" -NotePropertyValue @() + } + $alreadyLoader = $false + foreach ($group in $content.hooks.SessionStart) { + foreach ($h in $group.hooks) { + if ($h.command -match "skill-loader") { $alreadyLoader = $true; break } + } + } + if (-not $alreadyLoader) { + $content.hooks.SessionStart += [PSCustomObject]$loaderEntry + Write-Ok "Claude Code — added SessionStart hook" + } else { + Write-Ok "Claude Code — SessionStart hook already configured" + } + + $content | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8 + } else { + $dir = Split-Path $claudeSettings -Parent + if ($dir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $config = [PSCustomObject]@{ + hooks = [PSCustomObject]@{ + PreToolUse = @([PSCustomObject]$guardEntry) + SessionStart = @([PSCustomObject]$loaderEntry) + } + } + $config | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8 + Write-Ok "Claude Code — created $claudeSettings" + } +} catch { + Write-Warn "Claude Code — could not configure hooks" +} + +# Configure Copilot CLI preToolUse + sessionStart hooks (Windows-side) +$copilotHooksDir = "$env:USERPROFILE\.copilot\hooks" +try { + New-Item -ItemType Directory -Path $copilotHooksDir -Force | Out-Null + + $copilotGuard = [PSCustomObject]@{ + version = 1 + hooks = [PSCustomObject]@{ + preToolUse = @( + [PSCustomObject]@{ + type = "command" + bash = "wsl $WslHookPath" + timeoutSec = 5 + } + ) + } + } + $copilotGuard | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8 + Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-guard.json" + + $copilotLoader = [PSCustomObject]@{ + version = 1 + hooks = [PSCustomObject]@{ + sessionStart = @( + [PSCustomObject]@{ + type = "command" + bash = "wsl $WslLoaderPath" + timeoutSec = 5 + } + ) + } + } + $copilotLoader | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-skill-loader.json" -Encoding UTF8 + Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-skill-loader.json" +} catch { + Write-Warn "Copilot CLI — could not configure hooks" +} + # --------------------------------------------------------------------------- # 4. Detect backend CLIs available in WSL # --------------------------------------------------------------------------- @@ -212,6 +363,48 @@ if (Test-Path "$env:USERPROFILE\.cursor") { Set-McpConfig "$env:USERPROFILE\.cursor\mcp.json" "Cursor" } +# opencode — uses a distinct schema (mcp key, type: "local", combined +# command array, enabled: true) and lives at ~/.config/opencode/opencode.json. +function Set-OpencodeMcpConfig { + $ConfigPath = "$env:USERPROFILE\.config\opencode\opencode.json" + $opencodeEntry = [PSCustomObject]@{ + type = "local" + command = @("wsl", $WslBinaryPath, "serve") + enabled = $true + } + + try { + if (Test-Path $ConfigPath) { + $content = Get-Content -Raw $ConfigPath | ConvertFrom-Json + if (-not $content.mcp) { + $content | Add-Member -NotePropertyName "mcp" -NotePropertyValue ([PSCustomObject]@{}) + } + if ($content.mcp.PSObject.Properties.Name -contains "devcontainer-mcp") { + Write-Ok "opencode — already configured" + return + } + $content.mcp | Add-Member -NotePropertyName "devcontainer-mcp" -NotePropertyValue $opencodeEntry + $content | ConvertTo-Json -Depth 10 | Set-Content $ConfigPath -Encoding UTF8 + Write-Ok "opencode — added to $ConfigPath" + } else { + $dir = Split-Path $ConfigPath -Parent + if ($dir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $config = [PSCustomObject]@{ + '$schema' = "https://opencode.ai/config.json" + mcp = [PSCustomObject]@{ + "devcontainer-mcp" = $opencodeEntry + } + } + $config | ConvertTo-Json -Depth 10 | Set-Content $ConfigPath -Encoding UTF8 + Write-Ok "opencode — created $ConfigPath" + } + } catch { + Write-Warn "opencode — could not update $ConfigPath" + } +} + +Set-OpencodeMcpConfig + # --------------------------------------------------------------------------- # Done # --------------------------------------------------------------------------- diff --git a/install.sh b/install.sh index b3db7cf..4407da6 100755 --- a/install.sh +++ b/install.sh @@ -3,8 +3,12 @@ set -euo pipefail # devcontainer-mcp installer # Downloads the latest release binary. -# Backend CLIs (devpod, devcontainer, gh) are detected at runtime — -# if missing, the MCP server returns a helpful error message. +# Backend CLIs: +# - devcontainer (@devcontainers/cli) is auto-installed via the official +# standalone installer (bundles its own Node.js runtime) into +# ~/.local/share/devcontainer-mcp/devcontainers/ when missing. +# - devpod and gh are detected only; if missing, the MCP server returns +# a helpful error message and the installer prints install URLs. # # Usage: # curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash @@ -106,6 +110,7 @@ SKILL_DIRS=( "${HOME}/.copilot/skills/devcontainer-mcp" "${HOME}/.claude/skills/devcontainer-mcp" "${HOME}/.agents/skills/devcontainer-mcp" + "${HOME}/.config/opencode/skills/devcontainer-mcp" ) echo "" @@ -116,11 +121,213 @@ for dir in "${SKILL_DIRS[@]}"; do echo " ${dir}/SKILL.md" || true done +# --------------------------------------------------------------------------- +# Install host-protection hooks +# --------------------------------------------------------------------------- + +HOOK_URL="https://raw.githubusercontent.com/${REPO}/main/.github/hooks/devcontainer-guard.sh" +LOADER_URL="https://raw.githubusercontent.com/${REPO}/main/.github/hooks/devcontainer-skill-loader.sh" +HOOK_DIR="${HOME}/.local/share/devcontainer-mcp/hooks" +HOOK_PATH="${HOOK_DIR}/devcontainer-guard.sh" +LOADER_PATH="${HOOK_DIR}/devcontainer-skill-loader.sh" + +echo "" +echo "==> Installing host-protection hook..." +mkdir -p "$HOOK_DIR" +curl -fsSL -o "$HOOK_PATH" "$HOOK_URL" 2>/dev/null && \ + chmod +x "$HOOK_PATH" && \ + echo " ${HOOK_PATH}" || echo " ⚠ Could not download hook script" + +echo "" +echo "==> Installing skill-loader hook..." +curl -fsSL -o "$LOADER_PATH" "$LOADER_URL" 2>/dev/null && \ + chmod +x "$LOADER_PATH" && \ + echo " ${LOADER_PATH}" || echo " ⚠ Could not download skill-loader hook" + +# Install SKILL.md alongside hooks for the loader to find +SKILL_DATA_PATH="${HOME}/.local/share/devcontainer-mcp/SKILL.md" +curl -fsSL -o "$SKILL_DATA_PATH" "$SKILL_URL" 2>/dev/null && \ + echo " ${SKILL_DATA_PATH}" || true + +# Configure Claude Code PreToolUse + SessionStart hooks +configure_claude_hook() { + local settings_file="${HOME}/.claude/settings.json" + local hook_command="$HOOK_PATH" + local loader_command="$LOADER_PATH" + + if [ ! -f "$settings_file" ]; then + mkdir -p "$(dirname "$settings_file")" + cat > "$settings_file" << CLAUDEEOF +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${hook_command}", + "timeout": 5 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${loader_command}", + "timeout": 5 + } + ] + } + ] + } +} +CLAUDEEOF + echo " ✓ Claude Code — created ${settings_file}" + elif command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys + +path = '${settings_file}' +hook_cmd = '${hook_command}' +loader_cmd = '${loader_command}' + +with open(path) as f: + data = json.load(f) + +hooks = data.setdefault('hooks', {}) + +# --- PreToolUse: devcontainer-guard --- +pre_tool = hooks.setdefault('PreToolUse', []) +already_guard = False +for group in pre_tool: + for h in group.get('hooks', []): + if 'devcontainer-guard' in h.get('command', ''): + already_guard = True + break + +if not already_guard: + pre_tool.append({ + 'matcher': 'Bash', + 'hooks': [{ + 'type': 'command', + 'command': hook_cmd, + 'timeout': 5 + }] + }) + print(' ✓ Claude Code — added PreToolUse hook to ${settings_file}') +else: + print(' ✓ Claude Code — PreToolUse hook already configured') + +# --- SessionStart: skill-loader --- +session_start = hooks.setdefault('SessionStart', []) +already_loader = False +for group in session_start: + for h in group.get('hooks', []): + if 'skill-loader' in h.get('command', ''): + already_loader = True + break + +if not already_loader: + session_start.append({ + 'hooks': [{ + 'type': 'command', + 'command': loader_cmd, + 'timeout': 5 + }] + }) + print(' ✓ Claude Code — added SessionStart hook to ${settings_file}') +else: + print(' ✓ Claude Code — SessionStart hook already configured') + +with open(path, 'w') as f: + json.dump(data, f, indent=2) +" 2>/dev/null || echo " ⚠ Claude Code — could not update ${settings_file}" + else + echo " ⚠ Claude Code — exists but python3 not available to merge" + fi +} + +# Configure Copilot CLI preToolUse + sessionStart hooks +configure_copilot_hook() { + local hooks_dir="${HOME}/.copilot/hooks" + + mkdir -p "$hooks_dir" + + cat > "${hooks_dir}/devcontainer-guard.json" << COPILOTEOF +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "${HOOK_PATH}", + "timeoutSec": 5 + } + ] + } +} +COPILOTEOF + echo " ✓ Copilot CLI — created ${hooks_dir}/devcontainer-guard.json" + + cat > "${hooks_dir}/devcontainer-skill-loader.json" << COPILOTEOF +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "${LOADER_PATH}", + "timeoutSec": 5 + } + ] + } +} +COPILOTEOF + echo " ✓ Copilot CLI — created ${hooks_dir}/devcontainer-skill-loader.json" +} + +echo "" +echo "==> Configuring agent host-protection hooks..." +configure_claude_hook +configure_copilot_hook + # Detect available backends echo "" echo "Backend CLIs detected (install as needed — MCP server gives helpful errors if missing):" command -v devpod >/dev/null 2>&1 && echo " ✓ devpod" || echo " ✗ devpod — https://devpod.sh/docs/getting-started/install" -command -v devcontainer >/dev/null 2>&1 && echo " ✓ devcontainer" || echo " ✗ devcontainer — https://github.com/devcontainers/cli#install-script" + +# devcontainer CLI: auto-install via the official standalone installer +# (bundles its own Node.js runtime — no system Node required) into a +# folder we own, then symlink the binary into INSTALL_DIR so it picks +# up the same PATH guidance the script gives for the main binary. +install_devcontainer_cli() { + local cli_prefix="${HOME}/.local/share/devcontainer-mcp/devcontainers" + local installer_url="https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh" + echo " … devcontainer CLI not found — installing standalone bundle into ${cli_prefix}" + mkdir -p "$(dirname "$cli_prefix")" + if ! curl -fsSL "$installer_url" | sh -s -- --prefix "$cli_prefix" >/dev/null 2>&1; then + echo " ✗ devcontainer — standalone install failed; see https://github.com/devcontainers/cli#install-script" + return 1 + fi + local shim="${cli_prefix}/bin/devcontainer" + if [ ! -x "$shim" ]; then + echo " ✗ devcontainer — install completed but ${shim} is missing" + return 1 + fi + ln -sf "$shim" "${INSTALL_DIR}/devcontainer" + echo " ✓ devcontainer (auto-installed → ${INSTALL_DIR}/devcontainer)" +} + +if command -v devcontainer >/dev/null 2>&1; then + echo " ✓ devcontainer" +else + install_devcontainer_cli || true +fi + command -v gh >/dev/null 2>&1 && echo " ✓ gh (codespaces)" || echo " ✗ gh (codespaces) — https://cli.github.com/" # --------------------------------------------------------------------------- @@ -172,6 +379,51 @@ else: fi } +# opencode uses a different config schema (mcp key, type: "local", combined +# command array, enabled: true) and a different file path. +configure_opencode_mcp() { + local config_file="${HOME}/.config/opencode/opencode.json" + local bin_path="${INSTALL_DIR}/devcontainer-mcp" + + if [ ! -f "$config_file" ]; then + mkdir -p "$(dirname "$config_file")" + cat > "$config_file" << OPENCODEEOF +{ + "\$schema": "https://opencode.ai/config.json", + "mcp": { + "devcontainer-mcp": { + "type": "local", + "command": ["${bin_path}", "serve"], + "enabled": true + } + } +} +OPENCODEEOF + echo " ✓ opencode — created ${config_file}" + elif command -v python3 >/dev/null 2>&1; then + python3 -c " +import json +path = '${config_file}' +with open(path) as f: + data = json.load(f) +servers = data.setdefault('mcp', {}) +if 'devcontainer-mcp' not in servers: + servers['devcontainer-mcp'] = { + 'type': 'local', + 'command': ['${bin_path}', 'serve'], + 'enabled': True, + } + with open(path, 'w') as f: + json.dump(data, f, indent=2) + print(' ✓ opencode — added to ${config_file}') +else: + print(' ✓ opencode — already configured') +" 2>/dev/null || echo " ⚠ opencode — could not update ${config_file}" + else + echo " ⚠ opencode — exists but python3 not available to merge" + fi +} + echo "" echo "==> Configuring MCP clients..." @@ -198,5 +450,8 @@ fi # Claude Code — ~/.claude.json configure_mcp_client "${HOME}/.claude.json" "Claude Code" +# opencode — ~/.config/opencode/opencode.json (uses different schema) +configure_opencode_mcp + echo "" echo "Done! devcontainer-mcp is ready to use." diff --git a/server.json b/server.json new file mode 100644 index 0000000..116fe95 --- /dev/null +++ b/server.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.aniongithub/devcontainer-mcp", + "title": "devcontainer-mcp", + "description": "Manage dev container environments via MCP (Docker, DevPod, Codespaces).", + "repository": { + "url": "https://github.com/aniongithub/devcontainer-mcp", + "source": "github" + }, + "version": "0.0.0", + "packages": [ + { + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/devcontainer-mcp/releases/download/v0.0.0/devcontainer-mcp-linux-x64.tar.gz", + "fileSha256": "PLACEHOLDER", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/devcontainer-mcp/releases/download/v0.0.0/devcontainer-mcp-linux-arm64.tar.gz", + "fileSha256": "PLACEHOLDER", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/devcontainer-mcp/releases/download/v0.0.0/devcontainer-mcp-darwin-x64.tar.gz", + "fileSha256": "PLACEHOLDER", + "transport": { "type": "stdio" } + }, + { + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/devcontainer-mcp/releases/download/v0.0.0/devcontainer-mcp-darwin-arm64.tar.gz", + "fileSha256": "PLACEHOLDER", + "transport": { "type": "stdio" } + } + ] +} diff --git a/skills/footer.md b/skills/footer.md index 285c808..24a0570 100644 --- a/skills/footer.md +++ b/skills/footer.md @@ -12,3 +12,5 @@ order: 90 - ✅ DO ask the user which account/machine type to use - ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything - ✅ DO check `.devcontainer/devcontainer.json` first + +> **Note:** Host-protection hooks are installed for supported agent environments (Claude Code, GitHub Copilot CLI) that automatically block shell commands when a devcontainer is detected. If a command is blocked, use the appropriate MCP tool instead.