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 d90e382..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
@@ -89,25 +92,48 @@ jobs:
install.sh
install.ps1
- build-windows:
- name: Build devcontainer-mcp-windows-x64
- runs-on: windows-latest
+ 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
- - uses: dtolnay/rust-toolchain@stable
- with:
- targets: x86_64-pc-windows-msvc
- - uses: Swatinem/rust-cache@v2
- with:
- key: x86_64-pc-windows-msvc
- - name: Build
- run: cargo build --release --target x86_64-pc-windows-msvc -p devcontainer-mcp
+ - 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: Package binary
- run: Compress-Archive -Path target/x86_64-pc-windows-msvc/release/devcontainer-mcp.exe -DestinationPath devcontainer-mcp-windows-x64.zip
+ - name: Authenticate to MCP Registry (OIDC)
+ run: ./mcp-publisher login github-oidc
- - name: Upload release asset
- uses: softprops/action-gh-release@v3
- with:
- files: devcontainer-mcp-windows-x64.zip
+ - 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
[](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml)
+[](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.
+
+
+
+
+> 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
+
+
+
+
### Linux / macOS
```bash
@@ -79,6 +91,10 @@ graph TD
## Three Backends, One Interface
+
+
+
+
| 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 3842368..9e09880 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -60,7 +60,7 @@ You have access to `devcontainer-mcp`, an MCP server that manages dev container
**If a project has `.devcontainer/devcontainer.json`, ALL work MUST happen inside a dev container — never install dependencies, run builds, or execute code directly on the host.**
-**Use ONLY the MCP tools listed here.** Do not invoke `docker`, `devcontainer`, `devpod`, `gh`, or `wsl` CLI commands directly — the MCP tools wrap these CLIs with proper error handling, auth resolution, and escaping. Direct CLI usage bypasses these safeguards. This applies even when the user asks to work "directly in WSL" or "not in a devcontainer" — use `wsl_exec` and WSL file tools instead of raw `wsl` commands.
+**Use ONLY the MCP tools listed here.** Do not invoke `docker`, `devcontainer`, `devpod`, or `gh` CLI commands directly — the MCP tools wrap these CLIs with proper error handling, auth resolution, and escaping. Direct CLI usage bypasses these safeguards.
## Authentication
@@ -179,12 +179,14 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors:
- ❌ Do NOT install packages on the host
- ❌ Do NOT run builds on the host
- ❌ Do NOT modify the host's global config
-- ❌ Do NOT run `docker`, `devcontainer`, `devpod`, `gh`, or `wsl` CLI commands directly — use the MCP tools
+- ❌ Do NOT run `docker`, `devcontainer`, `devpod`, or `gh` CLI commands directly — use the MCP tools
- ✅ DO authenticate before using codespaces tools
- ✅ 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.
+
## 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 13b1457..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};
@@ -29,9 +32,6 @@ pub enum CliBinary {
Gcloud,
/// Kubernetes CLI
Kubectl,
- #[cfg(target_os = "windows")]
- /// Windows Subsystem for Linux
- Wsl,
}
impl CliBinary {
@@ -44,8 +44,6 @@ impl CliBinary {
CliBinary::Aws => "aws",
CliBinary::Gcloud => "gcloud",
CliBinary::Kubectl => "kubectl",
- #[cfg(target_os = "windows")]
- CliBinary::Wsl => "wsl",
}
}
@@ -58,14 +56,42 @@ impl CliBinary {
CliBinary::Aws => Error::AwsCliNotFound,
CliBinary::Gcloud => Error::GcloudCliNotFound,
CliBinary::Kubectl => Error::KubectlNotFound,
- #[cfg(target_os = "windows")]
- CliBinary::Wsl => Error::WslNotFound,
}
}
}
+/// 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
}
@@ -76,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 {
@@ -86,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 {
@@ -94,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