agentleFS
stacklokstacklok/toolhive-studio/.claude/skills/devcontainer-dev/SKILL.md

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.

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
  1. Very specific:

    If executed on the host: runs devcontainer up to build/start the container, then devcontainer exec to run the entrypoint inside it.

    line 23
  2. Very specific:

    If executed inside a container (detected via /.dockerenv): skips the build step and just runs the entrypoint directly.

    line 24
  3. Distinctive:

    If you're piping the output:

    line 59
  4. Distinctive:

    Only once all three are true does the banner fire and the host's browser auto-open.

    line 73
  5. Distinctive:

    If the app gets wedged, the entrypoint's first action on every run is to pkill all the usual suspects.

    line 129
  6. Very specific:

    All commands run via docker exec against the container with DISPLAY=:99 set (matches Xvfb's display).

    line 145
  7. Distinctive:

    If you're modifying the startup flags, keep these.

    line 228
  8. Very specific:

    Not a guide for the standard non-container dev workflow.

    line 274
  9. Very specific:

    Not a guide for packaging or e2e tests.

    line 275
  10. Distinctive:

    Not a general Docker / devcontainer tutorial.

    line 276

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:dev

runs scripts/devcontainer-dev.sh on the host. The script is "smart":

  • If executed on the host: runs devcontainer up to build/start the container, then devcontainer exec to 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

ScriptRuns onPurpose
scripts/devcontainer-dev.shhostPicks a host port, kills stale processes, starts readiness poller, opens browser when ready, devcontainer execs the entrypoint.
scripts/devcontainer-entrypoint.shin containerCleans stale X/VNC state, starts Xvfb, fluxbox, x11vnc, websockify/noVNC, dbus, gnome-keyring, then runs pnpm start.
scripts/devcontainer-post-start.shin containerpostStartCommand 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:2 devcontainer feature. Supplies /var/run/docker.sock inside the container so the bundled thv CLI 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:

  1. Written to a file by the launcher: ~/.cache/toolhive-studio-url. Survives any amount of output.
    cat ~/.cache/toolhive-studio-url
  2. Set as the terminal tab title via OSC escape. Visible in the tab bar of most terminals regardless of scrollback state.
  3. 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.log

The 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 serve is running (matched via pgrep -f 'thv serve' — not pgrep -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 :6080 first 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 with docker port "$CONTAINER_ID" 6080/tcp and 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 ps

Log files (written by the entrypoint)

PathContents
/tmp/xvfb.logXvfb startup and runtime errors
/tmp/fluxbox.logWindow manager
/tmp/x11vnc.logVNC server — includes client connection events
/tmp/websockify.lognoVNC WebSocket proxy
/tmp/keyring.loggnome-keyring-daemon unlock output
/tmp/entrypoint.logOutput 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.png

import 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_tables

Persist across reboots:

echo -e "iptable_nat\niptable_filter\nip_tables" | sudo tee /etc/modules-load.d/docker.conf

A 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:

  • runArgs has --shm-size=2g to grow the shared memory
  • The entrypoint launches Electron with --disable-dev-shm-usage so Chromium falls back to /tmp anyway

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-container

Codespaces specifics

  • forwardPorts: [6080] exposes noVNC via the Codespaces tunnel (HTTPS).
  • portsAttributes."6080".onAutoForward: "openPreview" makes the Simple Browser preview pane open automatically.
  • postStartCommand runs scripts/devcontainer-post-start.sh, which (when $CODESPACES is 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_PORT env var, etc.) is a no-op here — forwardPorts is 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 start running directly on the host, follow the top-level CLAUDE.md instructions, not this skill.
  • Not a guide for packaging or e2e tests. pnpm make / pnpm e2e have their own workflows.
  • Not a general Docker / devcontainer tutorial. It only covers this project's specific setup.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.