Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2afab23a9 |
@@ -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
@@ -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;
|
||||
Executable
+10
@@ -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;
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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) ]"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user