Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .devcontainer/mnemon/seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \"<cmd>\"` (run as uid-1000, bash -l, -i no -t), `dts apt \"<pkgs>\"` (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"
}
]
}
125 changes: 125 additions & 0 deletions .devcontainer/skills/docker-test-shell/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<cmd>"` | Run `<cmd>` inside the container as the uid-1000 user, `bash -l`. TTY-aware. |
| `dts apt "<pkgs>"` | Run `apt-get update && install -y <pkgs>` 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 <cmd>` | 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.
66 changes: 66 additions & 0 deletions .devcontainer/skills/docker-test-shell/references/dts-usage.md
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 128 additions & 0 deletions .devcontainer/skills/docker-test-shell/scripts/dts.sh
Original file line number Diff line number Diff line change
@@ -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 "<cmd>" # run <cmd> inside container as uid-1000 user, bash -l, TTY-aware
# dts apt "<pkgs>" # 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 '<pkgs>' (root)"
log "verify the mount: diff <(md5sum <file>) <(dts exec 'md5sum /src/<file>')"
}

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 <command>"
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 '<pkg1> <pkg2> ...' (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
1 change: 1 addition & 0 deletions .devcontainer/wiki/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
Loading
Loading