From a37973079a506c296ea5228bb11e9b6e32439bb5 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 15:07:56 +0200 Subject: [PATCH 1/2] feat(registry): generate a container setup script for the agents image The agents image installs its tools from the same registry as the workstation. Entries on the new `container` surface carry an exact pin (datasource, package, version) and a build-time install command, and `installer/setup-container.sh` is rendered from them. It installs as root and `--check` fails unless every tool reports its pinned version. A Renovate regex manager bumps the pins, and a test proves it matches every one. A path-filtered workflow runs the script in a clean Debian container on amd64 and arm64. Closes #60 --- .github/workflows/ci.yml | 2 + .github/workflows/container-setup.yml | 33 ++++ docs/REGISTRY.md | 44 +++++ installer/setup-container.sh | 187 ++++++++++++++++++ registry/estate-tooling.yaml | 267 +++++++++++++++++++++++++- renovate.json | 14 ++ scripts/render_registry.py | 211 +++++++++++++++++++- tests/test_registry_render.py | 163 ++++++++++++++++ 8 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/container-setup.yml create mode 100755 installer/setup-container.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d64eedd..e45ff69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ 'if': '${{ !cancelled() }}' - 'run': 'bash -n installer/setup-workstation.sh' 'if': '${{ !cancelled() }}' + - 'run': 'bash -n installer/setup-container.sh && shellcheck installer/setup-container.sh' + 'if': '${{ !cancelled() }}' - 'run': 'bash -n scripts/sync-hermes-registry.sh' 'if': '${{ !cancelled() }}' - 'run': 'scripts/build-runtime-package.sh v0.0.0-ci dist' diff --git a/.github/workflows/container-setup.yml b/.github/workflows/container-setup.yml new file mode 100644 index 0000000..1c43ac3 --- /dev/null +++ b/.github/workflows/container-setup.yml @@ -0,0 +1,33 @@ +'name': 'Container Setup' + +# Proves installer/setup-container.sh in a clean Debian container on both +# image architectures: every tool installs as root, then a non-root user +# sees each one at its pinned version. Path-filtered because a full install +# takes minutes, and only these files can change its outcome. +'on': + 'pull_request': + 'paths': + - 'registry/estate-tooling.yaml' + - 'scripts/render_registry.py' + - 'installer/setup-container.sh' + - '.github/workflows/container-setup.yml' + 'workflow_dispatch': + +'permissions': + 'contents': 'read' + +'jobs': + 'install': + 'name': 'Install and verify (${{ matrix.runner }})' + 'strategy': + 'fail-fast': false + 'matrix': + 'runner': + - 'ubuntu-latest' + - 'ubuntu-24.04-arm' + 'runs-on': '${{ matrix.runner }}' + 'steps': + - 'uses': 'actions/checkout@v6' + - 'run': | + docker run --rm -v "$PWD/installer/setup-container.sh:/setup-container.sh:ro" debian:bookworm-slim \ + bash -c 'bash /setup-container.sh && useradd -m agent && su agent -c "bash /setup-container.sh --check"' diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index be41cb6..5c7fe13 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -15,6 +15,7 @@ uv run pytest tests/test_registry_render.py | Artifact | Consumed by | |---|---| | `installer/setup-workstation.sh` | this laptop, via [SETUP.md](SETUP.md) | +| `installer/setup-container.sh` | the agents image, at build time | | `registry/generated/hermes/skills-sources.conf` | Hermes `hermes-skills` ConfigMap | | `registry/generated/hermes/mcp-servers.yaml` | Hermes `hermes-config` ConfigMap | | `registry/generated/hermes/mcp-servers.local.yaml` | a workstation's local `~/.hermes/config.yaml` | @@ -31,6 +32,7 @@ was never rendered. Both are one test. | `workstation` | Claude Code, Codex and local Hermes on a developer machine | | `hermes` | the in-cluster Hermes gateway | | `runner` | the per-workspace agent-runner image | +| `container` | the agents image, through `setup-container.sh` | `surfaces: []` is how a thing is **retired**: it stays documented, and reaches nowhere. That is what the `knowledge` MCP entry is now, and a test asserts it @@ -126,6 +128,48 @@ Two traps this catches: default, so a new `10.43.0.0/16` server reports **zero tools while the hosted ones work**. That is the block, not the NetworkPolicy. `hermes doctor` first. +## Adding a tool to the agents image + +Give the entry a `container:` block and add `container` to its `surfaces:`. +The renderer refuses one without the other. Language servers have no +`surfaces:`, so for them the block alone is enough. + +```yaml +- name: codex + binary: codex + surfaces: [workstation, container] + version_command: "codex --version" + container: + datasource: npm # a Renovate datasource + package: "@openai/codex" # the Renovate depName + version: "0.154.0" # exact; `latest` is rejected + install: 'npm install -g "@openai/codex@${VERSION}"' + requires: [node] # other container tools to install first +``` + +- **Keep `datasource`, `package` and `version` on consecutive lines, in that + order.** Renovate's regex manager reads them as one match. A reordered block + is a pin Renovate never bumps, and `test_renovate_tracks_every_container_pin` + fails on it. +- `install` runs as root at image build time with `VERSION`, `DEB_ARCH` + (`amd64`/`arm64`) and `GNU_ARCH` (`x86_64`/`aarch64`) set, and must use + `${VERSION}`. There are no secrets at build time. Anything that needs a + credential belongs to container start. +- Install somewhere the non-root agent user can read: `/usr/local`, or `uv tool` + (the script points it at `/opt/uv`). +- `setup-container.sh --check` reruns `version_command` and fails unless the + output contains the pinned version. Pick a command that prints the version + without starting a server; `npm ls -g ` works for any npm tool. + `container.version_command` and `container.binary` override the entry's own. +- Debian packages go in `container_base.apt_packages`. They follow the base + image's release, so they carry no version of their own. + +Prove a change in a clean container before merging: + +```bash +docker run --rm -v "$PWD/installer/setup-container.sh:/s.sh:ro" debian:bookworm-slim bash /s.sh +``` + ## Adding a Claude profile ```yaml diff --git a/installer/setup-container.sh b/installer/setup-container.sh new file mode 100755 index 0000000..4c1a0ad --- /dev/null +++ b/installer/setup-container.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# GENERATED FROM registry/estate-tooling.yaml -- DO NOT EDIT. +# +# Installs every tool the agents image gives its Agent Sessions, at the +# versions the registry pins. Runs as root at image build time, where no +# secret exists; anything that needs a credential happens at container start. +# +# Usage: +# ./setup-container.sh install everything, then verify (root) +# ./setup-container.sh --check verify only: every tool present at its pin + +set -euo pipefail + +CHECK_ONLY=0 +case "${1:-}" in + --check) CHECK_ONLY=1 ;; + "") ;; + --help|-h) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 64 ;; +esac + +failures=0 +log() { printf 'setup-container: %s\n' "$*"; } +ok() { printf 'setup-container: ok %s\n' "$*"; } +fail() { printf 'setup-container: FAIL %s\n' "$*" >&2; failures=$((failures + 1)); } + +# Shared, world-readable locations, so the non-root agent user can run +# what root installed. +export UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin UV_PYTHON_INSTALL_DIR=/opt/uv/python +export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +export DEBIAN_FRONTEND=noninteractive + +case "$(uname -m)" in + x86_64|amd64) DEB_ARCH=amd64 GNU_ARCH=x86_64 ;; + aarch64|arm64) DEB_ARCH=arm64 GNU_ARCH=aarch64 ;; + *) echo "setup-container: unsupported architecture $(uname -m)" >&2; exit 1 ;; +esac +export DEB_ARCH GNU_ARCH + +install_tool() { + log "install $1 $2" + VERSION="$2" bash -euo pipefail -c "$3" +} + +# Verifies the value, not the exit code: the tool must report the pinned +# version, so a stale binary earlier on PATH fails the check. +check_tool() { + local binary="$1" version="${2#v}" version_command="$3" output + if ! command -v "${binary}" >/dev/null 2>&1; then + fail "${binary}: not on PATH" + return 0 + fi + output="$(bash -c "${version_command}" 2>&1 || true)" + if printf '%s\n' "${output}" | grep -Eq "(^|[^0-9.])${version//./\\.}([^0-9.]|$)"; then + ok "${binary} ${version}" + else + fail "${binary}: expected ${version}, got: $(printf '%s\n' "${output}" | head -1)" + fi +} + +check_apt() { + if dpkg -s "$1" >/dev/null 2>&1; then + ok "apt $1" + else + fail "apt $1: not installed" + fi +} + +if [ "${CHECK_ONLY}" = 0 ]; then + if [ "$(id -u)" != 0 ]; then + echo "setup-container: must run as root (image build time); use --check otherwise" >&2 + exit 77 + fi + + log "apt repositories" + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl gnupg + # shellcheck source=/dev/null + codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")" + curl -fsSL 'https://packages.adoptium.net/artifactory/api/gpg/key/public' | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg + echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb ${codename} main" > /etc/apt/sources.list.d/adoptium.list + apt-get update + log "apt packages" + apt-get install -y --no-install-recommends \ + bash \ + bat \ + bubblewrap \ + build-essential \ + ca-certificates \ + clangd \ + curl \ + fd-find \ + git \ + gnupg \ + jq \ + less \ + openssh-client \ + procps \ + python3 \ + python3-venv \ + ripgrep \ + temurin-21-jdk \ + tini \ + tmux \ + unzip \ + xz-utils \ + zsh \ + ; + + # Single quotes on purpose: install_tool expands ${VERSION} per tool. + # shellcheck disable=SC2016 + { + install_tool node '22.23.2' 'curl -fsSL "https://nodejs.org/dist/v${VERSION}/node-v${VERSION}-linux-${DEB_ARCH/amd64/x64}.tar.xz" | tar -xJ -C /usr/local --strip-components=1 --exclude='\''*.md'\'' --exclude=LICENSE --exclude=share/doc' + install_tool claude-code '2.1.272' 'npm install -g "@anthropic-ai/claude-code@${VERSION}"' + install_tool codex '0.154.0' 'npm install -g "@openai/codex@${VERSION}"' + install_tool uv '0.12.15' 'curl -fsSL "https://github.com/astral-sh/uv/releases/download/${VERSION}/uv-${GNU_ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin --strip-components=1 --wildcards '\''*/uv'\'' '\''*/uvx'\''' + install_tool hermes-agent '0.19.0' 'uv tool install --python 3.13 "hermes-agent[mcp]==${VERSION}"' + install_tool olcli '0.13.0' 'npm install -g "@aloth/olcli@${VERSION}"' + install_tool gh 'v2.100.0' 'curl -fsSL "https://github.com/cli/cli/releases/download/${VERSION}/gh_${VERSION#v}_linux_${DEB_ARCH}.tar.gz" | tar -xz -C /usr/local --strip-components=1 "gh_${VERSION#v}_linux_${DEB_ARCH}/bin/gh"' + install_tool go '1.27.1' 'curl -fsSL "https://go.dev/dl/go${VERSION}.linux-${DEB_ARCH}.tar.gz" | tar -xz -C /usr/local && ln -sf /usr/local/go/bin/go /usr/local/go/bin/gofmt /usr/local/bin/' + install_tool mise 'v2026.9.9' 'curl -fsSL -o /usr/local/bin/mise "https://github.com/jdx/mise/releases/download/${VERSION}/mise-${VERSION}-linux-${DEB_ARCH/amd64/x64}" && chmod 755 /usr/local/bin/mise' + install_tool ast-grep '0.45.3' 'npm install -g "@ast-grep/cli@${VERSION}"' + install_tool typescript '7.0.2' 'npm install -g "typescript@${VERSION}"' + install_tool github-mcp-server 'v1.12.1' 'case "${DEB_ARCH}" in amd64) a=x86_64 ;; arm64) a=arm64 ;; esac && curl -fsSL "https://github.com/github/github-mcp-server/releases/download/${VERSION}/github-mcp-server_Linux_${a}.tar.gz" | tar -xz -C /usr/local/bin github-mcp-server' + install_tool serena '1.7.0' 'uv tool install --python 3.13 "serena-agent==${VERSION}"' + install_tool playwright '0.0.81' 'npm install -g "@playwright/mcp@${VERSION}" && node "$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" install --with-deps chromium && chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}"' + install_tool drawio '1.5.0' 'npm install -g "@drawio/mcp@${VERSION}"' + install_tool typescript-lsp '6.0.0' 'npm install -g "typescript-language-server@${VERSION}"' + install_tool pyright-lsp '1.1.414' 'npm install -g "pyright@${VERSION}"' + install_tool gopls-lsp 'v0.23.0' 'GOBIN=/usr/local/bin GOPATH=/tmp/gopath GOCACHE=/tmp/gocache go install "golang.org/x/tools/gopls@${VERSION}" && GOPATH=/tmp/gopath go clean -modcache && rm -rf /tmp/gopath /tmp/gocache' + install_tool php-lsp '1.18.5' 'npm install -g "intelephense@${VERSION}"' + } + + chmod -R a+rX /opt/uv + npm cache clean --force >/dev/null 2>&1 || true + rm -rf /var/lib/apt/lists/* /root/.cache /root/.npm /tmp/* +fi + +log "verify" +check_apt bash +check_apt bat +check_apt bubblewrap +check_apt build-essential +check_apt ca-certificates +check_apt clangd +check_apt curl +check_apt fd-find +check_apt git +check_apt gnupg +check_apt jq +check_apt less +check_apt openssh-client +check_apt procps +check_apt python3 +check_apt python3-venv +check_apt ripgrep +check_apt temurin-21-jdk +check_apt tini +check_apt tmux +check_apt unzip +check_apt xz-utils +check_apt zsh +check_tool node '22.23.2' 'node --version' +check_tool claude '2.1.272' 'claude --version' +check_tool codex '0.154.0' 'codex --version' +check_tool uv '0.12.15' 'uv --version' +check_tool hermes '0.19.0' 'hermes --version' +check_tool olcli '0.13.0' 'olcli --version' +check_tool gh 'v2.100.0' 'gh --version' +check_tool go '1.27.1' 'go version' +check_tool mise 'v2026.9.9' 'mise --version' +check_tool ast-grep '0.45.3' 'ast-grep --version' +check_tool tsc '7.0.2' 'tsc --version' +check_tool github-mcp-server 'v1.12.1' 'github-mcp-server --version' +check_tool serena '1.7.0' 'serena --version' +check_tool playwright-mcp '0.0.81' 'npm ls -g @playwright/mcp' +check_tool drawio-mcp '1.5.0' 'npm ls -g @drawio/mcp' +check_tool typescript-language-server '6.0.0' 'typescript-language-server --version' +check_tool pyright-langserver '1.1.414' 'pyright --version' +check_tool gopls 'v0.23.0' 'gopls version' +check_tool intelephense '1.18.5' 'npm ls -g intelephense' + +if [ "${failures}" != 0 ]; then + log "${failures} check(s) failed" + exit 1 +fi +log "every tool is at its pinned version" diff --git a/registry/estate-tooling.yaml b/registry/estate-tooling.yaml index ca60c90..1cbdbba 100644 --- a/registry/estate-tooling.yaml +++ b/registry/estate-tooling.yaml @@ -6,6 +6,7 @@ # it and must never be hand-edited: # # installer/setup-workstation.sh local machine (Claude Code + Codex) +# installer/setup-container.sh the agents image, at build time # registry/generated/hermes/skills-sources.conf Hermes `sources.conf` body # registry/generated/hermes/mcp-servers.yaml Hermes `mcp_servers:` block # @@ -19,6 +20,14 @@ # workstation this laptop: Claude Code and Codex through setup-workstation.sh # hermes the in-cluster Hermes gateway # runner the per-workspace agent-runner image +# container the agents image, through setup-container.sh +# +# CONTAINER PINS ARE EXACT. An entry on the `container` surface carries a +# `container:` block with `datasource`, `package` and `version`, in that +# order: the Renovate regex manager reads those three lines as one match, so +# reordering them hides the pin from Renovate. `install` runs as root at image +# build time with VERSION, DEB_ARCH (amd64|arm64) and GNU_ARCH +# (x86_64|aarch64) set, and must use ${VERSION}. No secret is available there. # # PINS ARE COMMIT SHAs, NOT TAG SHAs. Hermes' sync-skills init container # clones `--branch ` and compares `git rev-parse HEAD`, which is the @@ -79,6 +88,46 @@ claude_profiles: The personal-account profile. Same skills, plugins, LSPs and MCP fleet as work; its own login and its own conversation history. +# ------------------------------------------------------------------- +# Container base: Debian packages the agents image installs before any +# tool below. They follow the base image's Debian release (and the named +# apt repositories), which the image pins by digest, so there is no +# per-package version here. +# +# The Docker CLI is deliberately absent: the agents container has no +# Docker socket (agents-api ADR 0001). +# ------------------------------------------------------------------- +container_base: + apt_repositories: + - name: adoptium + key_url: https://packages.adoptium.net/artifactory/api/gpg/key/public + url: https://packages.adoptium.net/artifactory/deb + components: main + apt_packages: + - bash + - bat + - bubblewrap # Codex's sandbox + - build-essential + - ca-certificates + - clangd + - curl + - fd-find + - git + - gnupg + - jq + - less + - openssh-client + - procps + - python3 + - python3-venv + - ripgrep + - temurin-21-jdk + - tini + - tmux + - unzip + - xz-utils + - zsh + # ------------------------------------------------------------------- # Command-line tools. # @@ -92,22 +141,37 @@ clis: binary: claude purpose: Claude Code CLI. latest: true - surfaces: [workstation] + surfaces: [workstation, container] # The native installer owns ~/.local/bin/claude and self-updates. # Do NOT `npm i -g @anthropic-ai/claude-code` on top of it: two # installs on one PATH is how a stale binary wins. install: "curl -fsSL https://claude.ai/install.sh | bash" update: "claude update" version_command: "claude --version" + # The container is the exception: the native installer writes into the + # installing user's home, which is root at build time and invisible to + # the agent user, so the image takes the npm package at a fixed version. + container: + datasource: npm + package: "@anthropic-ai/claude-code" + version: "2.1.272" + install: 'npm install -g "@anthropic-ai/claude-code@${VERSION}"' + requires: [node] - name: codex binary: codex purpose: OpenAI Codex CLI, the second engine council fans out to. latest: true - surfaces: [workstation] + surfaces: [workstation, container] install: "npm install -g @openai/codex@latest" update: "npm install -g @openai/codex@latest" version_command: "codex --version" + container: + datasource: npm + package: "@openai/codex" + version: "0.154.0" + install: 'npm install -g "@openai/codex@${VERSION}"' + requires: [node] - name: hermes-agent binary: hermes @@ -116,7 +180,7 @@ clis: `hermes doctor` / `hermes skills list` against a local config; the in-cluster gateway runs the container image, not this. latest: true - surfaces: [workstation] + surfaces: [workstation, container] # uv tool, not pip: hermes-agent needs >=3.11,<3.14 and a shared # site-packages is how a version conflict takes out both tools. # The `[mcp]` extra is what lets LOCAL hermes run any MCP server at @@ -126,6 +190,14 @@ clis: update: "uv tool upgrade 'hermes-agent[mcp]'" version_command: "hermes --version" requires: [uv] + # In the container this is the CLI only. Agent Sessions use the + # in-cluster Hermes; no gateway runs in the image. + container: + datasource: pypi + package: "hermes-agent" + version: "0.19.0" + install: 'uv tool install --python 3.13 "hermes-agent[mcp]==${VERSION}"' + requires: [uv] - name: olcli binary: olcli @@ -133,21 +205,149 @@ clis: Overleaf CLI and MCP server. Points at the estate's self-hosted Overleaf, not overleaf.com. latest: true - surfaces: [workstation, hermes] + surfaces: [workstation, hermes, container] install: "npm install -g @aloth/olcli@latest" update: "npm install -g @aloth/olcli@latest" version_command: "olcli --version" # Also provides the olcli-mcp and git-remote-overleaf binaries. provides_binaries: [olcli, olcli-mcp, git-remote-overleaf] + container: + datasource: npm + package: "@aloth/olcli" + version: "0.13.0" + install: 'npm install -g "@aloth/olcli@${VERSION}"' + requires: [node] - name: uv binary: uv purpose: Python tool/venv manager; prerequisite for hermes-agent and the kit's own tooling. latest: true - surfaces: [workstation] + surfaces: [workstation, container] install: "curl -fsSL https://astral.sh/uv/install.sh | sh" update: "uv self update" version_command: "uv --version" + container: + datasource: github-releases + package: "astral-sh/uv" + version: "0.12.15" + install: >- + curl -fsSL "https://github.com/astral-sh/uv/releases/download/${VERSION}/uv-${GNU_ARCH}-unknown-linux-gnu.tar.gz" + | tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/uv' '*/uvx' + + # --- Container-only tools. The workstation brings its own. --- + + - name: node + binary: node + purpose: Node.js runtime; every npm-installed tool in the image needs it. + surfaces: [container] + install: null + version_command: "node --version" + container: + datasource: node-version + package: "node" + version: "22.23.2" + install: >- + curl -fsSL "https://nodejs.org/dist/v${VERSION}/node-v${VERSION}-linux-${DEB_ARCH/amd64/x64}.tar.xz" + | tar -xJ -C /usr/local --strip-components=1 --exclude='*.md' --exclude=LICENSE --exclude=share/doc + + - name: gh + binary: gh + purpose: GitHub CLI. + surfaces: [container] + install: null + version_command: "gh --version" + container: + datasource: github-releases + package: "cli/cli" + version: "v2.100.0" + install: >- + curl -fsSL "https://github.com/cli/cli/releases/download/${VERSION}/gh_${VERSION#v}_linux_${DEB_ARCH}.tar.gz" + | tar -xz -C /usr/local --strip-components=1 "gh_${VERSION#v}_linux_${DEB_ARCH}/bin/gh" + + - name: go + binary: go + purpose: Go toolchain, world-readable under /usr/local/go. + surfaces: [container] + install: null + version_command: "go version" + container: + datasource: golang-version + package: "go" + version: "1.27.1" + install: >- + curl -fsSL "https://go.dev/dl/go${VERSION}.linux-${DEB_ARCH}.tar.gz" | tar -xz -C /usr/local + && ln -sf /usr/local/go/bin/go /usr/local/go/bin/gofmt /usr/local/bin/ + + - name: mise + binary: mise + purpose: Polyglot runtime manager, for toolchains a repository pins itself. + surfaces: [container] + install: null + version_command: "mise --version" + container: + datasource: github-releases + package: "jdx/mise" + version: "v2026.9.9" + install: >- + curl -fsSL -o /usr/local/bin/mise + "https://github.com/jdx/mise/releases/download/${VERSION}/mise-${VERSION}-linux-${DEB_ARCH/amd64/x64}" + && chmod 755 /usr/local/bin/mise + + - name: ast-grep + binary: ast-grep + purpose: Structural search and rewrite. + surfaces: [container] + install: null + version_command: "ast-grep --version" + container: + datasource: npm + package: "@ast-grep/cli" + version: "0.45.3" + install: 'npm install -g "@ast-grep/cli@${VERSION}"' + requires: [node] + + - name: typescript + binary: tsc + purpose: TypeScript compiler; typescript-language-server drives it. + surfaces: [container] + install: null + version_command: "tsc --version" + container: + datasource: npm + package: "typescript" + version: "7.0.2" + install: 'npm install -g "typescript@${VERSION}"' + requires: [node] + + - name: github-mcp-server + binary: github-mcp-server + purpose: >- + GitHub's stdio MCP server. The image only carries the binary; its + token is supplied at session start, never at build time. + surfaces: [container] + install: null + version_command: "github-mcp-server --version" + container: + datasource: github-releases + package: "github/github-mcp-server" + version: "v1.12.1" + install: >- + case "${DEB_ARCH}" in amd64) a=x86_64 ;; arm64) a=arm64 ;; esac + && curl -fsSL "https://github.com/github/github-mcp-server/releases/download/${VERSION}/github-mcp-server_Linux_${a}.tar.gz" + | tar -xz -C /usr/local/bin github-mcp-server + + - name: serena + binary: serena + purpose: LSP-backed semantic code intelligence MCP server. + surfaces: [container] + install: null + version_command: "serena --version" + container: + datasource: pypi + package: "serena-agent" + version: "1.7.0" + install: 'uv tool install --python 3.13 "serena-agent==${VERSION}"' + requires: [uv] # ------------------------------------------------------------------- # Claude Code plugin marketplaces. @@ -260,11 +460,25 @@ language_servers: install: "npm install -g typescript-language-server typescript" languages: [typescript, javascript] enabled: true + container: + datasource: npm + package: "typescript-language-server" + version: "6.0.0" + install: 'npm install -g "typescript-language-server@${VERSION}"' + version_command: "typescript-language-server --version" + requires: [node, typescript] - plugin: pyright-lsp binary: pyright-langserver install: "npm install -g pyright" languages: [python] enabled: true + container: + datasource: npm + package: "pyright" + version: "1.1.414" + install: 'npm install -g "pyright@${VERSION}"' + version_command: "pyright --version" + requires: [node] - plugin: kotlin-lsp binary: kotlin-lsp install: "brew install kotlin-lsp" @@ -285,6 +499,16 @@ language_servers: install: "go install golang.org/x/tools/gopls@latest" languages: [go] enabled: true + container: + datasource: go + package: "golang.org/x/tools/gopls" + version: "v0.23.0" + install: >- + GOBIN=/usr/local/bin GOPATH=/tmp/gopath GOCACHE=/tmp/gocache + go install "golang.org/x/tools/gopls@${VERSION}" + && GOPATH=/tmp/gopath go clean -modcache && rm -rf /tmp/gopath /tmp/gocache + version_command: "gopls version" + requires: [go] - plugin: rust-analyzer-lsp binary: rust-analyzer install: "rustup component add rust-analyzer" @@ -305,6 +529,14 @@ language_servers: install: "npm install -g intelephense" languages: [php] enabled: true + container: + datasource: npm + package: "intelephense" + version: "1.18.5" + install: 'npm install -g "intelephense@${VERSION}"' + # `intelephense --version` crashes instead of printing a version. + version_command: "npm ls -g intelephense" + requires: [node] - plugin: csharp-lsp binary: csharp-ls install: "dotnet tool install --global csharp-ls" @@ -516,9 +748,22 @@ mcp_servers: command: npx args: ["-y", "@playwright/mcp@latest", "--headless", "--browser", "chromium"] timeout: 120 - surfaces: [workstation, hermes] + surfaces: [workstation, hermes, container] trust: local requires_binary: npx + # Chromium's revision is tied to the playwright version @playwright/mcp + # depends on, so the browser is installed through that nested copy. + container: + datasource: npm + package: "@playwright/mcp" + version: "0.0.81" + install: >- + npm install -g "@playwright/mcp@${VERSION}" + && node "$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" install --with-deps chromium + && chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" + binary: playwright-mcp + version_command: "npm ls -g @playwright/mcp" + requires: [node] - name: drawio transport: stdio @@ -533,10 +778,18 @@ mcp_servers: hermes_command: npx hermes_args: ["-y", "@drawio/mcp@latest"] timeout: 60 - surfaces: [workstation, hermes] + surfaces: [workstation, hermes, container] trust: local requires_binary: drawio-mcp install: "npm install -g @drawio/mcp@latest" + container: + datasource: npm + package: "@drawio/mcp" + version: "1.5.0" + install: 'npm install -g "@drawio/mcp@${VERSION}"' + # The binary starts a server rather than printing a version. + version_command: "npm ls -g @drawio/mcp" + requires: [node] - name: overleaf transport: stdio diff --git a/renovate.json b/renovate.json index 1ce0aca..e1c4789 100644 --- a/renovate.json +++ b/renovate.json @@ -2,6 +2,14 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>JorisJonkers-dev/renovate-config"], "customManagers": [ + { + "customType": "regex", + "description": "Update the container tool pins in the tooling registry. Each container: block lists datasource, package and version on consecutive lines.", + "managerFilePatterns": ["/^registry/estate-tooling\\.yaml$/"], + "matchStrings": [ + "datasource: (?\\S+)\\s+package: \"?(?[^\"\\s]+)\"?\\s+version: \"?(?[^\"\\s]+)\"?" + ] + }, { "customType": "regex", "description": "Update BMAD Method source lock release pins.", @@ -67,6 +75,12 @@ } ], "packageRules": [ + { + "matchFileNames": ["registry/estate-tooling.yaml"], + "prBodyNotes": [ + "Run `uv run python scripts/render_registry.py --write` before merging so `installer/setup-container.sh` carries the new pin." + ] + }, { "matchFileNames": ["bmad-source.lock"], "prBodyNotes": [ diff --git a/scripts/render_registry.py b/scripts/render_registry.py index 6ef6d4f..db71bfb 100644 --- a/scripts/render_registry.py +++ b/scripts/render_registry.py @@ -5,6 +5,7 @@ below is generated from it: * ``installer/setup-workstation.sh`` -- local Claude Code + Codex + Hermes +* ``installer/setup-container.sh`` -- the agents image, at build time * ``registry/generated/hermes/skills-sources.conf`` -- Hermes ``sources.conf`` * ``registry/generated/hermes/mcp-servers.yaml`` -- Hermes ``mcp_servers:`` (gateway) * ``registry/generated/hermes/mcp-servers.local.yaml`` -- local Hermes ``mcp_servers:`` @@ -28,6 +29,7 @@ REGISTRY_PATH = KIT_ROOT / "registry" / "estate-tooling.yaml" SETUP_SCRIPT = Path("installer/setup-workstation.sh") +CONTAINER_SETUP_SCRIPT = Path("installer/setup-container.sh") # Hand-written, sourced by SETUP_SCRIPT: keeps workstation_connect forwards alive. PORT_FORWARD_HELPER = Path("installer/port-forward-agent.sh") HERMES_SOURCES = Path("registry/generated/hermes/skills-sources.conf") @@ -81,7 +83,7 @@ def _entries(data: dict[str, Any], key: str) -> list[dict[str, Any]]: def validate(data: dict[str, Any]) -> None: - known_surfaces = {"workstation", "hermes", "runner"} + known_surfaces = {"workstation", "hermes", "runner", "container"} marketplaces = {m["name"] for m in _entries(data, "marketplaces")} plugin_names = {p["name"] for p in _entries(data, "plugins")} @@ -157,6 +159,7 @@ def validate(data: dict[str, Any]) -> None: ) _validate_claude_profiles(data) + _validate_container(data) for key in ("clis", "skill_sources", "mcp_servers", "local_skills"): for item in _entries(data, key): @@ -230,6 +233,83 @@ def _validate_claude_profiles(data: dict[str, Any]) -> None: ) +CONTAINER_SOURCES = ("clis", "mcp_servers", "language_servers") + + +def _container_candidates(data: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + """Every registry entry that could carry a ``container:`` block, with its name.""" + found = [] + for key in CONTAINER_SOURCES: + for item in _entries(data, key): + found.append((str(item.get("name") or item.get("plugin")), item)) + return found + + +def _validate_container(data: dict[str, Any]) -> None: + candidates = _container_candidates(data) + blocks = {name: item["container"] for name, item in candidates if item.get("container")} + + for name, item in candidates: + # Language servers declare no surfaces; for them the block alone decides. + if "surfaces" in item and bool(item.get("container")) != on_surface(item, "container"): + raise RegistryError( + f"{name}: a container: block and the container surface go together", + ) + + for name, block in blocks.items(): + version = str(block.get("version") or "") + if not version or version == "latest": + raise RegistryError(f"container tool {name} must pin a version, not {version!r}") + for field in ("datasource", "package", "install", "version_command"): + if not block.get(field) and not (field == "version_command" and _item(data, name).get(field)): + raise RegistryError(f"container tool {name} must name its {field}") + if "${VERSION}" not in str(block["install"]): + raise RegistryError(f"container tool {name} install must use ${{VERSION}}") + for required in block.get("requires") or []: + if required not in blocks: + raise RegistryError(f"container tool {name} requires unknown tool {required!r}") + + +def _item(data: dict[str, Any], name: str) -> dict[str, Any]: + return next(item for candidate, item in _container_candidates(data) if candidate == name) + + +def container_tools(data: dict[str, Any]) -> list[dict[str, Any]]: + """The container tools in install order: registry order, after their requirements.""" + tools: dict[str, dict[str, Any]] = {} + for name, item in _container_candidates(data): + block = item.get("container") + if not block: + continue + tools[name] = { + "name": name, + "binary": block.get("binary") or item.get("binary") or item.get("requires_binary") or name, + "datasource": block["datasource"], + "package": block["package"], + "version": str(block["version"]), + "install": " ".join(str(block["install"]).split()), + "version_command": block.get("version_command") or item.get("version_command"), + "requires": list(block.get("requires") or []), + } + + ordered: list[dict[str, Any]] = [] + placed: set[str] = set() + + def place(name: str, trail: tuple[str, ...]) -> None: + if name in placed: + return + if name in trail: + raise RegistryError(f"container tools require each other in a cycle: {' -> '.join(trail)}") + for required in tools[name]["requires"]: + place(required, (*trail, name)) + placed.add(name) + ordered.append(tools[name]) + + for name in tools: + place(name, ()) + return ordered + + def claude_profiles(data: dict[str, Any]) -> list[dict[str, Any]]: """Every Claude Code profile, the primary first.""" profiles = _entries(data, "claude_profiles") @@ -1191,6 +1271,134 @@ def render_setup_script(data: dict[str, Any]) -> str: return "\n".join(out) + "\n" +def render_container_setup_script(data: dict[str, Any]) -> str: + base = data.get("container_base") or {} + tools = container_tools(data) + out: list[str] = [] + w = out.append + + w("#!/usr/bin/env bash") + w(f"# {GENERATED_BANNER}") + w("#") + w("# Installs every tool the agents image gives its Agent Sessions, at the") + w("# versions the registry pins. Runs as root at image build time, where no") + w("# secret exists; anything that needs a credential happens at container start.") + w("#") + w("# Usage:") + w("# ./setup-container.sh install everything, then verify (root)") + w("# ./setup-container.sh --check verify only: every tool present at its pin") + w("") + w("set -euo pipefail") + w("") + w("CHECK_ONLY=0") + w('case "${1:-}" in') + w(" --check) CHECK_ONLY=1 ;;") + w(' "") ;;') + w(" --help|-h) sed -n '2,12p' \"$0\"; exit 0 ;;") + w(' *) echo "unknown option: $1" >&2; exit 64 ;;') + w("esac") + w("") + w("failures=0") + w("log() { printf 'setup-container: %s\\n' \"$*\"; }") + w("ok() { printf 'setup-container: ok %s\\n' \"$*\"; }") + w("fail() { printf 'setup-container: FAIL %s\\n' \"$*\" >&2; failures=$((failures + 1)); }") + w("") + w("# Shared, world-readable locations, so the non-root agent user can run") + w("# what root installed.") + w("export UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin UV_PYTHON_INSTALL_DIR=/opt/uv/python") + w("export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright") + w("export DEBIAN_FRONTEND=noninteractive") + w("") + w('case "$(uname -m)" in') + w(" x86_64|amd64) DEB_ARCH=amd64 GNU_ARCH=x86_64 ;;") + w(" aarch64|arm64) DEB_ARCH=arm64 GNU_ARCH=aarch64 ;;") + w(' *) echo "setup-container: unsupported architecture $(uname -m)" >&2; exit 1 ;;') + w("esac") + w("export DEB_ARCH GNU_ARCH") + w("") + w("install_tool() {") + w(' log "install $1 $2"') + w(' VERSION="$2" bash -euo pipefail -c "$3"') + w("}") + w("") + w("# Verifies the value, not the exit code: the tool must report the pinned") + w("# version, so a stale binary earlier on PATH fails the check.") + w("check_tool() {") + w(' local binary="$1" version="${2#v}" version_command="$3" output') + w(' if ! command -v "${binary}" >/dev/null 2>&1; then') + w(' fail "${binary}: not on PATH"') + w(" return 0") + w(" fi") + w(' output="$(bash -c "${version_command}" 2>&1 || true)"') + w(' if printf \'%s\\n\' "${output}" | grep -Eq "(^|[^0-9.])${version//./\\\\.}([^0-9.]|$)"; then') + w(' ok "${binary} ${version}"') + w(" else") + w(' fail "${binary}: expected ${version}, got: $(printf \'%s\\n\' "${output}" | head -1)"') + w(" fi") + w("}") + w("") + w("check_apt() {") + w(' if dpkg -s "$1" >/dev/null 2>&1; then') + w(' ok "apt $1"') + w(" else") + w(' fail "apt $1: not installed"') + w(" fi") + w("}") + w("") + + packages = [str(p) for p in base.get("apt_packages") or []] + w('if [ "${CHECK_ONLY}" = 0 ]; then') + w(' if [ "$(id -u)" != 0 ]; then') + w(' echo "setup-container: must run as root (image build time); use --check otherwise" >&2') + w(" exit 77") + w(" fi") + w("") + w(' log "apt repositories"') + w(" apt-get update") + w(" apt-get install -y --no-install-recommends ca-certificates curl gnupg") + w(" # shellcheck source=/dev/null") + w(' codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"') + for repo in base.get("apt_repositories") or []: + name = repo["name"] + keyring = f"/usr/share/keyrings/{name}.gpg" + w(f' curl -fsSL {_q(repo["key_url"])} | gpg --dearmor -o {keyring}') + w( + f' echo "deb [signed-by={keyring}] {repo["url"]} ${{codename}} {repo["components"]}" ' + f"> /etc/apt/sources.list.d/{name}.list", + ) + w(" apt-get update") + w(' log "apt packages"') + w(" apt-get install -y --no-install-recommends \\") + for package in packages: + w(f" {package} \\") + w(" ;") + w("") + w(" # Single quotes on purpose: install_tool expands ${VERSION} per tool.") + w(" # shellcheck disable=SC2016") + w(" {") + for tool in tools: + w(f" install_tool {tool['name']} {_q(tool['version'])} {_q(tool['install'])}") + w(" }") + w("") + w(" chmod -R a+rX /opt/uv") + w(" npm cache clean --force >/dev/null 2>&1 || true") + w(" rm -rf /var/lib/apt/lists/* /root/.cache /root/.npm /tmp/*") + w("fi") + w("") + w('log "verify"') + for package in packages: + w(f"check_apt {package}") + for tool in tools: + w(f"check_tool {tool['binary']} {_q(tool['version'])} {_q(tool['version_command'])}") + w("") + w('if [ "${failures}" != 0 ]; then') + w(' log "${failures} check(s) failed"') + w(" exit 1") + w("fi") + w('log "every tool is at its pinned version"') + return "\n".join(out) + "\n" + + def _mcp_env_flags(server: dict[str, Any], credential: str | None, optional_cred: bool) -> str: """Build the ``--env KEY=VALUE`` fragment for a stdio MCP server. @@ -1247,6 +1455,7 @@ def _has_plugin_pins(data: dict[str, Any]) -> bool: def artifacts(data: dict[str, Any]) -> dict[Path, tuple[str, int]]: return { SETUP_SCRIPT: (render_setup_script(data), 0o755), + CONTAINER_SETUP_SCRIPT: (render_container_setup_script(data), 0o755), HERMES_SOURCES: (render_hermes_sources(data), 0o644), HERMES_MCP: (render_hermes_mcp(data), 0o644), HERMES_LOCAL_MCP: (render_hermes_local_mcp(data), 0o644), diff --git a/tests/test_registry_render.py b/tests/test_registry_render.py index b1b423e..2c3ef1a 100644 --- a/tests/test_registry_render.py +++ b/tests/test_registry_render.py @@ -620,3 +620,166 @@ def test_registered_server_names_are_extracted_without_pcre(tmp_path: Path) -> N assert result.returncode == 0, result.stderr assert result.stderr == "" assert result.stdout.split() == ["idea", "playwright", "plugin:github:github"] + + +# --------------------------------------------------------------------------- +# Container surface: installer/setup-container.sh +# --------------------------------------------------------------------------- + + +def _raw_entry(data: dict, name: str) -> dict: + for key in ("clis", "mcp_servers", "language_servers"): + for item in data.get(key) or []: + if item.get("name", item.get("plugin")) == name: + return item + raise KeyError(name) + + +def test_container_tools_are_declared() -> None: + names = {t["name"] for t in render_registry.container_tools(_registry())} + assert {"claude-code", "codex", "hermes-agent", "node", "uv"} <= names + + +@pytest.mark.parametrize("version", ["", "latest", None]) +def test_a_container_tool_must_pin_a_version(version: str | None) -> None: + data = _registry() + _raw_entry(data, "codex")["container"]["version"] = version + with pytest.raises(render_registry.RegistryError, match="pin a version"): + render_registry.validate(data) + + +def test_a_container_install_must_use_the_pinned_version() -> None: + data = _registry() + _raw_entry(data, "codex")["container"]["install"] = "npm install -g @openai/codex@latest" + with pytest.raises(render_registry.RegistryError, match=r"\$\{VERSION\}"): + render_registry.validate(data) + + +def test_the_container_surface_and_block_go_together() -> None: + data = _registry() + del _raw_entry(data, "codex")["container"] + with pytest.raises(render_registry.RegistryError, match="container surface"): + render_registry.validate(data) + + data = _registry() + _raw_entry(data, "codex")["surfaces"].remove("container") + with pytest.raises(render_registry.RegistryError, match="container surface"): + render_registry.validate(data) + + +def test_a_container_tool_may_only_require_another_container_tool() -> None: + data = _registry() + _raw_entry(data, "codex")["container"]["requires"] = ["nope"] + with pytest.raises(render_registry.RegistryError, match="requires unknown"): + render_registry.validate(data) + + +def test_container_tools_install_after_what_they_require() -> None: + order = [t["name"] for t in render_registry.container_tools(_registry())] + for tool in render_registry.container_tools(_registry()): + for required in tool["requires"]: + assert order.index(required) < order.index(tool["name"]) + + +def test_renovate_tracks_every_container_pin() -> None: + """A pin Renovate cannot see never moves, so prove the manager matches each one.""" + import json + import re + + renovate = json.loads((KIT_ROOT / "renovate.json").read_text()) + manager = next( + m for m in renovate["customManagers"] + if "estate-tooling" in "".join(m["managerFilePatterns"]) + ) + text = (KIT_ROOT / "registry" / "estate-tooling.yaml").read_text() + found = set() + for pattern in manager["matchStrings"]: + for match in re.finditer(pattern.replace("(?<", "(?P<"), text): + found.add((match["datasource"], match["depName"], match["currentValue"])) + expected = { + (t["datasource"], t["package"], t["version"]) + for t in render_registry.container_tools(_registry()) + } + assert found == expected + + +def test_a_container_mcp_server_is_checked_by_the_binary_it_runs() -> None: + """An MCP server's entry name is not its binary: `drawio` runs `drawio-mcp`.""" + data = _registry() + tools = {t["name"]: t for t in render_registry.container_tools(data)} + for server in data["mcp_servers"]: + tool = tools.get(server["name"]) + if tool and server["transport"] == "stdio" and "binary" not in server["container"]: + assert tool["binary"] == server["requires_binary"] + + +def test_container_script_carries_no_secret() -> None: + data = _registry() + script = render_registry.render_container_setup_script(data) + for server in data["mcp_servers"]: + if server.get("credential"): + assert server["credential"] not in script + assert "@latest" not in script + + +def _stub(bin_dir: Path, name: str, output: str) -> None: + path = bin_dir / name + path.write_text(f"#!/bin/sh\necho '{output}'\n") + path.chmod(0o755) + + +def _container_check(tmp_path: Path, override: dict[str, str] | None = None) -> subprocess.CompletedProcess: + data = _registry() + script = tmp_path / "setup-container.sh" + script.write_text(render_registry.render_container_setup_script(data)) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + # Several tools answer through one command (`npm ls -g ...`), so each stub + # prints every line meant for it. + outputs: dict[str, list[str]] = {} + for tool in render_registry.container_tools(data): + version = (override or {}).get(tool["name"], tool["version"]) + line = f"{tool['package']}@{version}" + outputs.setdefault(tool["binary"], []).append(line) + outputs.setdefault(tool["version_command"].split()[0], []).append(line) + for name, lines in outputs.items(): + _stub(bin_dir, name, "\n".join(dict.fromkeys(lines))) + _stub(bin_dir, "dpkg", "ok") + return subprocess.run( + ["bash", str(script), "--check"], capture_output=True, text=True, check=False, + env={"PATH": f"{bin_dir}:/usr/bin:/bin"}, + ) + + +def test_container_check_passes_when_every_tool_is_at_its_pin(tmp_path: Path) -> None: + result = _container_check(tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_container_check_fails_on_a_version_mismatch(tmp_path: Path) -> None: + result = _container_check(tmp_path, {"codex": "0.0.1"}) + assert result.returncode != 0 + assert "codex: expected" in result.stderr + + +def test_container_check_fails_when_a_tool_is_missing(tmp_path: Path) -> None: + script = tmp_path / "setup-container.sh" + script.write_text(render_registry.render_container_setup_script(_registry())) + result = subprocess.run( + ["bash", str(script), "--check"], capture_output=True, text=True, check=False, + env={"PATH": "/usr/bin:/bin"}, + ) + assert result.returncode != 0 + assert "hermes: not on PATH" in result.stderr + + +@pytest.mark.skipif(os.geteuid() == 0, reason="the refusal is for non-root callers") +def test_container_install_refuses_to_run_as_non_root(tmp_path: Path) -> None: + script = tmp_path / "setup-container.sh" + script.write_text(render_registry.render_container_setup_script(_registry())) + result = subprocess.run( + ["bash", str(script)], capture_output=True, text=True, check=False, + env={"PATH": "/usr/bin:/bin"}, + ) + assert result.returncode == 77 + assert "must run as root" in result.stderr From 6333b19b0ddf979ed1a55651b0b5cfd6b346a6d4 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 15:44:26 +0200 Subject: [PATCH 2/2] fix(registry): address review of the container setup script - Pin TypeScript to 5.x and check for tsserver: 7.x ships none, and typescript-language-server needs it. Renovate is held below 7, and Node stays on 22. - Re-render generated artifacts on Renovate registry PRs with the release app token, so a pin bump merges without a manual step. - Move the shared install locations and apt bootstrap into container_base, so the renderer holds no knowledge of any one tool. - Reject a container tool name declared in two sections, derive the --help range from the header, and add python3-pip back. - Lint the generated script in the shell-lint job. --- .github/workflows/ci.yml | 6 +- .github/workflows/container-setup.yml | 5 +- .github/workflows/renovate-render.yml | 43 ++++++++++++ docs/REGISTRY.md | 7 +- installer/setup-container.sh | 37 +++++------ registry/estate-tooling.yaml | 52 ++++++--------- renovate.json | 12 +++- scripts/render_registry.py | 96 ++++++++++++++------------- tests/test_registry_render.py | 34 ++++++++-- 9 files changed, 180 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/renovate-render.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e45ff69..beeccda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ 'if': '${{ !cancelled() }}' - 'run': 'bash -n installer/setup-workstation.sh' 'if': '${{ !cancelled() }}' - - 'run': 'bash -n installer/setup-container.sh && shellcheck installer/setup-container.sh' + - 'run': 'bash -n installer/setup-container.sh' 'if': '${{ !cancelled() }}' - 'run': 'bash -n scripts/sync-hermes-registry.sh' 'if': '${{ !cancelled() }}' @@ -95,6 +95,10 @@ bash -n "${repo_shell_scripts[@]}" shellcheck "${repo_shell_scripts[@]}" + - 'name': 'Lint the generated container setup script' + 'if': '${{ !cancelled() }}' + 'run': 'shellcheck installer/setup-container.sh' + 'pipeline-complete': 'name': 'Pipeline Complete' 'if': 'always()' diff --git a/.github/workflows/container-setup.yml b/.github/workflows/container-setup.yml index 1c43ac3..a5c2248 100644 --- a/.github/workflows/container-setup.yml +++ b/.github/workflows/container-setup.yml @@ -1,9 +1,6 @@ 'name': 'Container Setup' -# Proves installer/setup-container.sh in a clean Debian container on both -# image architectures: every tool installs as root, then a non-root user -# sees each one at its pinned version. Path-filtered because a full install -# takes minutes, and only these files can change its outcome. +# Installs every container tool in clean Debian on both architectures, then verifies as non-root. 'on': 'pull_request': 'paths': diff --git a/.github/workflows/renovate-render.yml b/.github/workflows/renovate-render.yml new file mode 100644 index 0000000..efe1fd6 --- /dev/null +++ b/.github/workflows/renovate-render.yml @@ -0,0 +1,43 @@ +'name': 'Renovate Render' + +# Re-renders generated artifacts on a Renovate registry bump; the app token's push re-triggers CI. +'on': + 'pull_request': + 'paths': + - 'registry/estate-tooling.yaml' + +'permissions': + 'contents': 'read' + +'jobs': + 'render': + 'name': 'Render registry artifacts' + 'if': >- + github.event.pull_request.user.login == 'renovate[bot]' + && github.event.pull_request.head.repo.full_name == github.repository + 'runs-on': 'ubuntu-latest' + 'steps': + - 'uses': 'actions/create-github-app-token@v3' + 'id': 'app-token' + 'with': + 'app-id': '${{ secrets.RELEASE_APP_ID }}' + 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}' + - 'uses': 'actions/checkout@v6' + 'with': + 'ref': '${{ github.event.pull_request.head.ref }}' + 'token': '${{ steps.app-token.outputs.token }}' + - 'uses': 'astral-sh/setup-uv@v8.2.0' + - 'run': 'uv sync --frozen' + - 'run': 'uv run python scripts/render_registry.py --write' + - 'name': 'Commit rendered artifacts' + 'env': + 'APP_SLUG': '${{ steps.app-token.outputs.app-slug }}' + 'run': | + if git diff --quiet; then + echo "artifacts already current" + exit 0 + fi + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + git commit -am "chore(registry): render artifacts for the updated pin" + git push diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index 5c7fe13..00082cd 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -155,14 +155,17 @@ The renderer refuses one without the other. Language servers have no (`amd64`/`arm64`) and `GNU_ARCH` (`x86_64`/`aarch64`) set, and must use `${VERSION}`. There are no secrets at build time. Anything that needs a credential belongs to container start. -- Install somewhere the non-root agent user can read: `/usr/local`, or `uv tool` - (the script points it at `/opt/uv`). +- Install somewhere the non-root agent user can read: `/usr/local`, or a path + set in `container_base.environment` and listed in `readable_paths`. - `setup-container.sh --check` reruns `version_command` and fails unless the output contains the pinned version. Pick a command that prints the version without starting a server; `npm ls -g ` works for any npm tool. `container.version_command` and `container.binary` override the entry's own. - Debian packages go in `container_base.apt_packages`. They follow the base image's release, so they carry no version of their own. +- Renovate bumps only the registry. `renovate-render.yml` re-renders the script + on that PR and pushes the result with the release app's token, which + re-triggers CI. Prove a change in a clean container before merging: diff --git a/installer/setup-container.sh b/installer/setup-container.sh index 4c1a0ad..f28b3d6 100755 --- a/installer/setup-container.sh +++ b/installer/setup-container.sh @@ -1,9 +1,7 @@ #!/usr/bin/env bash # GENERATED FROM registry/estate-tooling.yaml -- DO NOT EDIT. # -# Installs every tool the agents image gives its Agent Sessions, at the -# versions the registry pins. Runs as root at image build time, where no -# secret exists; anything that needs a credential happens at container start. +# Installs the agents image's tools at their registry pins. Root, build time, no secrets. # # Usage: # ./setup-container.sh install everything, then verify (root) @@ -15,7 +13,7 @@ CHECK_ONLY=0 case "${1:-}" in --check) CHECK_ONLY=1 ;; "") ;; - --help|-h) sed -n '2,12p' "$0"; exit 0 ;; + --help|-h) sed -n '2,8p' "$0"; exit 0 ;; *) echo "unknown option: $1" >&2; exit 64 ;; esac @@ -24,11 +22,11 @@ log() { printf 'setup-container: %s\n' "$*"; } ok() { printf 'setup-container: ok %s\n' "$*"; } fail() { printf 'setup-container: FAIL %s\n' "$*" >&2; failures=$((failures + 1)); } -# Shared, world-readable locations, so the non-root agent user can run -# what root installed. -export UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin UV_PYTHON_INSTALL_DIR=/opt/uv/python -export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright export DEBIAN_FRONTEND=noninteractive +export UV_TOOL_DIR='/opt/uv/tools' +export UV_TOOL_BIN_DIR='/usr/local/bin' +export UV_PYTHON_INSTALL_DIR='/opt/uv/python' +export PLAYWRIGHT_BROWSERS_PATH='/ms-playwright' case "$(uname -m)" in x86_64|amd64) DEB_ARCH=amd64 GNU_ARCH=x86_64 ;; @@ -42,8 +40,7 @@ install_tool() { VERSION="$2" bash -euo pipefail -c "$3" } -# Verifies the value, not the exit code: the tool must report the pinned -# version, so a stale binary earlier on PATH fails the check. +# Checks the reported version, so a stale binary earlier on PATH fails. check_tool() { local binary="$1" version="${2#v}" version_command="$3" output if ! command -v "${binary}" >/dev/null 2>&1; then @@ -86,17 +83,15 @@ if [ "${CHECK_ONLY}" = 0 ]; then bat \ bubblewrap \ build-essential \ - ca-certificates \ clangd \ - curl \ fd-find \ git \ - gnupg \ jq \ less \ openssh-client \ procps \ python3 \ + python3-pip \ python3-venv \ ripgrep \ temurin-21-jdk \ @@ -120,10 +115,10 @@ if [ "${CHECK_ONLY}" = 0 ]; then install_tool go '1.27.1' 'curl -fsSL "https://go.dev/dl/go${VERSION}.linux-${DEB_ARCH}.tar.gz" | tar -xz -C /usr/local && ln -sf /usr/local/go/bin/go /usr/local/go/bin/gofmt /usr/local/bin/' install_tool mise 'v2026.9.9' 'curl -fsSL -o /usr/local/bin/mise "https://github.com/jdx/mise/releases/download/${VERSION}/mise-${VERSION}-linux-${DEB_ARCH/amd64/x64}" && chmod 755 /usr/local/bin/mise' install_tool ast-grep '0.45.3' 'npm install -g "@ast-grep/cli@${VERSION}"' - install_tool typescript '7.0.2' 'npm install -g "typescript@${VERSION}"' + install_tool typescript '5.9.3' 'npm install -g "typescript@${VERSION}"' install_tool github-mcp-server 'v1.12.1' 'case "${DEB_ARCH}" in amd64) a=x86_64 ;; arm64) a=arm64 ;; esac && curl -fsSL "https://github.com/github/github-mcp-server/releases/download/${VERSION}/github-mcp-server_Linux_${a}.tar.gz" | tar -xz -C /usr/local/bin github-mcp-server' install_tool serena '1.7.0' 'uv tool install --python 3.13 "serena-agent==${VERSION}"' - install_tool playwright '0.0.81' 'npm install -g "@playwright/mcp@${VERSION}" && node "$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" install --with-deps chromium && chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}"' + install_tool playwright '0.0.81' 'npm install -g "@playwright/mcp@${VERSION}" && node "$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" install --with-deps chromium' install_tool drawio '1.5.0' 'npm install -g "@drawio/mcp@${VERSION}"' install_tool typescript-lsp '6.0.0' 'npm install -g "typescript-language-server@${VERSION}"' install_tool pyright-lsp '1.1.414' 'npm install -g "pyright@${VERSION}"' @@ -131,27 +126,29 @@ if [ "${CHECK_ONLY}" = 0 ]; then install_tool php-lsp '1.18.5' 'npm install -g "intelephense@${VERSION}"' } - chmod -R a+rX /opt/uv + [ ! -e '/opt/uv' ] || chmod -R a+rX '/opt/uv' + [ ! -e '/ms-playwright' ] || chmod -R a+rX '/ms-playwright' npm cache clean --force >/dev/null 2>&1 || true rm -rf /var/lib/apt/lists/* /root/.cache /root/.npm /tmp/* fi log "verify" +check_apt ca-certificates +check_apt curl +check_apt gnupg check_apt bash check_apt bat check_apt bubblewrap check_apt build-essential -check_apt ca-certificates check_apt clangd -check_apt curl check_apt fd-find check_apt git -check_apt gnupg check_apt jq check_apt less check_apt openssh-client check_apt procps check_apt python3 +check_apt python3-pip check_apt python3-venv check_apt ripgrep check_apt temurin-21-jdk @@ -170,7 +167,7 @@ check_tool gh 'v2.100.0' 'gh --version' check_tool go '1.27.1' 'go version' check_tool mise 'v2026.9.9' 'mise --version' check_tool ast-grep '0.45.3' 'ast-grep --version' -check_tool tsc '7.0.2' 'tsc --version' +check_tool tsserver '5.9.3' 'tsc --version' check_tool github-mcp-server 'v1.12.1' 'github-mcp-server --version' check_tool serena '1.7.0' 'serena --version' check_tool playwright-mcp '0.0.81' 'npm ls -g @playwright/mcp' diff --git a/registry/estate-tooling.yaml b/registry/estate-tooling.yaml index 1cbdbba..db28aa5 100644 --- a/registry/estate-tooling.yaml +++ b/registry/estate-tooling.yaml @@ -22,12 +22,8 @@ # runner the per-workspace agent-runner image # container the agents image, through setup-container.sh # -# CONTAINER PINS ARE EXACT. An entry on the `container` surface carries a -# `container:` block with `datasource`, `package` and `version`, in that -# order: the Renovate regex manager reads those three lines as one match, so -# reordering them hides the pin from Renovate. `install` runs as root at image -# build time with VERSION, DEB_ARCH (amd64|arm64) and GNU_ARCH -# (x86_64|aarch64) set, and must use ${VERSION}. No secret is available there. +# Container pins are exact; keep datasource, package, version in that order +# (Renovate reads them as one match). See docs/REGISTRY.md. # # PINS ARE COMMIT SHAs, NOT TAG SHAs. Hermes' sync-skills init container # clones `--branch ` and compares `git rev-parse HEAD`, which is the @@ -89,15 +85,19 @@ claude_profiles: fleet as work; its own login and its own conversation history. # ------------------------------------------------------------------- -# Container base: Debian packages the agents image installs before any -# tool below. They follow the base image's Debian release (and the named -# apt repositories), which the image pins by digest, so there is no -# per-package version here. -# -# The Docker CLI is deliberately absent: the agents container has no -# Docker socket (agents-api ADR 0001). +# Container base. Debian packages follow the base image's release. +# No Docker CLI: the agents container has no Docker socket (agents-api ADR 0001). # ------------------------------------------------------------------- container_base: + # Shared, world-readable install locations for the non-root agent user. + environment: + UV_TOOL_DIR: /opt/uv/tools + UV_TOOL_BIN_DIR: /usr/local/bin + UV_PYTHON_INSTALL_DIR: /opt/uv/python + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + readable_paths: [/opt/uv, /ms-playwright] + # Needed to add the apt repositories below. + apt_bootstrap: [ca-certificates, curl, gnupg] apt_repositories: - name: adoptium key_url: https://packages.adoptium.net/artifactory/api/gpg/key/public @@ -108,17 +108,15 @@ container_base: - bat - bubblewrap # Codex's sandbox - build-essential - - ca-certificates - clangd - - curl - fd-find - git - - gnupg - jq - less - openssh-client - procps - python3 + - python3-pip - python3-venv - ripgrep - temurin-21-jdk @@ -148,9 +146,7 @@ clis: install: "curl -fsSL https://claude.ai/install.sh | bash" update: "claude update" version_command: "claude --version" - # The container is the exception: the native installer writes into the - # installing user's home, which is root at build time and invisible to - # the agent user, so the image takes the npm package at a fixed version. + # Container uses npm: the native installer writes into root's home. container: datasource: npm package: "@anthropic-ai/claude-code" @@ -190,8 +186,7 @@ clis: update: "uv tool upgrade 'hermes-agent[mcp]'" version_command: "hermes --version" requires: [uv] - # In the container this is the CLI only. Agent Sessions use the - # in-cluster Hermes; no gateway runs in the image. + # CLI only in the container; Agent Sessions use the in-cluster Hermes. container: datasource: pypi package: "hermes-agent" @@ -307,23 +302,22 @@ clis: requires: [node] - name: typescript - binary: tsc - purpose: TypeScript compiler; typescript-language-server drives it. + # tsserver, not tsc: typescript-language-server needs it, and 7.x ships none. + binary: tsserver + purpose: TypeScript compiler and tsserver; typescript-language-server drives it. surfaces: [container] install: null version_command: "tsc --version" container: datasource: npm package: "typescript" - version: "7.0.2" + version: "5.9.3" install: 'npm install -g "typescript@${VERSION}"' requires: [node] - name: github-mcp-server binary: github-mcp-server - purpose: >- - GitHub's stdio MCP server. The image only carries the binary; its - token is supplied at session start, never at build time. + purpose: GitHub's stdio MCP server; its token arrives at session start, never at build. surfaces: [container] install: null version_command: "github-mcp-server --version" @@ -751,8 +745,7 @@ mcp_servers: surfaces: [workstation, hermes, container] trust: local requires_binary: npx - # Chromium's revision is tied to the playwright version @playwright/mcp - # depends on, so the browser is installed through that nested copy. + # Chromium comes from @playwright/mcp's own playwright, whose revision it matches. container: datasource: npm package: "@playwright/mcp" @@ -760,7 +753,6 @@ mcp_servers: install: >- npm install -g "@playwright/mcp@${VERSION}" && node "$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" install --with-deps chromium - && chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" binary: playwright-mcp version_command: "npm ls -g @playwright/mcp" requires: [node] diff --git a/renovate.json b/renovate.json index e1c4789..c30d93f 100644 --- a/renovate.json +++ b/renovate.json @@ -76,10 +76,16 @@ ], "packageRules": [ { + "description": "The image stays on Node 22 LTS.", "matchFileNames": ["registry/estate-tooling.yaml"], - "prBodyNotes": [ - "Run `uv run python scripts/render_registry.py --write` before merging so `installer/setup-container.sh` carries the new pin." - ] + "matchDepNames": ["node"], + "allowedVersions": "22.x" + }, + { + "description": "typescript-language-server needs tsserver, which TypeScript 7 does not ship.", + "matchFileNames": ["registry/estate-tooling.yaml"], + "matchDepNames": ["typescript"], + "allowedVersions": "<7" }, { "matchFileNames": ["bmad-source.lock"], diff --git a/scripts/render_registry.py b/scripts/render_registry.py index db71bfb..b4d5dea 100644 --- a/scripts/render_registry.py +++ b/scripts/render_registry.py @@ -246,36 +246,36 @@ def _container_candidates(data: dict[str, Any]) -> list[tuple[str, dict[str, Any def _validate_container(data: dict[str, Any]) -> None: - candidates = _container_candidates(data) - blocks = {name: item["container"] for name, item in candidates if item.get("container")} - - for name, item in candidates: + seen: set[str] = set() + for name, item in _container_candidates(data): # Language servers declare no surfaces; for them the block alone decides. if "surfaces" in item and bool(item.get("container")) != on_surface(item, "container"): raise RegistryError( f"{name}: a container: block and the container surface go together", ) - - for name, block in blocks.items(): - version = str(block.get("version") or "") - if not version or version == "latest": - raise RegistryError(f"container tool {name} must pin a version, not {version!r}") + if not item.get("container"): + continue + if name in seen: + raise RegistryError(f"container tool name {name!r} is declared twice") + seen.add(name) + + tools = _container_tool_views(data) + for tool in tools.values(): + name = tool["name"] + if not tool["version"] or tool["version"] in {"latest", "None"}: + raise RegistryError(f"container tool {name} must pin a version, not {tool['version']!r}") for field in ("datasource", "package", "install", "version_command"): - if not block.get(field) and not (field == "version_command" and _item(data, name).get(field)): + if not tool[field]: raise RegistryError(f"container tool {name} must name its {field}") - if "${VERSION}" not in str(block["install"]): + if "${VERSION}" not in tool["install"]: raise RegistryError(f"container tool {name} install must use ${{VERSION}}") - for required in block.get("requires") or []: - if required not in blocks: + for required in tool["requires"]: + if required not in tools: raise RegistryError(f"container tool {name} requires unknown tool {required!r}") -def _item(data: dict[str, Any], name: str) -> dict[str, Any]: - return next(item for candidate, item in _container_candidates(data) if candidate == name) - - -def container_tools(data: dict[str, Any]) -> list[dict[str, Any]]: - """The container tools in install order: registry order, after their requirements.""" +def _container_tool_views(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Each container block merged with the fields it inherits from its entry.""" tools: dict[str, dict[str, Any]] = {} for name, item in _container_candidates(data): block = item.get("container") @@ -284,14 +284,19 @@ def container_tools(data: dict[str, Any]) -> list[dict[str, Any]]: tools[name] = { "name": name, "binary": block.get("binary") or item.get("binary") or item.get("requires_binary") or name, - "datasource": block["datasource"], - "package": block["package"], - "version": str(block["version"]), - "install": " ".join(str(block["install"]).split()), + "datasource": block.get("datasource"), + "package": block.get("package"), + "version": str(block.get("version") or ""), + "install": " ".join(str(block.get("install") or "").split()), "version_command": block.get("version_command") or item.get("version_command"), "requires": list(block.get("requires") or []), } + return tools + +def container_tools(data: dict[str, Any]) -> list[dict[str, Any]]: + """The container tools in install order: registry order, after their requirements.""" + tools = _container_tool_views(data) ordered: list[dict[str, Any]] = [] placed: set[str] = set() @@ -1274,27 +1279,28 @@ def render_setup_script(data: dict[str, Any]) -> str: def render_container_setup_script(data: dict[str, Any]) -> str: base = data.get("container_base") or {} tools = container_tools(data) - out: list[str] = [] + bootstrap = [str(p) for p in base.get("apt_bootstrap") or []] + packages = [str(p) for p in base.get("apt_packages") or []] + header = [ + "#!/usr/bin/env bash", + f"# {GENERATED_BANNER}", + "#", + "# Installs the agents image's tools at their registry pins. Root, build time, no secrets.", + "#", + "# Usage:", + "# ./setup-container.sh install everything, then verify (root)", + "# ./setup-container.sh --check verify only: every tool present at its pin", + ] + out: list[str] = [*header, ""] w = out.append - w("#!/usr/bin/env bash") - w(f"# {GENERATED_BANNER}") - w("#") - w("# Installs every tool the agents image gives its Agent Sessions, at the") - w("# versions the registry pins. Runs as root at image build time, where no") - w("# secret exists; anything that needs a credential happens at container start.") - w("#") - w("# Usage:") - w("# ./setup-container.sh install everything, then verify (root)") - w("# ./setup-container.sh --check verify only: every tool present at its pin") - w("") w("set -euo pipefail") w("") w("CHECK_ONLY=0") w('case "${1:-}" in') w(" --check) CHECK_ONLY=1 ;;") w(' "") ;;') - w(" --help|-h) sed -n '2,12p' \"$0\"; exit 0 ;;") + w(f" --help|-h) sed -n '2,{len(header)}p' \"$0\"; exit 0 ;;") w(' *) echo "unknown option: $1" >&2; exit 64 ;;') w("esac") w("") @@ -1303,11 +1309,9 @@ def render_container_setup_script(data: dict[str, Any]) -> str: w("ok() { printf 'setup-container: ok %s\\n' \"$*\"; }") w("fail() { printf 'setup-container: FAIL %s\\n' \"$*\" >&2; failures=$((failures + 1)); }") w("") - w("# Shared, world-readable locations, so the non-root agent user can run") - w("# what root installed.") - w("export UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin UV_PYTHON_INSTALL_DIR=/opt/uv/python") - w("export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright") w("export DEBIAN_FRONTEND=noninteractive") + for key, value in (base.get("environment") or {}).items(): + w(f"export {key}={_q(str(value))}") w("") w('case "$(uname -m)" in') w(" x86_64|amd64) DEB_ARCH=amd64 GNU_ARCH=x86_64 ;;") @@ -1321,8 +1325,7 @@ def render_container_setup_script(data: dict[str, Any]) -> str: w(' VERSION="$2" bash -euo pipefail -c "$3"') w("}") w("") - w("# Verifies the value, not the exit code: the tool must report the pinned") - w("# version, so a stale binary earlier on PATH fails the check.") + w("# Checks the reported version, so a stale binary earlier on PATH fails.") w("check_tool() {") w(' local binary="$1" version="${2#v}" version_command="$3" output') w(' if ! command -v "${binary}" >/dev/null 2>&1; then') @@ -1346,7 +1349,6 @@ def render_container_setup_script(data: dict[str, Any]) -> str: w("}") w("") - packages = [str(p) for p in base.get("apt_packages") or []] w('if [ "${CHECK_ONLY}" = 0 ]; then') w(' if [ "$(id -u)" != 0 ]; then') w(' echo "setup-container: must run as root (image build time); use --check otherwise" >&2') @@ -1355,7 +1357,8 @@ def render_container_setup_script(data: dict[str, Any]) -> str: w("") w(' log "apt repositories"') w(" apt-get update") - w(" apt-get install -y --no-install-recommends ca-certificates curl gnupg") + if bootstrap: + w(f" apt-get install -y --no-install-recommends {' '.join(bootstrap)}") w(" # shellcheck source=/dev/null") w(' codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"') for repo in base.get("apt_repositories") or []: @@ -1380,13 +1383,14 @@ def render_container_setup_script(data: dict[str, Any]) -> str: w(f" install_tool {tool['name']} {_q(tool['version'])} {_q(tool['install'])}") w(" }") w("") - w(" chmod -R a+rX /opt/uv") + for path in base.get("readable_paths") or []: + w(f" [ ! -e {_q(str(path))} ] || chmod -R a+rX {_q(str(path))}") w(" npm cache clean --force >/dev/null 2>&1 || true") w(" rm -rf /var/lib/apt/lists/* /root/.cache /root/.npm /tmp/*") w("fi") w("") w('log "verify"') - for package in packages: + for package in [*bootstrap, *packages]: w(f"check_apt {package}") for tool in tools: w(f"check_tool {tool['binary']} {_q(tool['version'])} {_q(tool['version_command'])}") diff --git a/tests/test_registry_render.py b/tests/test_registry_render.py index 2c3ef1a..1dde6cf 100644 --- a/tests/test_registry_render.py +++ b/tests/test_registry_render.py @@ -3,7 +3,9 @@ from __future__ import annotations import copy +import json import os +import re import shutil import subprocess import sys @@ -628,7 +630,7 @@ def test_registered_server_names_are_extracted_without_pcre(tmp_path: Path) -> N def _raw_entry(data: dict, name: str) -> dict: - for key in ("clis", "mcp_servers", "language_servers"): + for key in render_registry.CONTAINER_SOURCES: for item in data.get(key) or []: if item.get("name", item.get("plugin")) == name: return item @@ -683,9 +685,6 @@ def test_container_tools_install_after_what_they_require() -> None: def test_renovate_tracks_every_container_pin() -> None: """A pin Renovate cannot see never moves, so prove the manager matches each one.""" - import json - import re - renovate = json.loads((KIT_ROOT / "renovate.json").read_text()) manager = next( m for m in renovate["customManagers"] @@ -713,6 +712,30 @@ def test_a_container_mcp_server_is_checked_by_the_binary_it_runs() -> None: assert tool["binary"] == server["requires_binary"] +def test_a_container_tool_name_may_not_be_declared_twice() -> None: + data = _registry() + clone = copy.deepcopy(_raw_entry(data, "codex")) + clone.update(transport="stdio", command="codex") + data["mcp_servers"].append(clone) + with pytest.raises(render_registry.RegistryError, match="declared twice"): + render_registry.validate(data) + + +def test_the_typescript_language_server_gets_a_tsserver() -> None: + """typescript-language-server drives tsserver, which typescript 7.x no longer ships.""" + typescript = next(t for t in render_registry.container_tools(_registry()) if t["name"] == "typescript") + assert typescript["binary"] == "tsserver" + + +def test_container_help_prints_only_the_header(tmp_path: Path) -> None: + script = tmp_path / "setup-container.sh" + script.write_text(render_registry.render_container_setup_script(_registry())) + result = subprocess.run(["bash", str(script), "--help"], capture_output=True, text=True, check=False) + assert result.returncode == 0 + assert all(line.startswith("#") for line in result.stdout.splitlines()), result.stdout + assert "--check" in result.stdout + + def test_container_script_carries_no_secret() -> None: data = _registry() script = render_registry.render_container_setup_script(data) @@ -734,8 +757,7 @@ def _container_check(tmp_path: Path, override: dict[str, str] | None = None) -> script.write_text(render_registry.render_container_setup_script(data)) bin_dir = tmp_path / "bin" bin_dir.mkdir(exist_ok=True) - # Several tools answer through one command (`npm ls -g ...`), so each stub - # prints every line meant for it. + # One stub can answer for several tools (`npm ls -g`), so it prints every line. outputs: dict[str, list[str]] = {} for tool in render_registry.container_tools(data): version = (override or {}).get(tool["name"], tool["version"])