Files
devbox-images/devbox/base-config/Dockerfile
T

409 lines
25 KiB
Docker
Raw Normal View History

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