devcontainer-dev
Skill173 starsChanged 5 months agoApache-2.0View on GitHub
Spin up and interact with ToolHive Studio's containerized dev environment (Xvfb + noVNC + DinD). Use when running, testing, or debugging the app in isolation — locally, in a git worktree, or in GitHub Codespaces; when touching `.devcontainer/*`, `scripts/devcontainer-*.sh`, or the `devContainer:dev` npm script; or when debugging "blank white window", "Docker daemon failed to start", or "Missing X server" errors in the devcontainer. The container is fully isolated: no host pnpm install, no host Docker socket, no host X11/GPU — experiment freely without contaminating the host.
In this repository20 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- .github/copilot-instructions.mdCopilot instructions
- bug-fix-tddSkill
- deep-linksSkill
- devcontainer-devSkill
- security-vuln-remediationSkill
- skill-creatorSkill
- skill-editorSkill
- testing-api-assertionsSkill
- testing-api-overridesSkill
- testing-with-api-mocksSkill
- bug-fix-tddSkill
- deep-linksSkill
- security-vuln-remediationSkill
- skill-creatorSkill
- skill-editorSkill
- testing-api-assertionsSkill
- testing-api-overridesSkill
- testing-with-api-mocksSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 5 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 23
If executed on the host: runs devcontainer up to build/start the container, then devcontainer exec to run the entrypoint inside it.
- Very specific: line 24
If executed inside a container (detected via /.dockerenv): skips the build step and just runs the entrypoint directly.
- Distinctive: line 59
If you're piping the output:
- Distinctive: line 73
Only once all three are true does the banner fire and the host's browser auto-open.
- Distinctive: line 129
If the app gets wedged, the entrypoint's first action on every run is to pkill all the usual suspects.
- Very specific: line 145
All commands run via docker exec against the container with DISPLAY=:99 set (matches Xvfb's display).
- Distinctive: line 228
If you're modifying the startup flags, keep these.
- Very specific: line 274
Not a guide for the standard non-container dev workflow.
- Very specific: line 275
Not a guide for packaging or e2e tests.
- Distinctive: line 276
Not a general Docker / devcontainer tutorial.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Containerized Dev Environment
An isolated, cross-platform test environment for ToolHive Studio. The whole Electron app — including its backend thv binary and the MCP-server containers it spawns — runs inside a single devcontainer. You interact with the UI via a noVNC browser tab.
The entire stack (Node, Electron, display server, window manager, VNC server, Docker-in-Docker, DBus, keyring) lives in the container. Nothing is installed on the host. That's the whole point: every worktree can have its own container and its own experiments, with zero risk of contaminating the user's global installs.
Entry point
pnpm devContainer:devruns scripts/devcontainer-dev.sh on the host. The script is "smart":
- If executed on the host: runs
devcontainer upto build/start the container, thendevcontainer execto run the entrypoint inside it. - If executed inside a container (detected via
/.dockerenv): skips the build step and just runs the entrypoint directly. This path is used by GitHub Codespaces.
The three scripts
| Script | Runs on | Purpose |
scripts/devcontainer-dev.sh | host | Picks a host port, kills stale processes, starts readiness poller, opens browser when ready, devcontainer execs the entrypoint. |
scripts/devcontainer-entrypoint.sh | in container | Cleans stale X/VNC state, starts Xvfb, fluxbox, x11vnc, websockify/noVNC, dbus, gnome-keyring, then runs pnpm start. |
scripts/devcontainer-post-start.sh | in container | postStartCommand in devcontainer.json. In Codespaces (detected via $CODESPACES) it nohup-launches the entrypoint in the background so the noVNC preview pane opens without user action. |
What the container runs
- Node 24 (matches
.nvmrc) - Electron + runtime deps — GTK, NSS, X11, etc. (see
.devcontainer/Dockerfile) - Display stack — Xvfb (virtual framebuffer at 1920×1200), fluxbox (window manager, auto-fullscreens windows with
WM_CLASS=ToolHive), x11vnc (VNC server), noVNC + websockify (browser client on port 6080) - Secret provider stack — dbus + gnome-keyring. Required by ToolHive's secret API; without them the backend returns 500 on secrets endpoints. Mirrors the setup in
.github/workflows/_e2e.yml. - Docker-in-Docker via the
docker-in-docker:2devcontainer feature. Supplies/var/run/docker.sockinside the container so the bundledthvCLI can spawn MCP-server containers.
Finding the URL (the logs are very long)
pnpm start + electron-forge + Vite + Electron + HMR produce a lot of output. The terminal scrollback often exhausts. Three recovery mechanisms are built in:
- Written to a file by the launcher:
~/.cache/toolhive-studio-url. Survives any amount of output.cat ~/.cache/toolhive-studio-url - Set as the terminal tab title via OSC escape. Visible in the tab bar of most terminals regardless of scrollback state.
- Prominent banners in the output — a green initial block right after
devcontainer up, plus an inverse-video✓ ToolHive ready — <URL>banner that fires only once the app is actually usable.
If you're piping the output:
pnpm devContainer:dev 2>&1 | tee /tmp/dev.log
# later:
grep -E 'ToolHive ready|vnc\.html' /tmp/dev.logThe readiness banner is what you care about. It gates on three signals simultaneously:
- noVNC's HTTP endpoint answers (the browser tab will actually load)
- The Electron binary is running (matched via
pgrep -f 'electron/dist/electron') thv serveis running (matched viapgrep -f 'thv serve'— notpgrep -x thv, because the short-lived version-check invocation also matches on bare name)
Only once all three are true does the banner fire and the host's browser auto-open.
Per-worktree isolation
Each git worktree gets its own independent devcontainer:
- Container identity — labelled with
devcontainer.local_folder=<absolute-worktree-path>. The devcontainer CLI uses this to decide which container to reuse vs create fresh. - Node modules — volume named
toolhive-node-modules-<basename>, scoped to the worktree's basename. No cross-worktree install pollution. - Host port — the primary clone uses
:6080; additional worktrees try:6080first and fall back to a Docker-assigned random port if it's taken. So multiple worktrees can run simultaneously. The actual bound port is queried withdocker port "$CONTAINER_ID" 6080/tcpand the URL is generated from that. - DinD, display state, keyring, etc. — all container-local. Tearing down a worktree's container removes all of it.
The host is never touched — no host-side pnpm install, no host-side /tmp/.X11-unix mount, no host-side Docker socket passthrough, no host GPU. Everything the app needs is inside the container. Even the NVIDIA driver on the host is unreachable from inside — the container uses CPU software rendering.
Interacting with a running container
Find the container ID for a given worktree:
WORKDIR="$(pwd)" # or a specific worktree path
CONTAINER=$(docker ps --filter "label=devcontainer.local_folder=$WORKDIR" --format '{{.ID}}' | head -1)Common operations:
# Shell in
docker exec -it "$CONTAINER" bash
# Run as the devcontainer user (usually correct for pnpm / thv commands)
docker exec -u node "$CONTAINER" pnpm test
# One-off inspection
docker exec "$CONTAINER" ps auxf
# DinD health
docker exec "$CONTAINER" docker info
docker exec "$CONTAINER" docker psLog files (written by the entrypoint)
| Path | Contents |
/tmp/xvfb.log | Xvfb startup and runtime errors |
/tmp/fluxbox.log | Window manager |
/tmp/x11vnc.log | VNC server — includes client connection events |
/tmp/websockify.log | noVNC WebSocket proxy |
/tmp/keyring.log | gnome-keyring-daemon unlock output |
/tmp/entrypoint.log | Output of the Codespaces auto-launch entrypoint (only exists in Codespaces) |
Killing stale state
If the app gets wedged, the entrypoint's first action on every run is to pkill all the usual suspects. To do it manually:
docker exec "$CONTAINER" bash -c '
pkill -f "electron/dist/electron"; pkill -f "thv serve"
pkill -f Xvfb; pkill -f fluxbox; pkill -f x11vnc; pkill -f websockify
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
'Driving the app as an agent
The container has enough tools for an AI agent to both see and interact with the running Electron window without going through the browser / noVNC. Useful for headless testing, reproducing user-reported UI bugs, or validating a feature end-to-end.
All commands run via docker exec against the container with DISPLAY=:99 set (matches Xvfb's display).
See the screen (screenshots)
# Take a PNG of the whole virtual framebuffer
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 import -window root /tmp/shot.png'
# Copy it to the host for viewing / feeding to a vision model
docker cp "$CONTAINER:/tmp/shot.png" /tmp/shot.pngimport is from ImageMagick. For a specific window only, use xwininfo to get the WID then import -window <WID>.
See the window tree (what's there, where, which is focused)
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xwininfo -root -tree'
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xdotool getactivewindow getwindowname'
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xdotool search --name ToolHive'The main app window has WM_CLASS=ToolHive (see gotcha below). Its geometry is usually 1920x1200+0+0 once fluxbox has auto-fullscreened it.
Interact with the UI (mouse, keyboard)
# Move the mouse and click
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xdotool mousemove 400 300 click 1'
# Type into whatever has focus
docker exec "$CONTAINER" bash -c "DISPLAY=:99 xdotool type --delay 20 'hello world'"
# Send a keystroke
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xdotool key ctrl+shift+i' # DevTools
# Focus the main window by name
docker exec "$CONTAINER" bash -c 'DISPLAY=:99 xdotool search --name ToolHive windowactivate'Typical agent loop
Screenshot → feed to vision model → decide next action → xdotool → screenshot again. The usual caveats apply: pixel-coordinate automation is fragile, and CSS changes or modal dialogs can throw it off. For robust long-term automation, prefer a DOM-level approach (e.g. Chrome DevTools Protocol against Electron's remote debugging port — not currently wired in, see Future: CDP access).
Future: CDP access
For DOM-level agent automation (querying, clicking specific elements by selector, scraping state), the cleanest path is Chromium's DevTools Protocol. Opt-in: pass --remote-debugging-port=9223 to Electron in the entrypoint, add -p 9223:9223 to the container's runArgs, and the agent connects via ws://localhost:9223/devtools/page/<id>. Not currently enabled by default; add it when you need it.
Gotchas
Docker daemon won't start (Linux, non-stock kernels)
Symptom: (*) Failed to start docker, retrying... in a loop; thv has no runtime; dockerd.log contains:
failed to create NAT chain DOCKER: iptables failed: ...
can't initialize iptables table `nat': Table does not exist (do you need to insmod?)Seen on zen, hardened, XanMod, and other non-stock Linux kernels that don't autoload iptables modules. Fix (on the host):
sudo modprobe iptable_nat iptable_filter ip_tablesPersist across reboots:
echo -e "iptable_nat\niptable_filter\nip_tables" | sudo tee /etc/modules-load.d/docker.confA complete explanation is in a comment block in .devcontainer/devcontainer.json directly above the docker-in-docker feature declaration. Mac, Windows/WSL2, Codespaces, and stock Ubuntu/Debian/Fedora are unaffected.
Electron renders a blank white page
Root cause: Docker's default /dev/shm is 64 MB, too small for Chromium's compositor buffers; it fails silently and paints nothing.
This is already fixed in the setup:
runArgshas--shm-size=2gto grow the shared memory- The entrypoint launches Electron with
--disable-dev-shm-usageso Chromium falls back to/tmpanyway
If you're modifying the startup flags, keep these.
Fluxbox fullscreen rule doesn't match
The Electron main window's WM_CLASS is ToolHive, not Electron. The ~/.fluxbox/apps file the entrypoint generates targets (class=ToolHive) for that reason. The Electron-classed windows that show up in xwininfo are tiny 16×16 internal helper windows — ignore them.
Readiness false-positive (transient processes)
pgrep -x thv would match the short-lived thv version / thv --version invocation that Electron runs during startup to verify the binary is present. The long-running backend is always thv serve --openapi ... --port=N, so use pgrep -f "thv serve" to gate on that specifically.
Readiness false-positive (pgrep self-match)
When using pgrep -f PATTERN from a shell command (e.g. bash -c 'pgrep -f "thv serve"'), the pattern string appears verbatim in the invoking shell's argv — and pgrep -f matches against the full argv of every process. So pgrep matches its own parent shell and always returns true, even when nothing is actually running the target process. Use the standard bracket-class trick: pgrep -f '[t]hv serve'. The regex character class [t] matches t literally, so it still matches thv serve, but the literal string [t]hv serve in the invoking shell's argv doesn't match the regex. See scripts/devcontainer-dev.sh and .devcontainer/greeting.sh for the pattern in use.
Secret provider returns 500
ToolHive's secret API needs DBus + gnome-keyring. The Dockerfile installs dbus, dbus-x11, gnome-keyring, libsecret-1-0, libsecret-1-dev, and the entrypoint starts dbus-launch and unlocks the keyring with gnome-keyring-daemon --unlock --components=secrets,ssh,pkcs11. The exact setup mirrors .github/workflows/_e2e.yml — if it breaks, cross-reference there.
Rebuilding the container
Most script changes don't require a rebuild (scripts are bind-mounted). A rebuild is only needed when you change:
.devcontainer/Dockerfile(apt packages).devcontainer/devcontainer.json→runArgs,features,mounts
To force a rebuild:
npx --yes @devcontainers/cli up --workspace-folder . --remove-existing-containerCodespaces specifics
forwardPorts: [6080]exposes noVNC via the Codespaces tunnel (HTTPS).portsAttributes."6080".onAutoForward: "openPreview"makes the Simple Browser preview pane open automatically.postStartCommandrunsscripts/devcontainer-post-start.sh, which (when$CODESPACESis set) nohup-launches the entrypoint in the background. So opening a Codespace gets you a running app with no terminal commands.- The on-host port-picking logic (
NOVNC_HOST_PORTenv var, etc.) is a no-op here —forwardPortsis the only mechanism that matters for the user-facing URL.
What this skill is NOT
- Not a guide for the standard non-container dev workflow. If you want
pnpm startrunning directly on the host, follow the top-levelCLAUDE.mdinstructions, not this skill. - Not a guide for packaging or e2e tests.
pnpm make/pnpm e2ehave their own workflows. - Not a general Docker / devcontainer tutorial. It only covers this project's specific setup.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

