Add devbox image Dockerfiles and build scripts, split from homelab-komodo

This commit is contained in:
2026-07-31 16:58:46 +02:00
parent a06bd1bd8e
commit e2afab23a9
24 changed files with 1889 additions and 1 deletions
+30 -1
View File
@@ -1,3 +1,32 @@
# devbox-images
Coder devbox image Dockerfiles, split out of homelab-komodo so remote Coder deployments can build/reference them standalone
Dockerfiles for the Coder devbox image flavors (`base`, `base-config`, `php`, `ruby`, `react`,
`dotnet`, `vnc`, `flutter`, `flutter-vnc`), split out of the `homelab-komodo` repo's
`stacks/harry/coder/` so a Coder deployment other than `harry` can build/reference these images
without checking out all of homelab-komodo (which also carries unrelated stacks and secrets
conventions).
The Coder server + Terraform workspace template that actually *uses* these images (peer-to-peer
Claude Code/Kilo Code agent bridge, MCP wiring, etc.) still lives in `homelab-komodo`'s
`stacks/harry/coder/devenv/` — this repo is only the image build side.
## Building
```sh
./build_fast.sh # cached build, every flavor
./build_no_cache.sh # forced full rebuild, every flavor
# or build one directly:
docker compose build devbox-<flavor>
```
**Every flavor but `base` builds `FROM` `base-config`** (`react`/`dotnet`/`vnc`/`flutter` directly,
`flutter-vnc` from `flutter`) — a `base-config/Dockerfile` change needs every flavor rebuilt, not
just the ones actively in use, or the others silently keep running the old layer with whatever
changed missing. Both build scripts cover every flavor for this reason; don't add a new flavor to
one without the other.
## Using from a remote Coder deployment
Point a Terraform `docker_image` reference (or an equivalent build step in another Coder
template) at an image built from this repo instead of `homelab-komodo`'s copy. Tag/push built
images to whatever registry that Coder deployment can pull from.
Executable
+10
View File
@@ -0,0 +1,10 @@
set -e
docker compose build devbox-base;
docker compose build devbox-base-config;
docker compose build devbox-php;
docker compose build devbox-ruby;
docker compose build devbox-react;
docker compose build devbox-dotnet;
docker compose build devbox-vnc;
docker compose build devbox-flutter;
docker compose build devbox-flutter-vnc;
+10
View File
@@ -0,0 +1,10 @@
set -e
docker compose build --no-cache devbox-base;
docker compose build --no-cache devbox-base-config;
docker compose build --no-cache devbox-php;
docker compose build --no-cache devbox-ruby;
docker compose build --no-cache devbox-react;
docker compose build --no-cache devbox-dotnet;
docker compose build --no-cache devbox-vnc;
docker compose build --no-cache devbox-flutter;
docker compose build --no-cache devbox-flutter-vnc;
+55
View File
@@ -0,0 +1,55 @@
services:
devbox-base:
container_name: devbox-base
profiles: ["build-only"]
build:
context: ./devbox/base
dockerfile: Dockerfile
devbox-base-config:
container_name: devbox-base-config
profiles: ["build-only"]
build:
context: ./devbox/base-config
dockerfile: Dockerfile
devbox-dotnet:
container_name: devbox-dotnet
profiles: ["build-only"]
build:
context: ./devbox/dotnet
dockerfile: Dockerfile
devbox-flutter:
container_name: devbox-flutter
profiles: ["build-only"]
build:
context: ./devbox/flutter
dockerfile: Dockerfile
devbox-php:
container_name: devbox-php
profiles: ["build-only"]
build:
context: ./devbox/php
dockerfile: Dockerfile
devbox-react:
container_name: devbox-react
profiles: ["build-only"]
build:
context: ./devbox/react
dockerfile: Dockerfile
devbox-vnc:
container_name: devbox-vnc
profiles: ["build-only"]
build:
context: ./devbox/vnc
dockerfile: Dockerfile
devbox-flutter-vnc:
container_name: devbox-flutter-vnc
profiles: ["build-only"]
build:
context: ./devbox/flutter-vnc
dockerfile: Dockerfile
devbox-ruby:
container_name: devbox-ruby
profiles: ["build-only"]
build:
context: ./devbox/ruby
dockerfile: Dockerfile
+408
View File
@@ -0,0 +1,408 @@
FROM coder-devbox-base:latest AS base-default
USER root
# socat: bridges kilo acp's stdio-only Agent Client Protocol onto a plain TCP
# socket for peer-workspace collaboration (see peer-mcp-bridge.py / README's
# "Peer workspaces" section) — kilo serve has no MCP server endpoint of its
# own, ACP is the only real path to another workspace's live agent session.
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends socat \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# ENV NVM_DIR=/home/coder/.nvm
ENV NVM_DIR=/usr/local/nvm
# Exposes every global npm install (not just a hardcoded allowlist) without
# per-binary symlinks — same pattern as dotnet/ruby's flavor Dockerfiles.
ENV PATH="$NVM_DIR/versions/node/active/bin:${PATH}"
RUN mkdir -p "$NVM_DIR" \
&& chown -R coder:coder "$NVM_DIR"
USER coder
# Install NVM
RUN curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/refs/heads/master/install.sh" | bash
# Sanity check that it actually landed where expected
RUN test -s "$NVM_DIR/nvm.sh" && echo "nvm.sh found at $NVM_DIR" || (echo "nvm install failed" && exit 1)
RUN bash -c "source $NVM_DIR/nvm.sh \
&& nvm install 20 \
&& nvm alias default 20 \
&& nvm use default \
&& npm install -g @kilocode/cli \
&& npm install -g @openai/codex \
&& ln -sfn \$NVM_DIR/versions/node/\$(nvm version default) \$NVM_DIR/versions/node/active"
RUN echo "[ Installing Claude Code (native installer) ]" \
&& curl -fsSL https://claude.ai/install.sh | bash \
&& "$HOME/.local/bin/claude" --version
# uv/uvx (Astral's Python package manager) — needed for MiniMax's own MCP
# servers (minimax-mcp, minimax-coding-plan-mcp; see the "MiniMax multimodal
# MCP servers" section of devenv/README.md for why these Python packages were
# picked over the community JS port). Installed here as `coder` (same as the
# Claude Code native installer just above) so the binaries land in
# $HOME/.local/bin, then copied out to /usr/local/bin below — same
# outside-/home/coder reasoning as the claude binary copy further down.
RUN echo "[ Installing uv (for MiniMax's official Python MCP servers) ]" \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& "$HOME/.local/bin/uv" --version
# Bake plugins + skills into the image rather than installing them from every
# workspace's startup_script: they land under /home/coder/.claude, and since
# /home/coder is a per-workspace *named* Docker volume (see devenv/main.tf's
# docker_volume.home_volume) that's empty the first time any container mounts
# it, Docker copies this image layer's content into it on that first mount —
# so every freshly created workspace gets these pre-installed for free, with
# zero network calls or plugin-CLI invocations at startup. (Pre-existing
# workspaces' volumes are already non-empty and won't pick this up
# automatically; those need a one-time manual install.)
#
# `claude plugin marketplace update` first: the marketplace ships with a
# baked-in snapshot that's stale relative to GitHub by the time this image
# builds (frontend-design/superpowers/code-review 404'd against it in a real
# workspace — the CLI's own error suggested this exact fix), so an explicit
# refresh is required before install, `marketplace add` alone isn't enough.
# typescript-lsp installed here too (plugin registration only — it needs
# typescript-language-server on PATH to actually do anything, installed a
# few steps down as root; the plugin CLI doesn't touch/require the LSP
# binary at install time, only at use time). Installed here rather than
# alongside it because this whole block runs as `coder`, so it lands
# correctly under /home/coder/.claude with the right ownership — the
# typescript-language-server RUN step below runs as root, and this same
# `claude plugin install` there would write root-owned files into a
# directory the `coder` user needs to write to at runtime (marketplace
# cache updates, enabling/disabling plugins). `superpowers` is left enabled
# (no `plugin disable` call) — on by default for every workspace.
#
# `impeccable` (pbakaus/impeccable) is installed from its own marketplace
# here too, not via its CLI installer: as of v4.0.2 it ships a real
# `.claude-plugin/marketplace.json` + `plugin/.claude-plugin/plugin.json`
# (confirmed against the live repo — this didn't exist when the CLI
# installer was first wired up), so it's a genuine first-party-shaped
# plugin for Claude Code now, same install shape as the official plugins
# above. Marketplace name and plugin name are both `impeccable` (per its
# own marketplace.json), hence `impeccable@impeccable`. Codex still gets it
# via the separate CLI-installer step further down — it has no
# `.codex-plugin/` manifest, so this marketplace only covers Claude Code.
#
# `context7` (upstash/context7) similarly ships a real
# `.claude-plugin/marketplace.json` (marketplace name `context7-marketplace`,
# confirmed via GitHub API), bundling a `context7-mcp` skill and a `/docs`
# command on top of the context7 MCP server registration devenv/main.tf
# already wires up by hand — installing it here is a strict addition, not a
# replacement (Claude Code's MCP precedence is CLI-flag config > plugin
# config, so the plugin's own duplicate "context7" MCP entry is silently
# ignored, no conflict). Purpose: push the agent to actually pull current
# library docs before guessing/trial-and-error-scripting an API surface,
# rather than relying on the bare MCP entry alone. Note its repo layout
# splits Claude's plugin (`plugins/claude/context7`) and Codex's
# (`plugins/codex/context7`) into separate directories neither one's
# `marketplace.json` cross-references, unlike the shared same-directory
# dual-manifest shape claude-plugins-official uses above — so unlike that
# marketplace, `codex plugin add` can't discover this one; Codex keeps using
# the hand-wired MCP entry only, same as before this change.
#
# `playwright` needs no separate `marketplace add` — it's published straight
# from the same `claude-plugins-official` marketplace already added above.
# Confirmed against that marketplace's live marketplace.json: entry `"name":
# "playwright", "source": "./external_plugins/playwright"`, whose
# `.claude-plugin/plugin.json` + `.mcp.json` (`npx @playwright/mcp@latest`)
# duplicate the hand-wired `playwright`/`playwright-visible` MCP entries
# devenv/main.tf already renders. CORRECTION (found live on
# chris/portal-rework, see devenv/README.md's playwright section): "CLI-flag
# config wins over plugin config" only holds when a session is actually
# invoked with `--mcp-config` — any invocation that skips it (a headless/
# background job, an IDE-launched session) falls through to this plugin's
# broken un-flagged default (real Chrome, never installed) instead. Fixed via
# a project-root `.mcp.json` render in main.tf (`claude_project_mcp_config`),
# not anything in this Dockerfile. No `.codex-plugin` manifest exists for
# playwright (confirmed against the repo, same situation as impeccable), so
# Codex keeps using only its own hand-wired entry.
#
# `ralph-loop`, `playground`, `feature-dev`, `serena`, `figma` — all 5
# confirmed real via a direct fetch of claude-plugins-official's own
# marketplace.json (not just trusting search results/subagent summaries,
# same verification standard as every plugin above). `ralph-loop`/
# `playground`/`feature-dev` are pure skill/command/agent plugins with no
# `.mcp.json` at all (confirmed via each plugin's own directory listing).
# Unlike impeccable (whose OWN standalone CLI installer drops plain
# SKILL.md content into ~/.agents/skills/, a directory Kilo also scans by
# default per its own docs — see devenv/README.md's impeccable section for
# the live-confirmed correction), a Claude Code *marketplace plugin*'s
# content stays nested under ~/.claude/plugins/cache/..., never flattened
# into ~/.claude/skills/ or ~/.agents/skills/ — so there's genuinely nothing
# for Kilo's compatibility scan to find here. Claude Code only, no Kilo/
# Codex equivalent exists to wire up, same conclusion as superpowers.
# `serena` (semantic code
# analysis, `uvx --from git+https://github.com/oraios/serena serena
# start-mcp-server` — `uvx` already installed below) and `figma` (Figma's
# own remote MCP, `https://mcp.figma.com/mcp`) both do bundle a real MCP
# server, hand-wired separately into kilo.jsonc.tftpl and
# codex_serena_mcp_toml/codex_figma_mcp_toml in devenv/main.tf for
# cross-harness parity — see those files' comments. Figma's remote MCP
# authenticates via an interactive OAuth browser flow with no documented
# headless alternative; installed anyway for config parity, but not yet
# confirmed to actually complete that handshake inside a display-less
# container. None of these 5 ship a `.codex-plugin` manifest either
# (confirmed against each plugin's directory listing), so `codex plugin add`
# can't discover any of them regardless.
#
# `code-simplifier`, `github`, `claude-security`, `security-guidance` — same
# marketplace, same verification standard as above. Two of these ship hooks
# worth knowing about before debugging surprising behavior: `claude-security`'s
# hook only fires on its own `/claude-security` slash-command menu (a banner,
# no side effects), but `security-guidance` genuinely runs on every session
# unconditionally (SessionStart/UserPromptSubmit/PostToolUse/Stop) — pattern-
# based warnings on every Edit/Write and an LLM-based diff review on Stop and
# on `git commit`/`git push`, which can force Claude to keep going (Stop hook
# exit code 2) until it addresses findings. `github` bundles its own MCP
# server against GitHub's Copilot MCP endpoint, needs the
# `github-personal-access-token` Coder secret before it actually authenticates
# (see devenv/README.md's "Secrets" section) — installed and enabled either
# way, just 401s until that secret exists. None of these 4 ship a
# `.codex-plugin` manifest either, Claude Code only.
RUN CLAUDE="$HOME/.local/bin/claude" \
&& echo "[ Installing Claude Code plugins: frontend-design, superpowers, code-review, typescript-lsp, impeccable, context7, playwright, ralph-loop, playground, feature-dev, serena, figma, code-simplifier, github, claude-security, security-guidance ]" \
&& ( "$CLAUDE" plugin marketplace update claude-plugins-official \
|| "$CLAUDE" plugin marketplace add anthropics/claude-plugins-official ) \
&& "$CLAUDE" plugin install frontend-design@claude-plugins-official \
&& "$CLAUDE" plugin install superpowers@claude-plugins-official \
&& "$CLAUDE" plugin install code-review@claude-plugins-official \
&& "$CLAUDE" plugin install typescript-lsp@claude-plugins-official \
&& "$CLAUDE" plugin install playwright@claude-plugins-official \
&& "$CLAUDE" plugin install ralph-loop@claude-plugins-official \
&& "$CLAUDE" plugin install playground@claude-plugins-official \
&& "$CLAUDE" plugin install feature-dev@claude-plugins-official \
&& "$CLAUDE" plugin install serena@claude-plugins-official \
&& "$CLAUDE" plugin install figma@claude-plugins-official \
&& "$CLAUDE" plugin install code-simplifier@claude-plugins-official \
&& "$CLAUDE" plugin install github@claude-plugins-official \
&& "$CLAUDE" plugin install claude-security@claude-plugins-official \
&& "$CLAUDE" plugin install security-guidance@claude-plugins-official \
&& "$CLAUDE" plugin marketplace add pbakaus/impeccable \
&& "$CLAUDE" plugin install impeccable@impeccable \
&& "$CLAUDE" plugin marketplace add upstash/context7 \
&& "$CLAUDE" plugin install context7@context7-marketplace
# Codex CLI plugins — same claude-plugins-official marketplace as the Claude
# Code block above. Its plugins ship a `.codex-plugin/plugin.json` manifest
# alongside `.claude-plugin/plugin.json` — confirmed live: `codex plugin add
# frontend-design@claude-plugins-official` installs cleanly, and
# typescript-lsp's `.codex-plugin/plugin.json` declares a native
# `lspServers` entry pointing at the same typescript-language-server this
# image installs below — this is a real first-party cross-agent
# marketplace, not something scraped or repurposed. Lands under
# /home/coder/.codex, the same per-workspace named volume as Claude's
# ~/.claude — same bake-into-the-image reasoning as the Claude block above.
# `codex` resolves via $NVM_DIR/versions/node/active/bin already on PATH
# (see ENV PATH at the top of this file), no full path needed the way
# Claude's native installer requires "$HOME/.local/bin/claude".
#
# No `codex plugin disable` subcommand exists (confirmed against `codex
# plugin --help`/`add --help`/`remove --help`) — a plugin's enabled state
# lives entirely in config.toml's `[plugins."x@y"].enabled` key, which
# devenv/main.tf's codex_config re-renders on every codex_config_version
# bump anyway (same enabledPlugins-style fix already applied for Claude
# Code), so superpowers' enabled state is set there instead of here (on by
# default, same as Claude Code).
RUN echo "[ Installing Codex CLI plugins: frontend-design, superpowers, code-review, typescript-lsp ]" \
&& ( codex plugin marketplace upgrade claude-plugins-official \
|| codex plugin marketplace add anthropics/claude-plugins-official ) \
&& codex plugin add frontend-design@claude-plugins-official \
&& codex plugin add superpowers@claude-plugins-official \
&& codex plugin add code-review@claude-plugins-official \
&& codex plugin add typescript-lsp@claude-plugins-official
# mcp-builder / web-artifacts-builder: plain SKILL.md directories from the
# public anthropics/skills repo, not plugins, so fetched via tarball straight
# into ~/.claude/skills/ instead of through the plugin CLI. Same
# baked-into-the-image reasoning as the plugins above.
RUN mkdir -p "$HOME/.claude/skills" \
&& curl -sL "https://github.com/anthropics/skills/archive/refs/heads/main.tar.gz" -o /tmp/anthropic-skills.tar.gz \
&& tar -xzf /tmp/anthropic-skills.tar.gz -C "$HOME/.claude/skills" --strip-components=2 \
skills-main/skills/mcp-builder skills-main/skills/web-artifacts-builder \
&& rm -f /tmp/anthropic-skills.tar.gz
# impeccable (pbakaus/impeccable) for Codex only — its own official CLI
# installer, not a plugin marketplace: unlike Claude Code (installed as a
# real marketplace plugin above), it still ships no `.codex-plugin/`
# manifest (confirmed against the repo), so `codex plugin add` can't see it
# the way frontend-design/superpowers/etc. above can; the CLI installer is
# the only path for Codex. `--scope=global` writes plain SKILL.md content to
# ~/.agents/skills/impeccable (confirmed live — Codex CLI reads skills from
# $HOME/.agents/skills globally, per its own docs), landing in the same
# per-workspace home volume as everything else in this block, so it's free
# on first mount. `--providers=codex` only: Claude Code would also accept
# `claude` here, but that'd double-install the skill content the
# `impeccable@impeccable` marketplace plugin above already provides
# (its plugin.json bundles its own `./.claude/skills/`) — no need for both.
# `--no-hooks` skips the optional per-project PostToolUse/Stop design-check
# hook (`.codex/hooks.json`) — it's scoped to whatever directory the
# installer runs in, which here is the image build context, not any real
# future project, and Codex's hook additionally needs an interactive
# per-project `/hooks` trust approval that doesn't fit a headless build
# step; the skill itself (the `/impeccable` commands) works fully without
# it. Package declares `engines.node >= 22.12.0`; this image's default Node
# is 20 (see `nvm install 20` above) — confirmed live that this is only an
# npm engine warning, not a hard failure, so no separate Node version is
# provisioned just for this one build-time install.
RUN bash -c "source $NVM_DIR/nvm.sh \
&& npx --yes impeccable@latest install --providers=codex --scope=global --yes --no-hooks"
USER root
# Copy (not symlink — the home copy may get overwritten by auto-update,
# self-update, or a stale/older volume) into image-owned territory so a
# fresh or stale workspace always has a working claude binary immediately
RUN cp "/home/coder/.local/bin/claude" /usr/local/bin/claude \
&& chmod +x /usr/local/bin/claude \
&& /usr/local/bin/claude --version
# Same copy-out reasoning as the claude binary above, for uv/uvx.
RUN cp "/home/coder/.local/bin/uv" /usr/local/bin/uv \
&& cp "/home/coder/.local/bin/uvx" /usr/local/bin/uvx \
&& chmod +x /usr/local/bin/uv /usr/local/bin/uvx \
&& /usr/local/bin/uv --version
# LSP servers for Kilo's "lsp": true config — bash and typescript are common
# enough across every devbox flavor to belong here rather than per-flavor.
# (typescript-lsp@claude-plugins-official, Claude Code's equivalent, is
# installed above in the USER-coder block, not here — see that comment.
# Language-specific "-lsp" plugins for ruby/php/csharp are installed the
# same way in their own flavor Dockerfiles instead, since those languages
# aren't in every image.)
RUN bash -c "source $NVM_DIR/nvm.sh \
&& npm install -g bash-language-server typescript-language-server typescript"
# Playwright MCP server (for kilo.jsonc's "playwright" MCP entry). Browsers go to
# /opt/ms-playwright (not ~/.cache/ms-playwright under /home/coder) since that
# home directory is a per-workspace persistent volume that shadows anything
# baked into the image after its first-ever creation — see kilo.jsonc.tftpl notes.
#
# Only @playwright/mcp is installed globally — NOT a separate top-level
# `playwright` package. @playwright/mcp pins its own playwright-core version
# (currently a pre-release, ahead of whatever `npm install -g playwright`
# resolves to "latest"), and the two disagree on browser revision numbers: a
# separately-installed playwright downloads a revision @playwright/mcp doesn't
# recognize, so the MCP server errors "Browser is not installed" even though a
# browser is very much on disk. Installing/installing-deps through
# @playwright/mcp's own bundled playwright-core CLI keeps both in lockstep.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN bash -c "source $NVM_DIR/nvm.sh \
&& npm install -g @playwright/mcp \
&& PW_CORE_CLI=\$(find \$NVM_DIR/versions/node/active/lib/node_modules/@playwright/mcp -path '*playwright-core/cli.js' | head -1) \
&& node \"\$PW_CORE_CLI\" install-deps chromium \
&& node \"\$PW_CORE_CLI\" install chromium" \
&& chmod -R a+rX /opt/ms-playwright
# Chrome DevTools MCP server (Google's official chrome-devtools-mcp, for
# kilo.jsonc/claude-mcp.json's "chrome-devtools" MCP entry — DevTools
# protocol access: console, network, performance traces, not just
# navigate/click/screenshot like Playwright). Same non-home-directory
# reasoning as Playwright above: Chrome for Testing goes to
# /opt/puppeteer-cache (not ~/.cache/puppeteer under /home/coder), which
# chrome-devtools-mcp picks up automatically via PUPPETEER_CACHE_DIR — no
# --executablePath needed, confirmed live (a real MCP `initialize` handshake
# over stdio against a container built from this exact image, headless
# Chrome launched and responded, no --no-sandbox required since it runs as
# the unprivileged `coder` user, not root).
ENV PUPPETEER_CACHE_DIR=/opt/puppeteer-cache
RUN bash -c "source $NVM_DIR/nvm.sh \
&& npm install -g chrome-devtools-mcp \
&& npx --yes puppeteer browsers install chrome --path /opt/puppeteer-cache" \
&& chmod -R a+rX /opt/puppeteer-cache
# mcp-telegram (MTProto-based Telegram MCP server) — installed so the binary
# is available on PATH, deliberately NOT wired into kilo.jsonc/mcp.json:
# using it for real needs a Telegram API ID/hash and an interactive
# device-login flow, which doesn't belong baked into a shared image or
# secrets file the way an API key does. Whoever wants it configures and adds
# the MCP entry themselves, per-workspace, when they actually need it.
# Caveat: pulls in `telegram` (gramjs), which upstream has archived in favor
# of the `teleproto` fork — fine for "available but unused," worth
# re-evaluating if this actually gets configured for real use.
RUN bash -c "source $NVM_DIR/nvm.sh && npm install -g mcp-telegram"
# MiniMax's official multimodal MCP servers — minimax-mcp (image/video/tts/
# voice-clone/voice-design/music generation) and minimax-coding-plan-mcp
# (Token-Plan-exclusive web_search + image understanding), both authoritative
# Python packages per MiniMax's own docs (see devenv/README.md's "MiniMax
# multimodal MCP servers" section for why these were picked over the
# community JS port). UV_TOOL_DIR/UV_TOOL_BIN_DIR keep the installed venvs
# and shims outside /home/coder — same reasoning as PLAYWRIGHT_BROWSERS_PATH/
# PUPPETEER_CACHE_DIR above: it's the only way a rebuilt image actually
# reaches workspaces whose /home/coder volume already exists.
ENV UV_TOOL_DIR=/opt/uv-tools \
UV_TOOL_BIN_DIR=/opt/uv-tools/bin
ENV PATH="/opt/uv-tools/bin:${PATH}"
RUN uv tool install minimax-mcp \
&& uv tool install minimax-coding-plan-mcp \
&& chmod -R a+rX /opt/uv-tools
# Dokku CLI — a small custom wrapper (not the official dokku/dokku
# contrib/dokku_client.sh) that lets any workspace run
# `dokku <command> --app <name>` directly against the Dokku PaaS on this
# same host (stacks/harry/dokku/), not just `git push dokku-demo`. The
# official client was tried first and rejected: it always shells out to a
# literal `ssh` binary, but this environment has no live ssh-agent socket
# and no on-disk SSH identity file for it to authenticate with — confirmed
# live in a real workspace (`ssh-add -l` and a bare `ssh -T git@github.com`
# both fail with no agent/no identity). The one thing that DOES
# authenticate here is Coder's own GIT_SSH_COMMAND proxy
# (`<coder-agent-binary> gitssh --`, injected as an agent-wide env var by
# Coder itself, fetching the real per-user key from the Coder control plane
# on demand) — that's what `git clone`/`git push dokku-demo` actually use
# under the hood, not a plain ssh-agent identity, contrary to what was
# originally assumed here. Vendoring the upstream script and patching its
# one `ssh` call was considered and rejected too: it's re-fetched fresh from
# `master` on every image rebuild, so a sed-based patch could silently stop
# matching upstream content on some future rebuild. dokku-cli.sh is a
# handful of lines, fully owned, and calls `coder gitssh` directly instead —
# confirmed live end-to-end against the real dokku container (`dokku
# apps:list` returned real app names through this exact path).
#
# App auto-detection from a git remote (the official client's main value
# beyond plain ssh) wouldn't have worked here regardless: this template's
# dokku-demo remote is deliberately written as "dokku-demo:<app>" with no
# literal "dokku@" prefix (auth comes from the `User dokku` directive on the
# dokku-demo SSH alias instead — see main.tf), which never matches a
# `dokku@host:app`-shaped parser. So every invocation needs an explicit
# --app <name> either way (the app name is always the workspace's own
# lowercased name) — documented in devbox-agents.md.
ENV DOKKU_HOST=dokku
COPY ./dokku-cli.sh /usr/local/bin/dokku
RUN chmod +x /usr/local/bin/dokku
# Lives outside /home/coder (like vnc-startup.sh) so it always reflects the
# current image, not whatever was baked into a volume on its first-ever
# creation.
COPY ./kilo/acp-bridge-listen.sh /usr/local/bin/acp-bridge-listen.sh
COPY ./kilo/acp-bridge-auth.py /usr/local/bin/acp-bridge-auth.py
COPY ./kilo/peer-mcp-bridge.py /usr/local/bin/peer-mcp-bridge.py
RUN chmod +x /usr/local/bin/acp-bridge-listen.sh /usr/local/bin/acp-bridge-auth.py /usr/local/bin/peer-mcp-bridge.py
# Claude Code's side of the peer bridge — see claude/claude-peer-listen.sh
# for why this is a headless `claude -p` runner rather than an ACP dance
# (Claude Code has no stdio agent-protocol server the way `kilo acp` does).
# Reuses acp-bridge-auth.py unchanged as the token gate for both bridges.
COPY ./claude/claude-peer-listen.sh /usr/local/bin/claude-peer-listen.sh
COPY ./claude/claude-peer-runner.py /usr/local/bin/claude-peer-runner.py
COPY ./claude/peer-mcp-bridge-claude.py /usr/local/bin/peer-mcp-bridge-claude.py
RUN chmod +x /usr/local/bin/claude-peer-listen.sh /usr/local/bin/claude-peer-runner.py /usr/local/bin/peer-mcp-bridge-claude.py
# Codex CLI's side of the peer bridge — see codex/codex-peer-listen.sh for
# why this one EXECs straight into `codex mcp-server` (no runner script
# needed, unlike Claude Code's): `codex mcp-server` already speaks
# newline-delimited JSON-RPC on stdio, the same shape `kilo acp` uses.
# Reuses acp-bridge-auth.py unchanged as the token gate for all three bridges.
COPY ./codex/codex-peer-listen.sh /usr/local/bin/codex-peer-listen.sh
COPY ./codex/peer-mcp-bridge-codex.py /usr/local/bin/peer-mcp-bridge-codex.py
RUN chmod +x /usr/local/bin/codex-peer-listen.sh /usr/local/bin/peer-mcp-bridge-codex.py
USER coder
@@ -0,0 +1,45 @@
#!/bin/bash
# Exposes this workspace's Claude Code as a token-gated TCP listener so peer
# workspaces can hand it a prompt and get back a real, autonomous run's final
# result — the Claude Code equivalent of acp-bridge-listen.sh's kilo bridge.
#
# Unlike `kilo acp`, Claude Code has no stdio agent-protocol server of its
# own to expose. The closest headless equivalent is `claude -p` (print
# mode), which takes its prompt as an argv string rather than a JSON-RPC
# stream and returns one clean `--output-format json` object on exit — no
# ACP handshake, no per-call model/cwd negotiation needed. So instead of
# socat EXEC'ing straight into the target binary (as acp-bridge-listen.sh
# does for `kilo acp`), it EXEC's into claude-peer-runner.py, which reads
# the prompt off the wire and invokes `claude -p` as a real argv (no shell
# quoting to worry about).
#
# No second "info port" (contrast acp-bridge-listen.sh's port 4098) is
# needed either: ACP's session/new takes an explicit `cwd` param the caller
# must supply, but `claude -p` just runs wherever it's invoked, and this
# script already `cd`s to PROJECT_DIR before starting socat — EXEC'd
# children inherit that cwd.
#
# Reuses acp-bridge-auth.py unchanged for the shared-secret token gate
# (PEER_BRIDGE_TOKEN) — it already just execs whatever command it's given
# after the token line matches, nothing kilo-specific about it.
#
# Lives outside /home/coder — see vnc-startup.sh header comment for why.
set -e
CLAUDE_PEER_PORT="${1:-4197}"
PROJECT_DIR="${PROJECT_DIR:-/home/coder}"
mkdir -p ~/.local/log ~/.local/run
if [ -f ~/.local/run/claude-peer-bridge.pid ]; then
kill "$(cat ~/.local/run/claude-peer-bridge.pid)" 2>/dev/null || true
fi
cd "$PROJECT_DIR"
setsid nohup socat TCP-LISTEN:"$CLAUDE_PEER_PORT",fork,reuseaddr \
EXEC:"python3 /usr/local/bin/acp-bridge-auth.py python3 /usr/local/bin/claude-peer-runner.py" \
> ~/.local/log/claude-peer-bridge.log 2>&1 < /dev/null &
echo $! > ~/.local/run/claude-peer-bridge.pid
echo "[ Claude peer bridge listening on :$CLAUDE_PEER_PORT ]"
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# Sits between claude-peer-listen.sh's socat listener and headless Claude
# Code. acp-bridge-auth.py has already consumed exactly the token line from
# the connection before exec'ing into this script, so everything that
# follows on stdin is the peer's prompt: a 4-byte big-endian length prefix,
# then exactly that many bytes of prompt text.
#
# Confirmed live against real workspace containers: length-prefixing is
# load-bearing, not a style choice. The original design read the prompt via
# "read until EOF," with the client signaling end-of-prompt by
# half-closing its write side (`shutdown(SHUT_WR)`). That half-close made
# socat tear the connection down (or stop relaying the exec'd child's
# output — not fully diagnosed which) the moment the child's response took
# more than an instant to produce, independent of everything else: not
# claude-specific, not about subprocess vs exec, not about which fds the
# child inherited. A fast, purely-local response (no real work) slipped
# out before whatever socat does on seeing that half-close; anything doing
# real work (in Claude Code's case, an actual authenticated API call)
# consistently got nothing back — confirmed by reproducing the identical
# failure with a plain `sleep N; echo` script that never touched Claude
# Code or this project's own scripts at all. A length prefix means the
# reader knows exactly how many bytes to expect, so the client never needs
# to half-close the connection while a response is still pending.
#
# The prompt is passed to `claude -p` as a single real argv element (never
# through a shell), which is what avoids the quoting/injection hazard a
# naive `EXEC:"claude -p $PROMPT"` would have. cwd is whatever
# claude-peer-listen.sh already `cd`'d to before starting socat — Claude
# Code has no per-invocation cwd param to pass the way ACP's session/new
# does, it just runs wherever it's invoked.
#
# --mcp-config points at this workspace's own rendered MCP config (see
# devenv/main.tf's claude_config rendering) so the peer's run gets the same
# tools (context7, playwright, its own further peers, ...) a normal
# in-workspace Claude Code session would — not a bare, tool-less run.
#
# ANTHROPIC_API_KEY is deliberately stripped from claude's environment
# below. The container sets it for Kilo (routed through MiniMax's
# Anthropic-compatible endpoint), but Claude Code's own auth strategy
# checks ANTHROPIC_API_KEY *before* CLAUDE_CODE_OAUTH_TOKEN — so if it's
# left inherited, Claude Code silently bills through that key instead of
# using the subscription-plan OAuth token this deployment intends it to
# use. See README's "Claude Code peer bridge" / auth section.
#
# Uses os.execvpe — a single in-place exec, same shape as acp-bridge-auth.py's
# own exec into this script — rather than subprocess.run(), which hung
# indefinitely as a second fork (claude as a grandchild of socat's
# connection handler, rather than its direct EXEC target); plain binaries
# (echo, cat) were unaffected by the same setup. execvpe replaces this
# process outright, one generation from socat, and lets claude's own
# stdout/stderr become the socket directly — no capturing/relaying code
# needed. stdin is explicitly pointed at /dev/null first since claude
# never reads it anyway (the prompt is the `-p` argv above).
import json
import os
import struct
import sys
MCP_CONFIG = "/home/coder/.config/claude/mcp.json"
def read_exact(n):
buf = b""
while len(buf) < n:
chunk = os.read(0, n - len(buf))
if not chunk:
break
buf += chunk
return buf
def main():
length_bytes = read_exact(4)
if len(length_bytes) != 4:
print(json.dumps({"is_error": True, "result": "malformed request (missing length prefix)"}))
return
(length,) = struct.unpack("!I", length_bytes)
prompt = read_exact(length).decode(errors="replace")
if not prompt.strip():
print(json.dumps({"is_error": True, "result": "empty prompt"}))
return
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
devnull_fd = os.open(os.devnull, os.O_RDONLY)
os.dup2(devnull_fd, 0)
os.close(devnull_fd)
# `-p`/`--print` consumes the very next argv token as the prompt itself,
# not a trailing positional independent of flag order — confirmed by
# testing: putting other flags between `-p` and the prompt made `-p`
# swallow the next flag's name as prompt text instead, and everything
# after cascaded into nonsense. Every other flag goes first.
argv = [
"claude",
"--permission-mode", "bypassPermissions",
"--output-format", "json",
"--mcp-config", MCP_CONFIG,
"-p", prompt,
]
try:
os.execvpe("claude", argv, env)
except OSError as e:
print(json.dumps({"is_error": True, "result": f"failed to launch claude: {e}"}))
if __name__ == "__main__":
main()
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
# MCP stdio server exposing one tool ("ask") that hands a prompt to a peer
# workspace's Claude Code over TCP and returns its final result — the
# Claude Code counterpart to kilo/peer-mcp-bridge.py.
#
# Claude Code has no live agent-protocol session to drive the way `kilo
# acp` gives peer-mcp-bridge.py (no session/new, no streamed tool_call
# updates, no mid-run permission callback to answer). Instead this talks to
# claude-peer-listen.sh's socat listener, which runs a single headless
# `claude -p --permission-mode bypassPermissions` invocation per connection
# via claude-peer-runner.py and returns one `--output-format json` object
# once that run finishes. So this bridge is simpler than the kilo one: send
# the token, send the length-prefixed prompt, read the response until the
# peer closes its end, and parse the one JSON object out of it.
#
# The prompt is length-prefixed (4-byte big-endian byte count, then that
# many bytes), NOT terminated by half-closing the connection
# (`shutdown(SHUT_WR)`) the way an earlier version of this did. Confirmed
# live against real workspace containers: that half-close made socat tear
# the connection down (or stop relaying output — not fully diagnosed
# which) the instant the far side's response took more than an instant to
# produce, regardless of what was actually running there — reproduced with
# a plain `sleep N; echo` script that never touched Claude Code at all. A
# real `claude -p` run doing actual work is never "an instant," so
# half-closing here would silently and consistently return nothing.
import argparse
import json
import socket
import struct
import sys
TOOL_NAME = "ask"
def read_stdin_messages():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def write_message(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def send_response(msg_id, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": msg_id}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
write_message(msg)
class PeerConnectionError(Exception):
pass
class ClaudePeer:
def __init__(self, host, port, timeout, token):
self.host = host
self.port = port
self.timeout = timeout
self.token = token
def ask(self, prompt_text):
try:
sock = socket.create_connection((self.host, self.port), timeout=15)
except OSError as e:
raise PeerConnectionError(f"could not reach peer Claude bridge {self.host}:{self.port}: {e}")
with sock:
sock.settimeout(self.timeout)
sock.sendall((self.token + "\n").encode())
prompt_bytes = prompt_text.encode()
sock.sendall(struct.pack("!I", len(prompt_bytes)))
sock.sendall(prompt_bytes)
chunks = []
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
except socket.timeout:
raise TimeoutError(f"peer did not finish within {self.timeout}s")
raw = b"".join(chunks).decode(errors="replace").strip()
if not raw:
raise PeerConnectionError("peer closed the connection with no response (bad token?)")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return raw # fall back to raw text rather than hide a malformed-but-useful reply
if payload.get("is_error"):
raise PeerConnectionError(payload.get("result", "peer reported an error"))
return payload.get("result") or "(peer produced no text response)"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", required=True)
ap.add_argument("--port", type=int, default=4197)
ap.add_argument("--peer-name", required=True)
ap.add_argument("--timeout", type=int, default=900)
args = ap.parse_args()
# Read from the environment (same as kilo's peer-mcp-bridge.py) rather
# than a CLI flag, so the shared secret never ends up in the rendered
# MCP config's command array or in `ps` output.
import os
token = os.environ.get("PEER_BRIDGE_TOKEN", "")
if not token:
print(json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "PEER_BRIDGE_TOKEN not set"}}))
sys.exit(1)
peer = ClaudePeer(args.host, args.port, args.timeout, token)
for msg in read_stdin_messages():
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
client_version = msg.get("params", {}).get("protocolVersion", "2024-11-05")
send_response(msg_id, result={
"protocolVersion": client_version,
"capabilities": {"tools": {}},
"serverInfo": {"name": f"claude-peer-bridge-{args.peer_name}", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(msg_id, result={"tools": [{
"name": TOOL_NAME,
"description": (
f"Ask the '{args.peer_name}' workspace's Claude Code agent a question about "
"its project, or hand it a task to work on. It runs with full autonomy in its "
"own project directory (reads/edits/writes files, runs bash) and returns its "
"final response once done. Each call starts a fresh session on the peer's "
"side — include any needed context in the prompt itself."
),
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "The question or task for the peer agent."},
},
"required": ["prompt"],
},
}]})
elif method == "tools/call":
params = msg.get("params", {})
if params.get("name") != TOOL_NAME:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Unknown tool: {params.get('name')}"}],
"isError": True,
})
continue
prompt_text = params.get("arguments", {}).get("prompt", "")
try:
reply = peer.ask(prompt_text)
send_response(msg_id, result={"content": [{"type": "text", "text": reply}], "isError": False})
except Exception as e:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Peer request failed: {e}"}],
"isError": True,
})
elif msg_id is not None:
send_response(msg_id, error={"code": -32601, "message": f"Method not found: {method}"})
# else: unrecognized notification, ignore
if __name__ == "__main__":
main()
@@ -0,0 +1,40 @@
#!/bin/bash
# Exposes this workspace's Codex CLI as a token-gated TCP listener speaking
# `codex mcp-server`'s native MCP-over-stdio protocol directly — no runner
# script needed, unlike claude-peer-listen.sh. `codex mcp-server` already
# reads/writes newline-delimited JSON-RPC on stdio, the same shape `kilo acp`
# uses, so this mirrors acp-bridge-listen.sh far more closely than
# claude-peer-listen.sh: socat EXEC's straight into
# `acp-bridge-auth.py codex mcp-server`, same as the kilo bridge does for
# `kilo acp`.
#
# No second "info port" (contrast acp-bridge-listen.sh's port 4098): the
# `codex` MCP tool's own `cwd` argument is optional and, left unset, resolves
# against the server process's own cwd — so this script just `cd`s to
# PROJECT_DIR before starting socat (same trick claude-peer-listen.sh uses),
# and every fork'd `codex mcp-server` inherits that cwd automatically.
#
# Reuses acp-bridge-auth.py unchanged for the shared-secret token gate
# (PEER_BRIDGE_TOKEN) — it already just execs whatever command it's given
# after the token line matches, nothing kilo- or codex-specific about it.
#
# Lives outside /home/coder — see vnc-startup.sh header comment for why.
set -e
CODEX_PEER_PORT="${1:-4297}"
PROJECT_DIR="${PROJECT_DIR:-/home/coder}"
mkdir -p ~/.local/log ~/.local/run
if [ -f ~/.local/run/codex-peer-bridge.pid ]; then
kill "$(cat ~/.local/run/codex-peer-bridge.pid)" 2>/dev/null || true
fi
cd "$PROJECT_DIR"
setsid nohup socat TCP-LISTEN:"$CODEX_PEER_PORT",fork,reuseaddr \
EXEC:"python3 /usr/local/bin/acp-bridge-auth.py codex mcp-server" \
> ~/.local/log/codex-peer-bridge.log 2>&1 < /dev/null &
echo $! > ~/.local/run/codex-peer-bridge.pid
echo "[ Codex peer bridge listening on :$CODEX_PEER_PORT ]"
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# MCP stdio server exposing one tool ("ask") that hands a prompt to a peer
# workspace's Codex CLI over TCP and returns its final result — the Codex
# counterpart to kilo/peer-mcp-bridge.py and claude/peer-mcp-bridge-claude.py.
#
# Unlike Claude Code, Codex actually has a native MCP server mode
# (`codex mcp-server`) that speaks real, tool-capable MCP over stdio — the
# closest of the three to kilo acp's ACP session, and simpler to bridge than
# Claude Code's headless -p (no custom length-prefix framing needed: this is
# already ordinary line-delimited JSON-RPC, same as ACP). So this script is a
# genuine MCP *client* against the peer's codex-peer-listen.sh socat
# listener: send the token, do a normal MCP initialize handshake, then call
# the "codex" tool with approval-policy "never" and sandbox
# "danger-full-access" — this workspace's own container is already the
# isolation boundary, same justification as Claude Code's bypassPermissions
# and kilo.jsonc's "permission": "allow" (see README). Model is deliberately
# left unset in the tool call so it falls back to config.toml's own
# model/model_provider default (MiniMax M3) rather than duplicating it here.
#
# The "codex" tool call can, in principle, still send server->client
# approval requests (execCommandApproval / applyPatchApproval) mid-call —
# not expected to actually fire with approval-policy "never", but handled
# defensively (auto-"allow") the same way kilo's session/request_permission
# is, rather than assuming it never will.
import argparse
import json
import os
import socket
import sys
import time
TOOL_NAME = "ask"
def read_stdin_messages():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def write_message(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def send_response(msg_id, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": msg_id}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
write_message(msg)
class PeerConnectionError(Exception):
pass
class CodexPeer:
def __init__(self, host, port, timeout, token):
self.host = host
self.port = port
self.timeout = timeout
self.token = token
def ask(self, prompt_text):
deadline = time.time() + self.timeout
try:
sock = socket.create_connection((self.host, self.port), timeout=15)
except OSError as e:
raise PeerConnectionError(f"could not reach peer Codex bridge {self.host}:{self.port}: {e}")
with sock:
sock.settimeout(self.timeout)
rf = sock.makefile("r")
wf = sock.makefile("w")
wf.write(self.token + "\n")
wf.flush()
def send(msg):
wf.write(json.dumps(msg) + "\n")
wf.flush()
def recv():
line = rf.readline()
if not line:
return None
return json.loads(line)
def await_response(expected_id):
while True:
if time.time() > deadline:
raise TimeoutError(f"peer did not finish within {self.timeout}s")
obj = recv()
if obj is None:
raise PeerConnectionError("peer connection closed unexpectedly")
# A genuine response to our own request has this id and
# no "method" (requests/notifications always carry one,
# even if a numeric id happens to collide with ours).
if obj.get("id") == expected_id and "method" not in obj:
if "error" in obj:
raise PeerConnectionError(f"peer error: {obj['error']}")
return obj
if obj.get("method") in ("execCommandApproval", "applyPatchApproval"):
send({"jsonrpc": "2.0", "id": obj["id"], "result": {"decision": "allow"}})
# else: ignore interleaved notifications (codex/event, etc.)
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "codex-peer-bridge", "version": "1.0.0"}}})
await_response(1)
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "codex", "arguments": {
"prompt": prompt_text,
"approval-policy": "never",
"sandbox": "danger-full-access",
}}})
resp = await_response(2)
result = resp.get("result", {})
structured = result.get("structuredContent") or {}
if structured.get("content"):
return structured["content"]
text_parts = [
block.get("text", "")
for block in result.get("content", [])
if block.get("type") == "text"
]
reply = "".join(text_parts).strip()
return reply or "(peer produced no text response)"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", required=True)
ap.add_argument("--port", type=int, default=4297)
ap.add_argument("--peer-name", required=True)
ap.add_argument("--timeout", type=int, default=900)
args = ap.parse_args()
# Read from the environment (same as kilo's/Claude's peer-mcp-bridge
# scripts) rather than a CLI flag, so the shared secret never ends up in
# config.toml's mcp_servers.*.args array or in `ps` output.
token = os.environ.get("PEER_BRIDGE_TOKEN", "")
if not token:
print(json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "PEER_BRIDGE_TOKEN not set"}}))
sys.exit(1)
peer = CodexPeer(args.host, args.port, args.timeout, token)
for msg in read_stdin_messages():
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
client_version = msg.get("params", {}).get("protocolVersion", "2024-11-05")
send_response(msg_id, result={
"protocolVersion": client_version,
"capabilities": {"tools": {}},
"serverInfo": {"name": f"codex-peer-bridge-{args.peer_name}", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(msg_id, result={"tools": [{
"name": TOOL_NAME,
"description": (
f"Ask the '{args.peer_name}' workspace's Codex agent a question about its "
"project, or hand it a task to work on. It runs with full autonomy in its "
"own project directory (reads/edits/writes files, runs commands) and returns "
"its final response once done. Each call starts a fresh Codex thread on the "
"peer's side — include any needed context in the prompt itself."
),
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "The question or task for the peer agent."},
},
"required": ["prompt"],
},
}]})
elif method == "tools/call":
params = msg.get("params", {})
if params.get("name") != TOOL_NAME:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Unknown tool: {params.get('name')}"}],
"isError": True,
})
continue
prompt_text = params.get("arguments", {}).get("prompt", "")
try:
reply = peer.ask(prompt_text)
send_response(msg_id, result={"content": [{"type": "text", "text": reply}], "isError": False})
except Exception as e:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Peer request failed: {e}"}],
"isError": True,
})
elif msg_id is not None:
send_response(msg_id, error={"code": -32601, "message": f"Method not found: {method}"})
# else: unrecognized notification, ignore
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Minimal Dokku remote-CLI wrapper for this Coder devbox environment — see
# base-config/Dockerfile's own comment for why this replaced the official
# dokku/dokku contrib/dokku_client.sh: that script always shells out to a
# literal `ssh` binary, which has nothing to authenticate with here (no
# live ssh-agent socket, no on-disk identity file — confirmed live: both
# `ssh-add -l` and a bare `ssh -T git@github.com` fail in a real
# workspace). Every outbound SSH connection that actually works here (git
# clones, the dokku-demo git remote push) goes through Coder's own
# GIT_SSH_COMMAND proxy instead (`<coder-agent-binary> gitssh --`), which
# Coder injects as an agent-wide env var and which fetches the real
# per-user key from the Coder control plane on demand.
set -euo pipefail
if [[ -z "${GIT_SSH_COMMAND:-}" ]]; then
echo "dokku: \$GIT_SSH_COMMAND is not set -- this only works inside a Coder-managed workspace" >&2
exit 1
fi
# GIT_SSH_COMMAND is Coder's own "<agent-binary-path> gitssh --" string;
# only the binary path is reused here, since the argv below is built from
# scratch rather than reusing the whole templated command line.
CODER_BIN="${GIT_SSH_COMMAND%% *}"
exec "$CODER_BIN" gitssh -- -p "${DOKKU_PORT:-22}" -t "dokku@${DOKKU_HOST:-dokku}" "$@"
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# Gate for acp-bridge-listen.sh's socat listeners: reads exactly one line
# (unbuffered — a normal buffered readline could silently swallow bytes
# belonging to the real protocol stream that follows, which would be lost on
# exec) from the connection and compares it to PEER_BRIDGE_TOKEN. Only on a
# match does it exec into the real target, inheriting the same socket fds so
# everything after the token line passes through untouched.
#
# Without this, acp-bridge-listen.sh's ACP port would be a fully
# auto-approving agent shell (bash, edit, write) reachable, unauthenticated,
# by anything on the same docker network — not just the specific peer
# workspace it's meant for.
import os
import sys
def read_line_unbuffered():
buf = b""
fd = sys.stdin.fileno()
while True:
b = os.read(fd, 1)
if not b or b == b"\n":
break
buf += b
return buf.decode(errors="replace")
def main():
if len(sys.argv) < 2:
print("usage: acp-bridge-auth.py <command> [args...]", file=sys.stderr)
print(" acp-bridge-auth.py --print-env <VAR_NAME>", file=sys.stderr)
sys.exit(1)
expected = os.environ.get("PEER_BRIDGE_TOKEN", "")
got = read_line_unbuffered()
if not expected or got != expected:
sys.exit(1)
# --print-env avoids threading a shell one-liner through socat's own EXEC
# argument quoting just to echo an already-inherited env var.
if sys.argv[1] == "--print-env":
print(os.environ.get(sys.argv[2], ""))
sys.exit(0)
os.execvp(sys.argv[1], sys.argv[1:])
if __name__ == "__main__":
main()
@@ -0,0 +1,47 @@
#!/bin/bash
# Exposes this workspace's `kilo acp` (Agent Client Protocol) agent session
# over a plain TCP socket so peer workspaces can drive a real, tool-capable
# Kilo session here — ACP itself only speaks JSON-RPC over stdio, with no
# network transport of its own. socat's `fork` spawns a fresh `kilo acp`
# process per connection, so concurrent peer requests get fully isolated
# sessions.
#
# A second, tiny listener answers with this workspace's actual project
# directory as plain text. ACP's session/new takes a `cwd` param that
# genuinely governs where tool calls (bash, edit, read, ...) execute —
# confirmed by test: it overrides the spawned process's real OS cwd, it's
# not just informational — and the calling peer has no other way to learn
# this workspace's project path.
#
# Both listeners are gated behind acp-bridge-auth.py, which requires a
# shared-secret token (PEER_BRIDGE_TOKEN) as the connection's first line
# before proceeding — without it, this would be a fully auto-approving agent
# shell (bash, edit, write) reachable, unauthenticated, by anything on the
# same docker network, not just the intended peer.
#
# Lives outside /home/coder — see vnc-startup.sh header comment for why.
set -e
ACP_PORT="${1:-4097}"
INFO_PORT="${2:-4098}"
PROJECT_DIR="${PROJECT_DIR:-/home/coder}"
mkdir -p ~/.local/log ~/.local/run
for name in acp-bridge acp-bridge-info; do
if [ -f ~/.local/run/$name.pid ]; then
kill "$(cat ~/.local/run/$name.pid)" 2>/dev/null || true
fi
done
cd "$PROJECT_DIR"
setsid nohup socat TCP-LISTEN:"$ACP_PORT",fork,reuseaddr EXEC:"python3 /usr/local/bin/acp-bridge-auth.py kilo acp" \
> ~/.local/log/acp-bridge.log 2>&1 < /dev/null &
echo $! > ~/.local/run/acp-bridge.pid
setsid nohup socat TCP-LISTEN:"$INFO_PORT",fork,reuseaddr EXEC:"python3 /usr/local/bin/acp-bridge-auth.py --print-env PROJECT_DIR" \
> ~/.local/log/acp-bridge-info.log 2>&1 < /dev/null &
echo $! > ~/.local/run/acp-bridge-info.pid
echo "[ Kilo peer bridge (ACP) listening on :$ACP_PORT (project dir info on :$INFO_PORT) ]"
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# MCP stdio server exposing one tool ("ask") that opens a live ACP
# (Agent Client Protocol) session against a peer Kilo workspace over TCP and
# returns its final response. This exists because kilo serve has no MCP
# server endpoint of its own (confirmed by dumping every route in the kilo
# binary) — kilo acp is a real, tool-capable agent session, but it only
# speaks JSON-RPC over stdio, so acp-bridge-listen.sh exposes it via socat
# and this script bridges that into something kilo (as an MCP client) can
# call as an ordinary "local" MCP server.
import argparse
import json
import os
import socket
import sys
import time
TOOL_NAME = "ask"
def read_stdin_messages():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def write_message(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def send_response(msg_id, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": msg_id}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
write_message(msg)
class PeerConnectionError(Exception):
pass
class AcpPeer:
def __init__(self, host, acp_port, info_port, model, timeout, token):
self.host = host
self.acp_port = acp_port
self.info_port = info_port
self.model = model
self.timeout = timeout
self.token = token
def _fetch_project_dir(self):
try:
with socket.create_connection((self.host, self.info_port), timeout=10) as s:
wf = s.makefile("w")
wf.write(self.token + "\n")
wf.flush()
f = s.makefile("r")
line = f.readline().strip()
return line or "/home/coder"
except OSError as e:
raise PeerConnectionError(f"could not reach peer info port {self.host}:{self.info_port}: {e}")
def ask(self, prompt_text):
cwd = self._fetch_project_dir()
deadline = time.time() + self.timeout
try:
sock = socket.create_connection((self.host, self.acp_port), timeout=15)
except OSError as e:
raise PeerConnectionError(f"could not reach peer ACP port {self.host}:{self.acp_port}: {e}")
with sock:
sock.settimeout(self.timeout)
rf = sock.makefile("r")
wf = sock.makefile("w")
wf.write(self.token + "\n")
wf.flush()
def send(msg):
wf.write(json.dumps(msg) + "\n")
wf.flush()
def recv():
line = rf.readline()
if not line:
return None
return json.loads(line)
def await_response(expected_id, allow_error=False):
while True:
obj = recv()
if obj is None:
raise PeerConnectionError("peer connection closed unexpectedly")
if obj.get("id") == expected_id:
if "error" in obj and not allow_error:
raise PeerConnectionError(f"peer error: {obj['error']}")
return obj
# ignore interleaved notifications while waiting for this response
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": 1, "clientCapabilities": {}}})
await_response(1)
send({"jsonrpc": "2.0", "id": 2, "method": "session/new",
"params": {"cwd": cwd, "mcpServers": []}})
resp = await_response(2)
session_id = resp["result"]["sessionId"]
send({"jsonrpc": "2.0", "id": 3, "method": "session/set_model",
"params": {"sessionId": session_id, "modelId": self.model}})
await_response(3, allow_error=True)
send({"jsonrpc": "2.0", "id": 4, "method": "session/prompt",
"params": {"sessionId": session_id,
"prompt": [{"type": "text", "text": prompt_text}]}})
text_parts = []
tool_log = []
while True:
if time.time() > deadline:
raise TimeoutError(f"peer did not finish within {self.timeout}s")
obj = recv()
if obj is None:
raise PeerConnectionError("peer connection closed unexpectedly")
method = obj.get("method")
if method == "session/update":
upd = obj["params"]["update"]
su = upd.get("sessionUpdate")
if su == "agent_message_chunk":
content = upd.get("content", {})
if content.get("type") == "text":
text_parts.append(content["text"])
elif su == "tool_call_update" and upd.get("status") == "completed":
tool_log.append(upd.get("title") or upd.get("kind") or "tool")
elif method == "session/request_permission":
options = obj.get("params", {}).get("options", [])
chosen = None
for pref in ("allow_always", "allow_once", "allow"):
for opt in options:
if pref in str(opt.get("kind", "")):
chosen = opt["optionId"]
break
if chosen:
break
if chosen is None and options:
chosen = options[0]["optionId"]
send({"jsonrpc": "2.0", "id": obj["id"],
"result": {"outcome": {"outcome": "selected", "optionId": chosen}}})
elif obj.get("id") == 4:
break
# else: ignore anything else (other notifications, stray responses)
reply = "".join(text_parts).strip()
if tool_log:
reply += "\n\n[peer actions: " + ", ".join(tool_log) + "]"
return reply or "(peer produced no text response)"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", required=True)
ap.add_argument("--acp-port", type=int, default=4097)
ap.add_argument("--info-port", type=int, default=4098)
ap.add_argument("--model", required=True)
ap.add_argument("--peer-name", required=True)
ap.add_argument("--timeout", type=int, default=600)
args = ap.parse_args()
# Read from the environment (inherited from the agent's own env, same as
# acp-bridge-auth.py) rather than a CLI flag, so the shared secret never
# ends up in kilo.jsonc's command array or in `ps` output.
token = os.environ.get("PEER_BRIDGE_TOKEN", "")
if not token:
print(json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "PEER_BRIDGE_TOKEN not set"}}))
sys.exit(1)
peer = AcpPeer(args.host, args.acp_port, args.info_port, args.model, args.timeout, token)
for msg in read_stdin_messages():
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
client_version = msg.get("params", {}).get("protocolVersion", "2024-11-05")
send_response(msg_id, result={
"protocolVersion": client_version,
"capabilities": {"tools": {}},
"serverInfo": {"name": f"peer-bridge-{args.peer_name}", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(msg_id, result={"tools": [{
"name": TOOL_NAME,
"description": (
f"Ask the '{args.peer_name}' workspace's Kilo agent a question about its "
"project, or hand it a task to work on. It runs with full autonomy in its "
"own project directory (reads/edits/writes files, runs bash) and returns "
"its final response once done. Each call starts a fresh session on the "
"peer's side — include any needed context in the prompt itself."
),
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "The question or task for the peer agent."},
},
"required": ["prompt"],
},
}]})
elif method == "tools/call":
params = msg.get("params", {})
if params.get("name") != TOOL_NAME:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Unknown tool: {params.get('name')}"}],
"isError": True,
})
continue
prompt_text = params.get("arguments", {}).get("prompt", "")
try:
reply = peer.ask(prompt_text)
send_response(msg_id, result={"content": [{"type": "text", "text": reply}], "isError": False})
except Exception as e:
send_response(msg_id, result={
"content": [{"type": "text", "text": f"Peer request failed: {e}"}],
"isError": True,
})
elif msg_id is not None:
send_response(msg_id, error={"code": -32601, "message": f"Method not found: {method}"})
# else: unrecognized notification, ignore
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
FROM codercom/example-base:ubuntu AS base-default
USER root
# RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://ubuntu-archive.mirror.liquidtelecom.com/ubuntu/|g' /etc/apt/sources.list.d/ubuntu.sources
# RUN sed -i 's|http://security.ubuntu.com/ubuntu|http://ubuntu-archive.mirror.liquidtelecom.com/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
RUN DEBIAN_FRONTEND=noninteractive apt-get update -y
RUN DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y --no-install-recommends;
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ncdu mtr-tiny;
USER coder
+56
View File
@@ -0,0 +1,56 @@
FROM coder-devbox-base-config:latest AS base-default
USER root
RUN DEBIAN_FRONTEND=noninteractive apt-get update -y -qqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -qqq libpq-dev libffi-dev zlib1g-dev libedit-dev libyaml-dev redis-server libicu-dev postgresql-client psmisc
USER coder
RUN echo "[ Installing .NET SDK via dotnet-install.sh (jincod/dotnetcore-buildpack approach) ]" \
&& sudo DEBIAN_FRONTEND=noninteractive apt-get update -y -qqq \
&& sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -qqq libicu-dev libssl-dev curl \
&& DOTNET_INSTALL_DIR="$HOME/.dotnet" \
&& mkdir -p "$DOTNET_INSTALL_DIR" \
&& curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
&& chmod +x /tmp/dotnet-install.sh \
&& /tmp/dotnet-install.sh --channel LTS --install-dir "$DOTNET_INSTALL_DIR" \
&& "$DOTNET_INSTALL_DIR/dotnet" tool install --global dotnet-ef \
&& "$DOTNET_INSTALL_DIR/dotnet" --version
USER root
# .NET lives entirely under /home/coder/.dotnet — invisible to Kilo's own
# agent process (spawned non-interactively, never sources ~/.bashrc), needed
# for the csharp built-in LSP, which auto-installs roslyn-language-server via
# `dotnet tool install` if `dotnet` is found on PATH. Copy out to image-owned
# territory and put on PATH via ENV, so it works regardless of the home
# volume's state.
RUN mkdir -p /usr/local/dotnet \
&& cp -a /home/coder/.dotnet/. /usr/local/dotnet/ \
&& chown -R coder:coder /usr/local/dotnet
ENV DOTNET_ROOT=/usr/local/dotnet
ENV DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV NUGET_XMLDOC_MODE=skip
ENV PATH="/usr/local/dotnet:/usr/local/dotnet/tools:${PATH}"
USER coder
RUN dotnet --version
# csharp-ls (.NET tool) + Claude Code's plugin wrapper around it — same
# pattern as php-lsp/intelephense and ruby-lsp. `--tool-path
# /usr/local/dotnet/tools` (not plain `--global`, which would resolve to
# ~/.dotnet/tools under /home/coder — the per-workspace volume the dotnet-ef
# install above was deliberately copied OUT of) puts it directly in the
# already-PATH'd, image-owned tools dir, so it works regardless of the home
# volume's state, same as dotnet-ef.
RUN dotnet tool install --tool-path /usr/local/dotnet/tools csharp-ls \
&& "$HOME/.local/bin/claude" plugin install csharp-lsp@claude-plugins-official \
&& codex plugin add csharp-lsp@claude-plugins-official
RUN echo "[ Installing Heroku CLI ]" \
&& curl https://cli-assets.heroku.com/install.sh | sh
RUN echo ".NET ready: $(dotnet --version)"
+38
View File
@@ -0,0 +1,38 @@
FROM coder-devbox-flutter:latest AS base-default
USER root
# Same XFCE4 + TigerVNC + noVNC stack as devbox/vnc, layered on top of the
# already-built Flutter image instead of base-config directly — reuses the
# Flutter/fvm layer's build cache entirely rather than reinstalling it.
RUN DEBIAN_FRONTEND=noninteractive apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
xfce4 xfce4-terminal dbus-x11 \
tigervnc-standalone-server tigervnc-common \
novnc websockify \
fonts-dejavu-core \
x11vnc \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# x11vnc is installed only for its `storepasswd` tool — see devbox/vnc's
# Dockerfile for why (no vncpasswd equivalent in this Ubuntu package split).
# x11vnc itself is never run.
# Flutter's own Linux desktop build requirements (per Flutter's official
# docs) — needed so `flutter run -d linux` / `flutter build linux` actually
# work inside this workspace, with the VNC desktop providing a real display
# for the resulting GUI app to run against instead of Xvfb-style headless
# testing.
RUN DEBIAN_FRONTEND=noninteractive apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Lives outside /home/coder — see vnc-startup.sh header comment.
COPY ./vnc-startup.sh /usr/local/bin/vnc-startup.sh
RUN chmod +x /usr/local/bin/vnc-startup.sh
USER coder
RUN flutter config --enable-linux-desktop
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Starts an XFCE4 desktop under TigerVNC and bridges it to a browser-reachable
# noVNC endpoint. Lives outside /home/coder (the per-workspace persistent
# volume) so it always reflects the current image, not whatever was baked in
# on that volume's first-ever creation — same reasoning as acp-bridge-listen.sh.
set -e
mkdir -p ~/.vnc ~/.local/log ~/.local/run
VNC_PASSWD_FILE=~/.vnc/passwd
# Regenerated and printed on every start, not just first-ever — a one-time
# "shown once" value was too easy to lose (see devenv README's VNC section:
# recovering a stale one meant killing the running session anyway). Fine
# since VNC access is already gated by secure-admin's own auth in front of
# this. tigervnc-standalone-server/tigervnc-common ship no vncpasswd
# equivalent on this Ubuntu package split — x11vnc's storepasswd generates a
# file in the same standard VNC password format that Xtigervnc/tigervncserver
# reads; x11vnc itself is never actually run.
VNC_PASSWORD="${VNC_PASSWORD:-$(openssl rand -hex 8)}"
x11vnc -storepasswd "$VNC_PASSWORD" "$VNC_PASSWD_FILE" > /dev/null
chmod 600 "$VNC_PASSWD_FILE"
echo "[ VNC password: $VNC_PASSWORD ]"
cat > ~/.vnc/xstartup <<'XSTARTUP_EOF'
#!/bin/bash
unset SESSION_MANAGER
unset DBUS_SESSION_BUS_ADDRESS
exec startxfce4
XSTARTUP_EOF
chmod +x ~/.vnc/xstartup
# Idempotent: kill any stale server/proxy from a prior start before relaunching
vncserver -kill :1 > /dev/null 2>&1 || true
pkill -f "websockify.*6080" > /dev/null 2>&1 || true
vncserver :1 -geometry 1280x800 -depth 24 -SecurityTypes VncAuth -PasswordFile "$VNC_PASSWD_FILE" > ~/.local/log/vncserver.log 2>&1
setsid nohup websockify --web=/usr/share/novnc/ 6080 localhost:5901 > ~/.local/log/novnc.log 2>&1 < /dev/null &
echo $! > ~/.local/run/novnc.pid
for i in $(seq 1 15); do
if curl -sf http://localhost:6080/vnc.html > /dev/null 2>&1; then
echo "[ noVNC ready on :6080 after ~${i}s ]"
break
fi
sleep 1
done
+63
View File
@@ -0,0 +1,63 @@
FROM coder-devbox-base-config:latest AS base-default
ARG FLUTTER_TESTING_TOOLS="false"
USER coder
RUN echo "[ Installing fvm ]" \
&& curl -fsSL https://fvm.app/install.sh | bash \
&& export PATH="$HOME/fvm/bin:$PATH" \
&& echo "[ Installing Flutter SDK (stable) ]" \
&& "$HOME/fvm/bin/fvm" install stable \
&& "$HOME/fvm/bin/fvm" global stable \
&& "$HOME/fvm/default/bin/flutter" --version
USER root
# Move the installed SDK + fvm binary out of /home/coder into the image
# filesystem, so it survives stale/old persisted home volumes on
# workspace stop/start. Leave nothing important depending on $HOME/fvm.
RUN mkdir -p /usr/local/fvm \
&& cp -a /home/coder/fvm/. /usr/local/fvm/ \
&& REAL_VERSION_DIR="$(readlink -f /home/coder/fvm/default)" \
&& VERSION_NAME="$(basename "$REAL_VERSION_DIR")" \
&& rm -f /usr/local/fvm/default \
&& ln -sfn "/usr/local/fvm/versions/$VERSION_NAME" /usr/local/fvm/default \
&& chown -R coder:coder /usr/local/fvm \
&& ln -sf /usr/local/fvm/bin/fvm /usr/local/bin/fvm \
&& ln -sf /usr/local/fvm/default/bin/flutter /usr/local/bin/flutter \
&& ln -sf /usr/local/fvm/default/bin/dart /usr/local/bin/dart \
&& rm -rf /home/coder/fvm
USER coder
RUN /usr/local/bin/flutter --version
# Flutter Testing Tools - apt packages baked into the image
RUN if [ "$FLUTTER_TESTING_TOOLS" = "true" ]; then \
echo "[ Installing Flutter testing tools ]"; \
fi
RUN if [ "$FLUTTER_TESTING_TOOLS" = "true" ]; then \
DEBIAN_FRONTEND=noninteractive apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
pkg-config xvfb x11-utils cmake clang mesa-utils libgtk-3-dev ninja-build \
&& echo "[ Testing tools ready: xvfb, cmake, clang, ninja, pkg-config ]"; \
else \
echo "[ Skipping Flutter testing tools ]"; \
fi
USER coder
# marionette_mcp / patrol_cli install into ~/.pub-cache, which lives in the
# persisted home volume — these must be (re)activated per-workspace, not baked
# into the image. Handle this in a startup script instead, e.g.:
#
# if [ "$FLUTTER_TESTING_TOOLS" = "true" ]; then
# mkdir -p "$HOME/.pub-cache/bin"
# dart pub global activate marionette_mcp
# flutter pub global activate patrol_cli
# sudo ln -sf "$HOME/.pub-cache/bin/marionette_mcp" /usr/local/bin/marionette_mcp
# sudo ln -sf "$HOME/.pub-cache/bin/patrol" /usr/local/bin/patrol
# fi
+25
View File
@@ -0,0 +1,25 @@
FROM coder-devbox-base-config:latest AS base-default
USER root
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -qqq software-properties-common
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -qqq php-fpm php-cli php-mbstring php-xml php-mysql php-imap php-curl psmisc mariadb-client
RUN apt-get clean;
USER coder
# intelephense for Kilo's "lsp": true config — php itself is already on PATH
# system-wide via apt, but the PHP LSP is a separate npm package.
RUN npm install -g intelephense
USER root
RUN ln -sf "$NVM_DIR/versions/node/active/bin/intelephense" /usr/local/bin/intelephense
USER coder
# Claude Code's equivalent of the "lsp": true config above — intelephense is
# already on PATH from the install above, this just registers the plugin.
# Codex gets the same plugin from the same marketplace — see base-config's
# Dockerfile for why this works.
RUN "$HOME/.local/bin/claude" plugin install php-lsp@claude-plugins-official \
&& codex plugin add php-lsp@claude-plugins-official
+20
View File
@@ -0,0 +1,20 @@
FROM coder-devbox-base-config:latest AS base-default
USER root
RUN DEBIAN_FRONTEND=noninteractive apt-get update -y -qqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends -qqq libpq-dev libffi-dev zlib1g-dev libedit-dev libyaml-dev redis-server libicu-dev postgresql-client psmisc
USER coder
# Node.js/npm/typescript-language-server are already provided by base-config —
# this flavor previously tried to install a second, differently-versioned
# Node via an undefined $NODE_VERSION build arg (never declared or passed in
# compose.yaml, so always empty) and cloned/built rbenv + Ruby, which has
# nothing to do with a React/TypeScript flavor. Removed both as dead,
# non-functional leftovers.
RUN echo "[ Installing heroku cli ]" \
&& curl https://cli-assets.heroku.com/install.sh | sh
+90
View File
@@ -0,0 +1,90 @@
FROM coder-devbox-base-config:latest AS base-default
USER root
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libpq-dev libffi-dev zlib1g-dev libedit-dev libyaml-dev redis-server libicu-dev postgresql-client psmisc
USER coder
# RUN echo "[ Installing nvm ]" \
# curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh" | bash \
# echo "[ Installing Node.js $NODE_VERSION ]" \
# nvm install "$NODE_VERSION" \
# # Write alias file directly so persisted home volumes respect it on restart
# mkdir -p "$NVM_DIR/alias" \
# echo "$NODE_VERSION" > "$NVM_DIR/alias/default" \
# nvm use "$NODE_VERSION" \
# echo "[ Node active: $(node --version) ]"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv && \
cd ~/.rbenv && src/configure && make -C src && \
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build && \
export PATH="$HOME/.rbenv/bin:$PATH" && \
eval "$(rbenv init -)" && \
rbenv install -s 3.2.2 && \
rbenv global 3.2.2 && \
gem install bundler ruby-lsp --no-document
USER root
# rbenv's ruby lives entirely under /home/coder/.rbenv, exposed on PATH only
# via ~/.bashrc — invisible to Kilo's own agent process, which is spawned
# non-interactively and never sources .bashrc (needed for the ruby-lsp
# built-in LSP, which auto-installs rubocop via `gem` if `ruby`/`gem` are
# found on PATH). Copy the installed version out to image-owned territory and
# add it to PATH via ENV, so it works regardless of the home volume's state.
# bundler/ruby-lsp were installed BEFORE this copy deliberately: gem installs
# done AFTER copying (via the copied `gem` binary) still land back under
# /home/coder — Ruby's own RbConfig bakes in the interpreter's original
# build-time prefix, which `cp -a` doesn't rewrite, so gem defaults to that
# original path regardless of where the files physically now live. Confirmed
# live: `gem install ruby-lsp` run here (after the copy) installed to
# /home/coder/.rbenv/... same as before, not /usr/local/rbenv-ruby, leaving
# `ruby-lsp` off PATH entirely.
#
# That same RbConfig-prefix behavior means EVERY workspace's own `bundle
# install` (the normal, universal first step for any real Ruby project —
# not something this template automates, see devenv/main.tf) writes its
# gems' executables back under /home/coder/.rbenv/versions/<ver>/bin, never
# to /usr/local/rbenv-ruby/bin — confirmed live (real user report): `bin/dev`'s
# `exec foreman start -f Procfile.dev` failed with "foreman: not found" even
# after a clean `bundle install` showed `Bundle complete!` and `bundle
# list`/`gem list` both showed `foreman (0.89.1)` installed; the real
# executable was sitting under /home/coder/.rbenv/versions/<ver>/bin,
# invisible to $PATH (and thus to `bundle exec foreman` too — Bundler's own
# exec lookup is $PATH-based). Same root cause as bare `rails` not being
# found (also documented in devenv/README.md's Ruby section) — anything a
# project's own Gemfile pulls in beyond the two gems baked into this image
# was always going to hit this.
#
# Fixed at the root instead of papering over it with a second PATH entry:
# once the real files are copied out, the *original* versioned rbenv
# directory is deleted and replaced with a symlink back to the copy. Ruby's
# baked-in RbConfig prefix still resolves to the same absolute path either
# way, so every future `bundle install`/`gem install` (which only ever
# knows about that original path, never /usr/local/rbenv-ruby directly)
# transparently lands in the one real, already-on-PATH location instead —
# no second copy, no drift, and no new place a future ruby-version bump has
# to be kept in sync (the version string is read from rbenv's own
# `~/.rbenv/version` file, set by `rbenv global` above, not re-hardcoded
# here).
RUN RBENV_RUBY_VERSION="$(cat /home/coder/.rbenv/version)" \
&& mkdir -p /usr/local/rbenv-ruby \
&& cp -a "/home/coder/.rbenv/versions/$RBENV_RUBY_VERSION/." /usr/local/rbenv-ruby/ \
&& chown -R coder:coder /usr/local/rbenv-ruby \
&& rm -rf "/home/coder/.rbenv/versions/$RBENV_RUBY_VERSION" \
&& ln -s /usr/local/rbenv-ruby "/home/coder/.rbenv/versions/$RBENV_RUBY_VERSION"
ENV PATH="/usr/local/rbenv-ruby/bin:${PATH}"
USER coder
RUN ruby --version && gem --version && ruby-lsp --version
# Claude Code's plugin wrapper around ruby-lsp (installed above, before the
# copy-out) — same pattern as php-lsp/intelephense. Codex gets the same
# plugin from the same marketplace — see base-config's Dockerfile for why
# this works.
RUN "$HOME/.local/bin/claude" plugin install ruby-lsp@claude-plugins-official \
&& codex plugin add ruby-lsp@claude-plugins-official
RUN curl https://cli-assets.heroku.com/install.sh | sh
+27
View File
@@ -0,0 +1,27 @@
FROM coder-devbox-base-config:latest AS base-default
USER root
# XFCE4 desktop + TigerVNC + noVNC — browser-based desktop access (no native
# VNC client needed), for running/testing GUI apps (e.g. Flutter Linux
# builds) that need a real display.
RUN DEBIAN_FRONTEND=noninteractive apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
xfce4 xfce4-terminal dbus-x11 \
tigervnc-standalone-server tigervnc-common \
novnc websockify \
fonts-dejavu-core \
x11vnc \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# x11vnc is installed only for its `storepasswd` tool — this Ubuntu
# tigervnc-standalone-server/tigervnc-common split ships no vncpasswd
# equivalent at all (checked dpkg -L and Xtigervnc -help), but the VNC
# password file format is a shared standard, so x11vnc's tool can generate
# the file that Xtigervnc/tigervncserver reads. x11vnc itself is never run.
# Lives outside /home/coder — see vnc-startup.sh header comment.
COPY ./vnc-startup.sh /usr/local/bin/vnc-startup.sh
RUN chmod +x /usr/local/bin/vnc-startup.sh
USER coder
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Starts an XFCE4 desktop under TigerVNC and bridges it to a browser-reachable
# noVNC endpoint. Lives outside /home/coder (the per-workspace persistent
# volume) so it always reflects the current image, not whatever was baked in
# on that volume's first-ever creation — same reasoning as acp-bridge-listen.sh.
set -e
mkdir -p ~/.vnc ~/.local/log ~/.local/run
VNC_PASSWD_FILE=~/.vnc/passwd
# Regenerated and printed on every start, not just first-ever — a one-time
# "shown once" value was too easy to lose (see devenv README's VNC section:
# recovering a stale one meant killing the running session anyway). Fine
# since VNC access is already gated by secure-admin's own auth in front of
# this. tigervnc-standalone-server/tigervnc-common ship no vncpasswd
# equivalent on this Ubuntu package split — x11vnc's storepasswd generates a
# file in the same standard VNC password format that Xtigervnc/tigervncserver
# reads; x11vnc itself is never actually run.
VNC_PASSWORD="${VNC_PASSWORD:-$(openssl rand -hex 8)}"
x11vnc -storepasswd "$VNC_PASSWORD" "$VNC_PASSWD_FILE" > /dev/null
chmod 600 "$VNC_PASSWD_FILE"
echo "[ VNC password: $VNC_PASSWORD ]"
cat > ~/.vnc/xstartup <<'XSTARTUP_EOF'
#!/bin/bash
unset SESSION_MANAGER
unset DBUS_SESSION_BUS_ADDRESS
exec startxfce4
XSTARTUP_EOF
chmod +x ~/.vnc/xstartup
# Idempotent: kill any stale server/proxy from a prior start before relaunching
vncserver -kill :1 > /dev/null 2>&1 || true
pkill -f "websockify.*6080" > /dev/null 2>&1 || true
vncserver :1 -geometry 1280x800 -depth 24 -SecurityTypes VncAuth -PasswordFile "$VNC_PASSWD_FILE" > ~/.local/log/vncserver.log 2>&1
setsid nohup websockify --web=/usr/share/novnc/ 6080 localhost:5901 > ~/.local/log/novnc.log 2>&1 < /dev/null &
echo $! > ~/.local/run/novnc.pid
for i in $(seq 1 15); do
if curl -sf http://localhost:6080/vnc.html > /dev/null 2>&1; then
echo "[ noVNC ready on :6080 after ~${i}s ]"
break
fi
sleep 1
done