# opencode-agent-platform runtime image
#
# Purpose
#   Run the OpenCode v2 API server (pinned 2.2.5) with everything the agent
#   needs inside the container: git, curl, jq, bash, python3/uv for uvx-based
#   MCP servers, and the @astudioplus/codegraph-mcp engine plus its static
#   embedding weights baked in, so the running container performs zero
#   package or model downloads.
#
# Base pin
#   ghcr.io/anomalyco/opencode:2.2.5 with digest
#   sha256:1690aad69864d2f6714772d1e040918feebc6df5286d2bd7e181fbb35d12d4a9
#   Alpine 3.24.3, one Bun-compiled binary, upstream ENTRYPOINT ["opencode"],
#   upstream USER root. `latest` is NOT an alias for this release: it resolved
#   to a different manifest when this pin was taken, so both tag and digest
#   are pinned below and a rebuild can never silently move.
#
# How to move the pin
#   1. Read the manifest digest for the new tag:
#        curl -sI -H 'Accept: application/vnd.oci.image.index.v1+json' \
#          https://ghcr.io/v2/anomalyco/opencode/manifests/<new-tag>
#      and take the docker-content-digest header.
#   2. Replace the tag and digest in the first FROM line.
#   3. Rebuild; the gates at the bottom of this file re-prove that opencode,
#      the codegraph engine, git and the offline uvx cache all still work.
#   The Debian stage digest is updated the same way from Docker Hub.
#
# Why the extra stage
#   The codegraph engine is a glibc-linked Rust binary. Upstream requires
#   glibc >= 2.30 and a libstdc++ with GLIBCXX_3.4.29, and publishes no musl
#   build. Alpine's shims cannot run it: with gcompat alone, libc6-compat
#   alone, or both together the engine dies with
#     Error relocating codegraph-server: __res_init: symbol not found
#   A real glibc runtime is therefore copied in from a pinned Debian stage and
#   the engine is launched through it by a small wrapper. The
#   `codegraph-server --version` gate below is what keeps this honest.
#
# Build
#   Context is the repository root because the entrypoint is copied from
#   docker/entrypoint.sh:
#     docker build -f docker/Dockerfile -t <registry>/opencode-agent:<tag> .
#
# ASCII only. No emoji.

# Pinned runtime under test. Do not replace with :latest or with a bare tag.
FROM ghcr.io/anomalyco/opencode:2.2.5@sha256:1690aad69864d2f6714772d1e040918feebc6df5286d2bd7e181fbb35d12d4a9 AS opencode-base

# Glibc donor stage. Only the shared libraries are copied out of it, so this
# stage never reaches the final image.
FROM debian:bookworm-slim@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc AS glibc

RUN set -eux; \
    mkdir -p /glibc; \
    cp -L /lib64/ld-linux-x86-64.so.2 /glibc/ld-linux-x86-64.so.2; \
    cp -L /lib/x86_64-linux-gnu/libc.so.6 /glibc/libc.so.6; \
    cp -L /lib/x86_64-linux-gnu/libm.so.6 /glibc/libm.so.6; \
    cp -L /lib/x86_64-linux-gnu/libpthread.so.0 /glibc/libpthread.so.0; \
    cp -L /lib/x86_64-linux-gnu/libdl.so.2 /glibc/libdl.so.2; \
    cp -L /lib/x86_64-linux-gnu/libresolv.so.2 /glibc/libresolv.so.2; \
    cp -L /lib/x86_64-linux-gnu/librt.so.1 /glibc/librt.so.1; \
    cp -L /lib/x86_64-linux-gnu/libgcc_s.so.1 /glibc/libgcc_s.so.1; \
    cp -L /usr/lib/x86_64-linux-gnu/libstdc++.so.6 /glibc/libstdc++.so.6; \
    /glibc/ld-linux-x86-64.so.2 --version

FROM opencode-base AS runtime

ARG PUID=1000
ARG PGID=1000
ARG CODEGRAPH_MCP_VERSION=0.21.0
ARG CODEGRAPH_MODEL_TAG=model

# The uv cache is populated at build time and read at runtime, so uvx never
# touches the network once the image is running.
ENV UV_CACHE_DIR=/opt/uv-cache

# The glibc runtime is the only thing this image takes from the Debian stage.
COPY --from=glibc /glibc /opt/codegraph/glibc

