diff --git a/.devcontainer/mnemon/seed.json b/.devcontainer/mnemon/seed.json index 4581885..b9f701d 100644 --- a/.devcontainer/mnemon/seed.json +++ b/.devcontainer/mnemon/seed.json @@ -606,6 +606,26 @@ "graph-data.js" ], "source": "agent" + }, + { + "content": "docker-test-shell skill at .devcontainer/skills/docker-test-shell/ provides the `dts` primitive for testing any tool in a clean throwaway ubuntu:24.04 container. Key commands: `dts up` (fresh container, repo at /src, uid-1000), `dts exec \"\"` (run as uid-1000, bash -l, -i no -t), `dts apt \"\"` (root apt installs), `dts shell` (interactive -it), `dts clean` (remove container), `dts status` (state). Verified end-to-end with OmniRoute 3.8.50: dts up -> dts apt xz-utils g++ make -> dts exec install.sh -> dts exec hermes chat -q works via auto-fastest combo. Golden rules: never trust host, -i not -it when scripted, bash -l login shell, uid-1000 parity, apt via dts apt not exec, md5 verify mount, always dts clean. Root vs uid-1000: test commands always uid-1000 (CI parity); root only for apt (dts apt) or explicit inspection (docker exec -u 0:0).", + "category": "insight", + "importance": 4, + "entities": [ + "docker-test-shell", + "dts", + "omniroute", + "devcontainer", + "skill" + ], + "tags": [ + "skill", + "testing", + "docker", + "standalone", + "codespace" + ], + "source": "agent" } ] } \ No newline at end of file diff --git a/.devcontainer/skills/docker-test-shell/SKILL.md b/.devcontainer/skills/docker-test-shell/SKILL.md new file mode 100644 index 0000000..86c368b --- /dev/null +++ b/.devcontainer/skills/docker-test-shell/SKILL.md @@ -0,0 +1,125 @@ +--- +name: docker-test-shell +description: Use when testing in a clean throwaway container. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux] +--- + +# Docker Test Shell (`dts`) + +Test ANY tool inside a fresh, throwaway Linux container — never trust the host. + +## The one concept + +> Test inside a throwaway container so your own environment's installed libs, stale state, and services do not pollute the test. + +That is the entire tool. No test framework, no schema, no verify primitives. Just: get me into a clean container with my repo mounted in, and let me run commands. Target logic is PLAIN SHELL composed of `dts exec` + bash checks. + +## Quickstart + +```bash +# from the repo you want to test (it gets bind-mounted at /src) +./scripts/dts.sh up +./scripts/dts.sh exec "bash /src/install.sh" +./scripts/dts.sh exec "my-tool --version" +./scripts/dts.sh clean +``` + +For convenience, add the script dir to PATH or alias `dts`: +```bash +export PATH="$PWD/scripts:$PATH" # or symlink dts.sh -> /usr/local/bin/dts +``` + +## Commands + +| Command | Job | +|---------|-----| +| `dts up` | Create container (fresh base, repo bind-mounted at `/src`, uid-1000 user). Installs NOTHING. | +| `dts exec ""` | Run `` inside the container as the uid-1000 user, `bash -l`. TTY-aware. | +| `dts apt ""` | Run `apt-get update && install -y ` as ROOT. The one privileged path (apt needs root; normal user can't). | +| `dts shell` | Drop into an interactive shell (`-it`). For a human at a terminal. | +| `dts clean` | Remove the container (no orphans). | +| `dts status` | Show container state + note the mount is present. | + +## When to use `dts exec` vs raw `docker exec` + +| Scenario | Use | Why | Notes | +|----------|-----|-----|-------| +| Normal test commands (build, version check, API call) | `dts exec` | Runs as uid-1000, `bash -l`, `-i` no `-t` — matches CI/Codespace parity | | +| Install apt packages (`apt-get install`) | `dts apt` | Dedicated root path; apt needs root, uid-1000 can't | | +| Debug interactively (human at terminal) | `dts shell` | `-it` gives full interactive TTY | | +| One-off root inspection (logs, sqlite, config files) | `docker exec -u 0:0 dts-test ` | Root-only operations outside apt; explicit `docker exec -u 0:0` signals intent | | +| Multi-step stateful commands from a script | `dts exec` with piped stdin | `printf 'cmd1\ncmd2\n' \| dts exec bash` — stdin stays open, runs sequentially | | + +**Rule of thumb:** `dts exec` = the test harness path (uid-1000, parity). Raw `docker exec -u 0:0` = the escape hatch for root-only ops that aren't apt installs (e.g. reading sqlite DBs, checking process ports, killing stuck processes). Don't run test commands as root — it masks permission bugs that would fail in CI/Codespace. + +## Config + +- **Base image**: configurable via `IMAGE`, defaults to `ubuntu:24.04`: + ```bash + IMAGE=debian:12 dts up + IMAGE=node:22 dts up + ``` +- **Container name**: `CONTAINER` (default `dts-test`). +- **Mount target**: always `/src`. Static, predictable. +- **uid/gid**: `CONTAINER_UID`/`CONTAINER_GID` (default 1000). + +## The golden rules (each verified by live testing) + +1. **Never trust the host.** Always repro in the container. The host has stale packages, leftover venvs, running services — a passing host test proves nothing about a fresh install. +2. **Drive with `docker exec -i`, never `-it`, when scripted.** `-it` fails with "the input device is not a TTY" when stdin is piped (an agent's normal mode). `-i` keeps stdin open, runs scripted AND stateful REPL-style commands fine. Reserve `-it` for a real human typing. +3. **Use `bash -l` (login shell).** Plain `bash -c` is non-interactive and doesn't source `.profile`. Login shell gives host/CI parity. +4. **Run as uid 1000, never root.** CI runner + Codespaces both run uid 1000; root masks permission bugs. `dts` does this automatically. +5. **Install apt packages with `dts apt`, never `dts exec`** — apt (and other package managers) need root, but the container's normal user runs as uid 1000 and can't. Use the dedicated root path: + ```bash + dts apt "curl g++ make" # apt-get update && install -y, as root + ``` + `dts exec` is the uid-1000 path for the actual test commands. Different targets need different prereqs; keep the tool minimal and make the choice explicit per test. +6. **Verify the mount with md5, not just listing.** Listing can lie; md5 proves same file (host write appears in container, container write appears on host, file md5-identical both sides). +7. **Clean up after yourself.** No orphan containers. `dts clean`. + +## Verification is plain shell + +There are no `verify` subcommands. Check exit codes, grep output, curl endpoints: + +```bash +dts exec "hermes --version" && echo "install OK" +dts exec "curl -sf http://127.0.0.1:20128/healthz && echo server-up" +``` + +## Non-goals + +- No test-definition / YAML schema (target logic = a short bash script). +- No `verify` subcommands. +- No Dockerfile / image build (bind-mount + base image, no rebuild cycle). +- No persistent SSH-like daemon (`docker exec -i` + piped stdin reproduces a stateful session). +- No bundled apt prereqs. + +## How to teach/perpetuate this + +1. **Never trust the host** — always repro in a fresh container. +2. **Learn the target iteratively** in `shell` mode (or one-off `exec`): poke, observe, confirm, then codify. +3. **Codify as a short bash script** of `dts exec` + checks — repeatable, and arguably CI-run-able. +4. **Record each pitfall** with root cause + fix (as references/ here or a wiki article). + +## Pitfalls table (hard-won) + +| Pitfall | Root cause | Fix | +|---------|-----------|-----| +| "input device is not a TTY" when scripted | `docker exec -it` needs a host PTY | Use `-i` (no `-t`) when stdin is piped; `-it` only for a real human | +| No `.profile` sourced | `bash -c` is non-interactive | Use `bash -l` (login shell) | +| Permission bugs masked | Tested as root | Always run as uid 1000 (CI/Codespace parity) | +| Mount "works" but file is stale | Bind-mount created once; container cached | Verify with md5 both sides; never trust a listing | +| Orphan containers eat disk | No cleanup | `dts clean` after each session | + +## Support files + +- `scripts/dts.sh` — the tool itself (up/exec/shell/clean/status). +- `references/dts-usage.md` — worked examples + how the golden rules were validated. + +## Related + +- Wiki: [docker-test-shell-proposal.md](../../wiki/docker-test-shell-proposal.md) — full design rationale. +- This skill replaces the project-specific `minions-docker-testing` approach with a standalone primitive. diff --git a/.devcontainer/skills/docker-test-shell/references/dts-usage.md b/.devcontainer/skills/docker-test-shell/references/dts-usage.md new file mode 100644 index 0000000..2b5f565 --- /dev/null +++ b/.devcontainer/skills/docker-test-shell/references/dts-usage.md @@ -0,0 +1,66 @@ +# dts usage & validation notes + +## Worked examples + +Test a `./install.sh` in a fresh container (e.g. `.minions` or any installer): + +```bash +cd /workspaces/your-repo +/path/to/dts.sh up +dts exec "bash /src/install.sh" +# if it needs apt packages, install one-off (as root): +dts apt "curl g++ make" +dts exec "./src/bin/my-tool --version" +# plain-shell verification: +dts exec "my-tool --version" && echo "install OK" +dts clean +``` + +Use a different base image: +```bash +IMAGE=debian:12 dts up +IMAGE=node:22 dts up +``` + +Interactive debugging (a human, has a terminal): +```bash +dts shell +``` + +## How the golden rules were validated (live) + +These were verified empirically in a real `ubuntu:24.04` container: + +1. **Host state lies** — the Codespace already has Node, a venv, hermes, stale configs, and running services; a host test passes while the fresh install fails. Repro inside the container instead. +2. **TTY** — `docker exec -it` fails with "the input device is not a TTY" when stdin is piped (an agent's normal mode); `docker exec -i` (no `-t`) runs both scripted one-shots AND stateful REPL-style piped input (`printf 'x=2\necho $((x*21))\nexit\n' | docker exec -i ...` -> 42). +3. **Login shell** — `bash -c` is non-interactive and doesn't source `.profile` (PATH etc.); `bash -l` sources it, matching CI. +4. **uid 1000** — CI runner and Codespace both run uid 1000; root masks permission errors that break the real install. `dts exec` runs as uid 1000. +5. **Root-only ops** — package managers (apt) need root; uid-1000 user can't run them. Use the dedicated `dts apt` root path for installs. +6. **Mount** — verified bidirectional: a file written on the host appears in the container and vice-versa; `md5sum` identical both sides (not just a listing). +7. **Cleanup** — orphan containers hold disk; always `dts clean`. + +## Pitfalls & conventions map + +| Topic | Convention | +|-------|-----------| +| Scripted exec | `-i`, never `-it` | +| Human shell | `-it` | +| User | uid 1000 (ubuntu in Ubuntu/Debian) | +| Mount | always `/src` | +| Base image | `IMAGE` env, default `ubuntu:24.04` | +| apt prereqs | manual one-off via `dts apt`, not bundled | +| Verification | plain shell (exit code, grep, curl) | + +## Root vs uid-1000: when to use which + +| Operation | Command | User | Note | +|-----------|---------|------|------| +| Test/build commands | `dts exec "..."` | uid-1000 | Default path — CI parity | +| apt install | `dts apt "pkg1 pkg2"` | root | Dedicated root path | +| Read sqlite DB | `docker exec -u 0:0 dts-test cat ...` | root | Root-only inspection | +| Check process ports | `docker exec -u 0:0 dts-test ss -tlnp` | root | Root-only | +| Kill stuck process | `docker exec -u 0:0 dts-test pkill ...` | root | Root-only | +| Interactive debug | `dts shell` | uid-1000 | Human, `-it` | +| Piped multi-step | `printf '...' | dts exec bash` | uid-1000 | Agent/script | + +**Key point:** Test commands always run as uid-1000. Root (`docker exec -u 0:0`) is an explicit escape hatch for ops that genuinely require it — never for the actual test logic, because running as root masks permission bugs that would fail in CI/Codespace. diff --git a/.devcontainer/skills/docker-test-shell/scripts/dts.sh b/.devcontainer/skills/docker-test-shell/scripts/dts.sh new file mode 100755 index 0000000..ffe37ee --- /dev/null +++ b/.devcontainer/skills/docker-test-shell/scripts/dts.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# dts.sh — Docker Test Shell. Run commands in a fresh, mounted, uid-1000 container. +# +# The whole point: test in a throwaway container so the host's installed libs, +# stale state, and services never pollute the test. +# +# Usage: +# dts up # fresh container, repo bind-mounted at /src (installs NOTHING) +# dts exec "" # run inside container as uid-1000 user, bash -l, TTY-aware +# dts apt "" # apt-get update + install as ROOT (the one privileged path) +# dts shell # interactive shell (-it) for a human at a terminal +# dts clean # remove the container +# dts status # show container state +# +# Config (env vars): +# IMAGE base image (default ubuntu:24.04) +# CONTAINER container name (default dts-test) +# CONTAINER_UID user uid (default 1000) +# CONTAINER_GID user gid (default 1000) + +set -euo pipefail + +IMAGE="${IMAGE:-ubuntu:24.04}" +CONTAINER="${CONTAINER:-dts-test}" +CONTAINER_UID="${CONTAINER_UID:-1000}" +CONTAINER_GID="${CONTAINER_GID:-1000}" +REPO_PATH="$(pwd)" +MOUNT="/src" + +# Resolve the uid-1000 user's home (Ubuntu/Debian: ubuntu; fallback: /home/vscode) +user_home() { + # Ask the container which user owns uid 1000; default to /home/ubuntu + docker exec "${CONTAINER}" bash -c "getent passwd ${CONTAINER_UID} | cut -d: -f6" 2>/dev/null \ + | tr -d '\n' || true + [ -n "${_HOME:-}" ] || echo /home/ubuntu +} + +home="/home/ubuntu" + +color() { [ -t 1 ] && printf '\033[1;34m%s\033[0m\n' "$*" || echo "$*"; } +log() { color "[dts] $*"; } + +docker_env_flags() { + printf -- '--user %s:%s -e HOME=%s' "${CONTAINER_UID}" "${CONTAINER_GID}" "${home}" +} + +cmd_up() { + if docker inspect "${CONTAINER}" >/dev/null 2>&1; then + log "container ${CONTAINER} exists; use 'exec', 'clean', or 'rm -f ${CONTAINER}' first" + exit 1 + fi + log "creating ${CONTAINER} from ${IMAGE} (mount ${REPO_PATH} -> ${MOUNT})" + docker run -d --name "${CONTAINER}" -v "${REPO_PATH}:${MOUNT}" "${IMAGE}" sleep infinity >/dev/null + sleep 1 + # Ensure uid-1000 user exists and owns the mount (Ubuntu/Debian: ubuntu user) + docker exec -u 0:0 "${CONTAINER}" bash -c " + id ${CONTAINER_UID} >/dev/null 2>&1 || useradd -m -u ${CONTAINER_UID} ubuntu 2>/dev/null || true + chown -R ${CONTAINER_UID}:${CONTAINER_GID} ${MOUNT} + [ -d \"${home}\" ] || (mkdir -p ${home} && chown ${CONTAINER_UID}:${CONTAINER_GID} ${home}) + " >/dev/null + home=$(docker exec "${CONTAINER}" bash -c "getent passwd ${CONTAINER_UID} | cut -d: -f6" | tr -d '\n') + log "container ready. home=${home}. Install packages via: dts apt '' (root)" + log "verify the mount: diff <(md5sum ) <(dts exec 'md5sum /src/')" +} + +cmd_exec() { + docker inspect "${CONTAINER}" >/dev/null 2>&1 || { log "container not up; run 'dts up' first"; exit 1; } + if [ "$#" -lt 1 ]; then + log "usage: dts exec " + exit 1 + fi + if [ -t 1 ]; then + # Host stdout is a TTY (a real human) -> allocate a PTY inside + docker exec -it $(docker_env_flags) "${CONTAINER}" bash -l -c "$*" + else + # Scripted/agent mode -> no -t (avoids 'input device is not a TTY'), keep stdin open + docker exec -i $(docker_env_flags) "${CONTAINER}" bash -l -c "$*" + fi +} + +# Privileged/root exec. apt-get (and other package managers) need root, but a +# normal-user container can't run them as uid 1000. This is the ONE root path. +cmd_apt() { + docker inspect "${CONTAINER}" >/dev/null 2>&1 || { log "container not up; run 'dts up' first"; exit 1; } + if [ "$#" -lt 1 ]; then + log "usage: dts apt ' ...' (runs apt-get update && install as root)" + exit 1 + fi + docker exec -u 0:0 -e DEBIAN_FRONTEND=noninteractive "${CONTAINER}" bash -c "apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq -o Dpkg::Options::='--force-confnew' $* " +} + +cmd_shell() { + docker inspect "${CONTAINER}" >/dev/null 2>&1 || { log "container not up; run 'dts up' first"; exit 1; } + docker exec -it $(docker_env_flags) "${CONTAINER}" bash -l +} + +cmd_clean() { + if docker inspect "${CONTAINER}" >/dev/null 2>&1; then + docker rm -f "${CONTAINER}" >/dev/null + log "removed ${CONTAINER}" + else + log "no container ${CONTAINER} to remove" + fi +} + +cmd_status() { + docker inspect "${CONTAINER}" >/dev/null 2>&1 || { log "container ${CONTAINER} not running"; exit 1; } + docker ps --filter "name=${CONTAINER}" --format '{{.Names}} {{.Image}} {{.Status}}' + log "mount: ${REPO_PATH} -> ${MOUNT}" +} + +help_() { + sed -n '2,20p' "$0" | sed 's/^#//' + exit 0 +} + +cmd="${1:-help}" +shift || true +case "${cmd}" in + up) cmd_up "$@" ;; + exec) cmd_exec "$@" ;; + apt) cmd_apt "$@" ;; + shell) cmd_shell "$@" ;; + clean) cmd_clean "$@" ;; + status) cmd_status "$@" ;; + help|-h|--help) help_ ;; + *) log "unknown command: ${cmd}"; help_ ;; +esac diff --git a/.devcontainer/wiki/INDEX.md b/.devcontainer/wiki/INDEX.md index 6cdb82f..0151d6d 100644 --- a/.devcontainer/wiki/INDEX.md +++ b/.devcontainer/wiki/INDEX.md @@ -26,6 +26,7 @@ | [persistent-knowledge.md](persistent-knowledge.md) | Persistent skills/knowledge in Codespace via symlinks — validated pattern and self-check wiring | persistence, symlink, codespace, knowledge, skill | | [vscode-cli-codespaces.md](vscode-cli-codespaces.md) | Auto-discover VS Code CLI in Codespaces and open files in connected editor | codespace, vscode, editor, cli, skill | | [ci-lint-check.md](ci-lint-check.md) | Pre-commit CI lint validation — run locally before push to avoid GitHub Actions failures | ci, lint, pre-commit, validation, github-actions, skill | +| [docker-test-shell-proposal.md](docker-test-shell-proposal.md) | Proposal: reusable `dts` tool to test anything in a fresh throwaway container — container isolation, `dts exec` primitive, no schema, plain shell target logic | docker, container, testing, isolation, proposal | | [codespace-webtop.md](codespace-webtop.md) | Native Selkies/XFCE webtop (browser desktop) via pixelflux-based selkies — architecture, WebSocket-only streaming, XFCE failsafe fix, port 3000 | selkies, xfce, webtop, desktop, websocket, codespace, browser-desktop, skill | | [selkies-package-discrepancy.md](selkies-package-discrepancy.md) | Critical: PyPI `selkies==1.6.1` is legacy GStreamer; correct pixelflux-based package is a GitHub Actions artifact | selkies, package, pixelflux, webrtc, gotcha | diff --git a/.devcontainer/wiki/docker-test-shell-proposal.md b/.devcontainer/wiki/docker-test-shell-proposal.md new file mode 100644 index 0000000..31531c8 --- /dev/null +++ b/.devcontainer/wiki/docker-test-shell-proposal.md @@ -0,0 +1,145 @@ +# Proposal: Docker Test Shell (`dts`) — Fresh-Environment Standalone Testing for Hermes Agent + +> **Status**: PROPOSED — design proposal for review, not yet implemented +> **Date**: 2026-09-07 +> **Goal**: Give Hermes (and future agents) one reusable way to test any tool in a *clean, throwaway Linux container* — so the working environment's installed libraries, stale config, and running services never mask or pollute the test. + +--- + +## TL;DR + +When an agent needs to test something in a Codespace, testing on the host is unreliable: the host already has Node, Python, pip packages, venvs, stale configs, and possibly running services. A passing host test proves nothing about a fresh install. + +The answer is a tiny, reusable tool — **Docker Test Shell** — that exposes one primitive: **run a command inside a fresh, mounted, normal-user container**. The whole point is container isolation alone; everything else is plain shell. It is fully standalone: not tied to any specific project or stack. + +## The one concept + +> **Test inside a throwaway container so your own environment's installed libs, stale state, and services do not pollute the test.** + +That is the entire tool. Not a test framework, not a schema, not verify primitives. Just: get me into a clean container with my code mounted in, and let me run commands. + +## Why a reusable primitive, not a per-project script + +A tightly-coupled test script (one that also knows how to install a specific stack, start its servers, check its config) is only reusable for that one project. The value worth generalizing is the *transport*: container lifecycle, bind-mount, uid-1000 user, TTY-aware exec, cleanup. Those are fiddly and easy to get wrong, and they apply to *any* target. + +Dividing that out leaves: + +- **`dts`** = the reusable transport (container isolation). +- **Target logic** = plain shell written per project, composed entirely of `dts exec` + bash checks. + +A future agent testing a Node app, a Python package, or a shell installer all use the same primitive; only the few commands differ. + +## Proposed design — Docker Test Shell (`dts`) + +### The primitive + +``` +dts exec "" # run inside a fresh, mounted, uid-1000 container +``` + +That is the core. Verification is plain shell around it: + +```bash +dts exec "bash /src/install.sh" +dts exec "hermes --version" && echo "hermes ok" +curl --max-time 5 -sf http://127.0.0.1:20128/healthz && echo "server ok" +``` + +No `wait_port` / `http_ok` / `cmd_ok` primitives — those smuggle a framework back in. You have `exec`; bash does the rest. + +### Three responsibilities `dts` owns (because they're fiddly) + +| Command | Job | Why `dts` owns it | +|---------|-----|-------------------| +| `dts up` | Create container: fresh base image (configurable), live bind-mount of the current repo, run as uid‑1000 user; install nothing | Correct base, uid, mount flags; container isolation is the whole point | +| `dts exec` | `docker exec` with correct flags: `-i` for scripts/agents, `-it` for a human TTY; always `bash -l`; always the right user | TTY and user flags are easy to get wrong | +| `dts clean` | Remove the container | No orphan containers / disk leak | + +### Configuring the base image + +The base image is **configurable**, defaulting to `ubuntu:24.04`: + +``` +dts up # defaults to ubuntu:24.04 +IMAGE=debian:12 dts up # explicit override +``` + +Rationale: different targets need different bases (Alpine for musl, a Node image for node apps). One env var keeps it flexible without complexity. + +### Installing apt packages (manual, one-off) + +`dts up` installs **nothing**. When a target needs apt packages (e.g. `curl` for a server, `g++` for native modules), the agent installs them it taught one-off through `dts exec`: + +```bash +dts exec "apt-get update && apt-get install -y curl g++ make" +``` + +This keeps the core minimal and the choice explicit per target, rather than baking a prereq list into `dts`. + +### Static mount target: `/src` + +The repo is always bind-mounted at `/src`. Simple, predictable, one name. Targets reference `/src/...` in their commands. + +### Non-goals (deliberate) + +- **No test-definition / YAML schema.** Per-target logic is a plain bash function or a short script. If you can test something manually in three commands, the test is three lines of bash — not a manifest. +- **No `verify` subcommands.** Verification is shell: check exit codes, grep output, curl endpoints. +- **No Dockerfile.** Bind-mount + a base image. No image build, no rebuild cycle. +- **No persistent SSH-like daemon.** `docker exec -i` with piped stdin reproduces a stateful session; we don't need a server inside the container. +- **No bundled apt prereqs.** Install what you need at test time via `dts exec`. + +### Naming + +**Docker Test Shell**, command `dts`. Plain, descriptive, honest about what it is. Not "harness," not "framework." + +## Key design decisions (each grounded in live testing) + +| Decision | Validation | +|----------|-----------| +| Fresh base, never the host | Host state lies (stale packages/venvs/services) | +| Run as uid‑1000 `ubuntu` user, never root | CI runner + Codespace both run uid 1000; root masks permission bugs | +| Live bind-mount of the repo at `/src` | Verified bidirectional: host write appears in container, container write appears on host, files md5-identical both sides | +| TTY-aware exec: `-i` scripted, `-it` human | Verified: `-it` errors with "input device is not a TTY" when stdin is piped; `-i` runs scripted + stateful REPL-style commands fine | +| Login shell (`bash -l`) | `bash -c` is non-interactive and doesn't source `.profile`; parity with CI needs the login shell | +| Verify mount with md5, not listing | Listing can lie; md5 proves same file | +| Guaranteed cleanup | Orphan containers leak disk; CI doesn't clean up after you | + +## How a future agent uses it + +Core loop — edit on host, test in the container, repeat. No rebuild: + +```bash +dts up # fresh container, repo mounted +dts exec "bash /src/install.sh" +dts exec "hermes --version" # did it install? +# ... observe, edit a host file, re-run: +dts exec "bash /src/install.sh" # mount makes the edit live immediately +dts clean # done, no orphan +``` + +A target becomes a short script of `dts exec` + bash checks. Another project ships its own ~10-line script reusing the same primitive. + +## The teaching layer (why this becomes a skill too) + +The design is the *how*; a skill is the *why* + method. A future agent needs to learn: + +1. **Never trust the host** — always repro in a fresh container; host state lies. +2. **Drive with `docker exec -i`**, never `-it`, when scripted; reserve `-it` for a human TTY. +3. **Use `bash -l`** and the right uid (1000) for host/CI parity. +4. **Install apt packages manually** inside the container via `dts exec "apt-get update && apt-get install -y "` — don't bake them into the tool. +5. **Clean up** — no orphan containers. +6. **The root-cause mindset** — find why it fails in the clean env, don't weaken the test. + +That's the skill's job; it lands in `.devcontainer/skills/` as procedural knowledge, and this wiki article references it. + +## Shipping the code + +The `dts` script ships **with the skill**, in `.devcontainer/skills/docker-test-shell/scripts/` (accessible via the `~/.hermes/skills/codespace` symlink). Any Codespace that loads the skill gets the tool. This keeps it self-contained and guarantees the skill and its executable stay in sync. + +## See also + +- [github-actions-testing-plan.md](github-actions-testing-plan.md) — CI is still the merge gate; `dts` is for fast local iteration + +--- + +*Living proposal — update as design or testing clarifies.* \ No newline at end of file