diff --git a/README.md b/README.md index b03b0ae..0ffa0e5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # devbox-images -Coder devbox image Dockerfiles, split out of homelab-komodo so remote Coder deployments can build/reference them standalone \ No newline at end of file +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- +``` + +**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. diff --git a/build_fast.sh b/build_fast.sh new file mode 100755 index 0000000..03e6700 --- /dev/null +++ b/build_fast.sh @@ -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; \ No newline at end of file diff --git a/build_no_cache.sh b/build_no_cache.sh new file mode 100755 index 0000000..2c40753 --- /dev/null +++ b/build_no_cache.sh @@ -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; \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..3800909 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/devbox/base-config/Dockerfile b/devbox/base-config/Dockerfile new file mode 100644 index 0000000..b0bf936 --- /dev/null +++ b/devbox/base-config/Dockerfile @@ -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 --app ` 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 +# (` 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:" 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 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 + diff --git a/devbox/base-config/claude/claude-peer-listen.sh b/devbox/base-config/claude/claude-peer-listen.sh new file mode 100644 index 0000000..15eea72 --- /dev/null +++ b/devbox/base-config/claude/claude-peer-listen.sh @@ -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 ]" diff --git a/devbox/base-config/claude/claude-peer-runner.py b/devbox/base-config/claude/claude-peer-runner.py new file mode 100644 index 0000000..5e5f869 --- /dev/null +++ b/devbox/base-config/claude/claude-peer-runner.py @@ -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() diff --git a/devbox/base-config/claude/peer-mcp-bridge-claude.py b/devbox/base-config/claude/peer-mcp-bridge-claude.py new file mode 100644 index 0000000..f98e570 --- /dev/null +++ b/devbox/base-config/claude/peer-mcp-bridge-claude.py @@ -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() diff --git a/devbox/base-config/codex/codex-peer-listen.sh b/devbox/base-config/codex/codex-peer-listen.sh new file mode 100644 index 0000000..0cdbab2 --- /dev/null +++ b/devbox/base-config/codex/codex-peer-listen.sh @@ -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 ]" diff --git a/devbox/base-config/codex/peer-mcp-bridge-codex.py b/devbox/base-config/codex/peer-mcp-bridge-codex.py new file mode 100644 index 0000000..70abcac --- /dev/null +++ b/devbox/base-config/codex/peer-mcp-bridge-codex.py @@ -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() diff --git a/devbox/base-config/dokku-cli.sh b/devbox/base-config/dokku-cli.sh new file mode 100644 index 0000000..8ca5988 --- /dev/null +++ b/devbox/base-config/dokku-cli.sh @@ -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 (` 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 " 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}" "$@" diff --git a/devbox/base-config/kilo/acp-bridge-auth.py b/devbox/base-config/kilo/acp-bridge-auth.py new file mode 100644 index 0000000..5df7e39 --- /dev/null +++ b/devbox/base-config/kilo/acp-bridge-auth.py @@ -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 [args...]", file=sys.stderr) + print(" acp-bridge-auth.py --print-env ", 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() diff --git a/devbox/base-config/kilo/acp-bridge-listen.sh b/devbox/base-config/kilo/acp-bridge-listen.sh new file mode 100644 index 0000000..4f2818d --- /dev/null +++ b/devbox/base-config/kilo/acp-bridge-listen.sh @@ -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) ]" diff --git a/devbox/base-config/kilo/peer-mcp-bridge.py b/devbox/base-config/kilo/peer-mcp-bridge.py new file mode 100644 index 0000000..8db3b02 --- /dev/null +++ b/devbox/base-config/kilo/peer-mcp-bridge.py @@ -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() diff --git a/devbox/base/Dockerfile b/devbox/base/Dockerfile new file mode 100644 index 0000000..f9e047b --- /dev/null +++ b/devbox/base/Dockerfile @@ -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 diff --git a/devbox/dotnet/Dockerfile b/devbox/dotnet/Dockerfile new file mode 100644 index 0000000..d7e5073 --- /dev/null +++ b/devbox/dotnet/Dockerfile @@ -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)" \ No newline at end of file diff --git a/devbox/flutter-vnc/Dockerfile b/devbox/flutter-vnc/Dockerfile new file mode 100644 index 0000000..d0e485a --- /dev/null +++ b/devbox/flutter-vnc/Dockerfile @@ -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 diff --git a/devbox/flutter-vnc/vnc-startup.sh b/devbox/flutter-vnc/vnc-startup.sh new file mode 100644 index 0000000..f4552d9 --- /dev/null +++ b/devbox/flutter-vnc/vnc-startup.sh @@ -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 diff --git a/devbox/flutter/Dockerfile b/devbox/flutter/Dockerfile new file mode 100644 index 0000000..a9f9ac4 --- /dev/null +++ b/devbox/flutter/Dockerfile @@ -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 \ No newline at end of file diff --git a/devbox/php/Dockerfile b/devbox/php/Dockerfile new file mode 100644 index 0000000..0c2a185 --- /dev/null +++ b/devbox/php/Dockerfile @@ -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 + diff --git a/devbox/react/Dockerfile b/devbox/react/Dockerfile new file mode 100644 index 0000000..f175159 --- /dev/null +++ b/devbox/react/Dockerfile @@ -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 + + diff --git a/devbox/ruby/Dockerfile b/devbox/ruby/Dockerfile new file mode 100644 index 0000000..b839547 --- /dev/null +++ b/devbox/ruby/Dockerfile @@ -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//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//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 diff --git a/devbox/vnc/Dockerfile b/devbox/vnc/Dockerfile new file mode 100644 index 0000000..e2581d6 --- /dev/null +++ b/devbox/vnc/Dockerfile @@ -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 diff --git a/devbox/vnc/vnc-startup.sh b/devbox/vnc/vnc-startup.sh new file mode 100644 index 0000000..f4552d9 --- /dev/null +++ b/devbox/vnc/vnc-startup.sh @@ -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