# Packages, codegraph engine, embedding weights and the uv cache in one layer
# so the build-only npm dependency does not survive into the image.
# ripgrep is deliberately absent from this list: it already ships in the base
# image (verified with `command -v rg`), so adding the apk would duplicate it.
#
# docker-cli, docker-cli-compose and docker-cli-buildx are here because a task
# container can now test what it builds. Measured on task 69f4f4d3: the run had
# no way to execute `docker compose up`, so every acceptance criterion that
# depended on a live stack was reported as "not executed", honestly and
# uselessly. The daemon is NOT in this image - the dispatcher starts a pinned,
# privileged docker:dind sidecar per task and this CLI reaches it over
# DOCKER_HOST on a private per-task network. The host socket is never mounted,
# so a task cannot act on the host's daemon through this CLI.
#
# ORDER IS THE FIX. The runtime user is created HERE, before the uv cache is
# populated, and the population runs AS that user. uv creates the cache's
# top-level subdirectories itself - archive-v0, builds-v0, environments-v2,
# interpreter-v4, sdists-v9, simple-v21, wheels-v6 - plus the `.git` marker
# inside sdists-v9 that it opens on every start. Seeded as root, every one of
# those inodes came out root:root 0755 while only the cache ROOT was
# opencode-owned, so uvx died at RUNTIME with
#     error: Failed to initialize cache at `/opt/uv-cache`
#       Caused by: failed to open file `/opt/uv-cache/sdists-v9/.git`:
#         Permission denied (os error 13)
# which is exactly how the json_yaml_toml MCP server failed on the deployed
# container while every other server connected.
#
# Seeding as the runtime user means every inode uv writes is already
# opencode-owned at the instant it is created, so no repair pass over the
# finished tree is needed - and none is possible either, because any recursive
# metadata walk over that tree stalls this builder (see the permission block
# below). A second benefit is concrete: HOME is the runtime user's home during
# seeding, so any per-user uv state lands where the runtime will look for it
# rather than in /root, which the runtime user cannot read under --offline.
RUN set -eux; \
    apk add --no-cache \
        bash \
        curl \
        docker-cli \
        docker-cli-buildx \
        docker-cli-compose \
        git \
        jq \
        nodejs \
        python3 \
        tini \
        uv; \
    apk add --no-cache --virtual .build-deps npm; \
    addgroup -g "${PGID}" opencode; \
    adduser -D -u "${PUID}" -G opencode -h /home/opencode -s /bin/bash opencode; \
    mkdir -p /opt/codegraph/bin /opt/codegraph/static_models /opt/uv-cache; \
    mkdir -p /etc/git-hooks /workspace/repos /workspace/worktrees; \
    npm install --global --no-fund --no-audit "@astudioplus/codegraph-mcp@${CODEGRAPH_MCP_VERSION}"; \
    ln /usr/local/lib/node_modules/@astudioplus/codegraph-mcp/bin/codegraph-server-linux-x64 /opt/codegraph/bin/codegraph-server.real; \
    chmod 0755 /opt/codegraph/bin/codegraph-server.real; \
    printf '%s\n' '#!/bin/sh' 'exec /opt/codegraph/glibc/ld-linux-x86-64.so.2 --library-path /opt/codegraph/glibc /opt/codegraph/bin/codegraph-server.real "$@"' > /opt/codegraph/bin/codegraph-server; \
    chmod 0755 /opt/codegraph/bin/codegraph-server; \
    curl -fsSL -o /tmp/codegraph-model.tar.gz "https://github.com/codegraph-ai/CodeGraph/releases/download/${CODEGRAPH_MODEL_TAG}/jina-code-static-256.tar.gz"; \
    mkdir -p /opt/codegraph/static_models/jina-code-static-256; \
    tar -xzf /tmp/codegraph-model.tar.gz -C /opt/codegraph/static_models/jina-code-static-256; \
    rm -f /tmp/codegraph-model.tar.gz; \
    test -f /opt/codegraph/static_models/jina-code-static-256/config.json; \
    chown opencode:opencode /opt/uv-cache; \
    su -s /bin/sh -c 'set -eux; \
        HOME=/home/opencode; \
        UV_CACHE_DIR=/opt/uv-cache; \
        export HOME UV_CACHE_DIR; \
        PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; \
        export PATH; \
        id; \
        timeout 300 uvx mcp-json-yaml-toml --help > /dev/null 2>&1 < /dev/null; \
        timeout 120 uvx --offline mcp-json-yaml-toml --help > /dev/null 2>&1 < /dev/null; \
        timeout 120 uvx --offline --from mcp-json-yaml-toml python -c "import yaml, jsonschema, fastmcp" < /dev/null' opencode; \
    rm -rf /root/.codegraph /root/.npm; \
    apk del .build-deps

# Ownership for the non-root runtime user.
#
# The user itself is created in the layer ABOVE, before the uv cache is seeded,
# so the seeding runs as that user and every cache inode is opencode-owned from
# birth. Nothing in this layer repairs ownership of /opt/uv-cache, and nothing
# may: the cache root is asserted as a single inode, and the real proof is the
# build gate at the bottom of this file, which starts the actual json_yaml_toml
# MCP server as uid 1000.
#
# DO NOT reintroduce ANY recursive metadata operation - chown OR chmod - over
# /opt/uv-cache. It is the recursive walk that stalls this builder, not the
# ownership change, and that was proven twice on two different instructions:
#   * chown -R over /opt/uv-cache and /opt/codegraph hung CI three times in a
#     row (runs 566 job 584, 566 job 587, 568 job 589): the layer printed the
#     chown line from `set -x` and then emitted nothing for 13 to 20 minutes
#     until the run was cancelled, with every earlier layer CACHED.
#   * chmod -R a+rwX /opt/uv-cache did exactly the same thing on run 580, which
#     was cancelled after 10+ minutes on that one instruction and then reverted.
# The size explanation was measured and disproven: a recursive chown over a
# 239.9 MB lower-layer tree costs 4 seconds on this build host
# (the brain-dump knowledge base). The tree is the trigger -
# archive-v0 alone is 19,065 inodes in 205.8 MB, against sdists-v9's 3 and
# builds-v0's 1 - and a read-only `ls` is safe while a recursive metadata WRITE
# is not. A later tidy-up that "just re-owns the cache" re-breaks the build.
#
# What each path needs at runtime:
#   /opt/codegraph  read and traverse only. The engine is spawned from
#                   CODEGRAPH_SERVER_PATH, the weights are only read through
#                   CODEGRAPH_STATIC_MODEL, per-project graph state lives under
#                   $HOME/.codegraph, and both launcher scripts are already
#                   0755. `a+rX` is the whole requirement, and a recursive
#                   chmod over this tree measured 4.3s on the same builder that
#                   could not finish a chown over it.
#   /opt/uv-cache   already owned recursively by the runtime user, because that
#                   user created it. Only the root is asserted here, one inode.
#   /workspace      owned recursively: it is only the empty repos/ and
#     /home/opencode   worktrees/ directories plus the fresh home directory, and
#                   the recursive chown over them measured 0.002s in the stalled
#                   logs (10.48 -> 10.49), so it is kept.
RUN set -eux; \
    chown -R opencode:opencode /workspace /home/opencode; \
    chmod -R a+rX /opt/codegraph; \
    echo "gate: /opt/uv-cache root belongs to the runtime user"; \
    test "$(stat -c '%u:%g' /opt/uv-cache)" = "${PUID}:${PGID}"; \
    stat -c '%u:%g %n' /opt/uv-cache; \
    git config --system core.hooksPath /etc/git-hooks; \
    if [ "$(command -v tini)" != "/usr/bin/tini" ]; then \
        ln -sf "$(command -v tini)" /usr/bin/tini; \
    fi; \
    test -x /usr/bin/tini

ENV HOME=/home/opencode \
    CODEGRAPH_SERVER_PATH=/opt/codegraph/bin/codegraph-server \
    CODEGRAPH_STATIC_MODEL=/opt/codegraph/static_models/jina-code-static-256 \
    OPENCODE_SERVER_USERNAME=opencode

COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN set -eux; \
    chmod 0755 /usr/local/bin/entrypoint.sh; \
    sh -n /usr/local/bin/entrypoint.sh

# ---------------------------------------------------------------------------
# Shipped configuration and git hooks: BAKED IN, never bind-mounted.
# ---------------------------------------------------------------------------
# Why baked and not bound. This image runs as a Portainer git stack on
# docker-node-01, and Portainer 2.45 deploys a git stack by piping the compose
# document from its database into `docker compose` with the project directory
# set to <FilesystemPath>/<stack id>/<commit sha>. It never materialises the
# repository there. A repo-relative bind such as
#   ./config/opencode.jsonc:/etc/opencode/opencode.jsonc:ro
# therefore resolved to /data/compose/172/<commit sha>/config/opencode.jsonc,
# which does not exist, and Docker created the missing source as an EMPTY
# DIRECTORY. Measured on the live container (subtask 8): all six destinations
# were `drwxr-xr-x root root` and empty, and `opencode serve` answered
#   PlatformError: BadResource: FileSystem.readFile
#     (/etc/opencode/opencode.jsonc)
#   [cause]: Error: EISDIR: illegal operation on a directory, read
# on every config load. Nothing failed loudly: the file existed, was readable
# as a directory, and the entrypoint's variable scan matched nothing and fell
# back to a built-in list that happened to be identical.
#
# Baking the tree removes the host-path dependency entirely. The config travels
# with the image tag, so what runs is exactly what this commit built, and no
# empty directory can silently stand in for it.
#
# Ownership: copied as root, read by uid 1000, so the running agent cannot edit
# the config it is judged by. The hooks keep their 0755 mode from git; the
# chmod makes the image independent of the builder's umask.
COPY config /etc/opencode
COPY hooks /etc/git-hooks

# The config is worthless if a later layer, a bad build context or a .dockerignore
# can drop part of it, so presence is asserted here rather than discovered at
# runtime. entrypoint.sh asserts it again on every start.
RUN set -eux; \
    chmod 0755 /etc/git-hooks/post-checkout /etc/git-hooks/post-commit; \
    chmod 0755 /etc/git-hooks/sync_config_edges.py; \
    echo "gate: every git repository in this image uses the shipped hooks"; \
    test "$(git config --system core.hooksPath)" = "/etc/git-hooks"; \
    test -x /etc/git-hooks/post-checkout; \
    test -x /etc/git-hooks/post-commit; \
    test -f /etc/opencode/opencode.jsonc; \
    test -s /etc/opencode/opencode.jsonc; \
    test -s /etc/opencode/AGENTS.md; \
    test -s /etc/opencode/plugins/enforce-structured-edits/index.ts; \
    test -s /etc/opencode/agents/graph-navigator.md; \
    test -s /etc/opencode/agents/surgical-patcher.md; \
    test -s /etc/opencode/commands/worktree-task.md; \
    test -s /etc/opencode/commands/blast-radius.md; \
    test -x /etc/git-hooks/post-checkout; \
    echo "gate: shipped config and git hooks are present and non-empty"

# Build gates. Each one exits non-zero on failure, so a broken binary or an
# empty cache fails the build instead of shipping. This matters most for
# codegraph: its own install step only warns when the engine download fails,
# so without these gates a silent-warning build would look successful.
RUN set -eux; \
    echo "gate: opencode binary"; \
    opencode --version; \
    echo "gate: codegraph engine through its glibc wrapper"; \
    "${CODEGRAPH_SERVER_PATH}" --version; \
    echo "gate: codegraph static weights present"; \
    test -f "${CODEGRAPH_STATIC_MODEL}/config.json"; \
    echo "gate: uvx offline resolution as the runtime user"; \
    su -s /bin/sh -c 'set -eux; \
        HOME=/home/opencode; \
        UV_CACHE_DIR=/opt/uv-cache; \
        export HOME UV_CACHE_DIR; \
        PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; \
        export PATH; \
        timeout 120 uvx --offline --from mcp-json-yaml-toml python -c "import yaml, jsonschema, fastmcp" < /dev/null' opencode; \
    echo "gate: git"; \
    git --version; \
    echo "gate: docker client, compose v2 and buildx are present"; \
    docker --version; \
    docker compose version; \
    docker buildx version; \
    echo "gate: the docker client is a client, not a daemon"; \
    if test -S /var/run/docker.sock; then \
        echo "ERROR: a docker socket is baked into the image"; exit 1; \
    fi; \
    if dockerd --version >/dev/null 2>&1; then \
        echo "ERROR: the daemon is baked into the image; it belongs in the dind sidecar"; exit 1; \
    fi; \
    test -z "${DOCKER_HOST:-}"

# The gates above run as root, and ENV HOME=/home/opencode is already in effect
# for them, so `opencode --version` and the codegraph gate leave root-owned
# directories behind: /home/opencode/.local/share/opencode/log (opencode writes
# its log there at startup, even for --version) and /home/opencode/.codegraph
# (the engine's state directory). The runtime user is uid 1000 and cannot write
# either one, which is a crash loop rather than a warning:
#   PlatformError: PermissionDenied: FileSystem.open
#     (/home/opencode/.local/share/opencode/log/opencode.log)
#   engine error: Failed to bind /home/opencode/.codegraph/cg-engine.sock:
#     Permission denied (os error 13)
# Reproduced on the real pushed image on docker-node-01, and measured with
# `ls -ldn`: /home/opencode was 1000:1000 but .local was 0:1000 and .codegraph
# was 0:0. It also poisons any named volume mounted at a nested path, because
# Docker initialises a fresh volume with the ownership of the image path.
#
# The re-own is deliberately scoped to /home/opencode, which is a handful of
# empty directories. It is NOT the recursive chown over /opt/uv-cache and
# /opt/codegraph that stalled layer 13 in CI; see the comment above that layer.
RUN set -eux; \
    chown -R opencode:opencode /home/opencode; \
    echo "gate: nothing under /home/opencode is still root-owned"; \
    test -z "$(find /home/opencode ! -user opencode)"; \
    stat -c '%u:%g %n' /home/opencode /home/opencode/.local

# ---------------------------------------------------------------------------
# Build gate: the uv cache must be USABLE BY THE RUNTIME USER.
# ---------------------------------------------------------------------------
# This is the gate that would have caught the json_yaml_toml failure before the
# image shipped. The defect was invisible to every earlier gate because they
# all ran as root: root can write anywhere, so `uvx --offline` passed at build
# time and the same command failed at runtime as uid 1000 with
#     Failed to initialize cache at /opt/uv-cache
#       Caused by: failed to open file /opt/uv-cache/sdists-v9/.git:
#         Permission denied (os error 13)
# A gate that runs as a different user from the thing it is checking is not a
# gate. Both checks below run through `su` as the runtime user and both are
# fatal.
#
# Check 1 - inode-level writability. For each top-level cache subdirectory uv
# uses, create and remove one probe file. This is a handful of inodes in seven
# directories, never a subtree walk, and it names the offending directory
# instead of surfacing as an opaque uv error. The subshell matters: a failed
# redirection inside `if` would otherwise abort the whole script on some shells
# and skip the remaining directories.
#
# Check 2 - the real command. `uvx --offline mcp-json-yaml-toml` exactly as
# docker-compose launches it, as uid 1000, with the same UV_CACHE_DIR. The
# stdin redirect from /dev/null is MANDATORY: the FastMCP stdio server blocks
# reading stdin forever without it, so the build would HANG rather than fail.
# Measured on a correctly owned cache: exit 0 in about 9 seconds, logging
# `Starting MCP server 'mcp-json-yaml-toml' with transport 'stdio'`. Measured on
# the broken image: exit 2 with the sdists-v9/.git permission error above.
#
# `su` carries neither HOME nor PATH reliably on BusyBox, so both are set
# explicitly rather than inherited. The user argument comes LAST so that option
# parsing cannot depend on whether this BusyBox permutes arguments (verified on
# the pinned image: this exact form returns uid 1000).
RUN set -eux; \
    echo "gate: every uv cache subdirectory is writable by the runtime user"; \
    su -s /bin/sh -c 'rc=0; \
        for d in /opt/uv-cache/*/; do \
            if ( : > "${d}.write-probe" ) 2>/dev/null; then \
                rm -f "${d}.write-probe"; echo "  writable $d"; \
            else \
                echo "  NOT WRITABLE $d"; rc=1; \
            fi; \
        done; \
        exit $rc' opencode; \
    echo "gate: json_yaml_toml MCP server starts offline as the runtime user"; \
    su -s /bin/sh -c 'set -eux; \
        id; \
        HOME=/home/opencode; \
        UV_CACHE_DIR=/opt/uv-cache; \
        export HOME UV_CACHE_DIR; \
        PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; \
        export PATH; \
        timeout 180 uvx --offline mcp-json-yaml-toml < /dev/null' opencode; \
    echo "gate: json_yaml_toml starts offline as uid $(id -u opencode)"

EXPOSE 4096
USER opencode
WORKDIR /workspace
ENTRYPOINT ["/usr/bin/tini","--","/usr/local/bin/entrypoint.sh"]
