# The Rindo **agent-host** image: `rindo-runner` plus the coding agents it
# exists to drive (Claude Code, Codex) and the everyday tools those agents reach
# for. Build context is `runner/`.
#
# This image is deliberately NOT a service in the root docker-compose.yml — the
# runner is a separate process on the agent operator's machine, outbound-only,
# and it holds an agent's `rndr_` token (runbook §2 "The runner"). Containerising
# it is a packaging convenience, not a topology change. Since rindo-runner 1.1
# (decision #90, RP2) this is ALSO the JOB image of a SHARED runner: the host-side
# supervisor (holding a `rndw_`) starts a new container of it per job with the
# job's own `rndj_` under the same env name — the MCP wiring below serves both.
# No change to this file was needed for that: a 1.0-built image is a valid job
# image (the supervisor sets `RINDO_RUNNER_SERVER_URL` for the entrypoint and
# mounts the project's skills at /home/rindo/.claude/skills; the engine creates
# that mountpoint — the image pre-creates /home/rindo/.claude only). Since 1.2
# (RP3b) the same image is ALSO the provisioner's sandbox template (E2B: the
# Template SDK's `from_image` of a PRIVATE copy — see the redistribution note
# below): the runner inside it serves an EPHEMERAL `rndw_` with
# `executor = "process"` (one job, then it exits and the sandbox is killed). The
# ENTRYPOINT below never runs in a sandbox — the provisioner starts the runner
# through `rindo-entrypoint` itself so the Codex wiring still happens. Since 1.3
# (R1, the runner harness step) a JOB image must carry `python3` >= 3.11 on PATH:
# the supervisor's own `harness_driver.py` rides into the container inside the
# per-job directory mount and runs there as the job's command whenever a served
# harness manifest has a runnable step (README "Harness step"). This image has
# it by construction (the venv below is built on the system python3 under
# `requires-python >=3.11`); an image without it still runs its jobs unwrapped —
# every entry that would otherwise run reads `driver_unavailable`. No change to this file:
# a 1.0-built image is a valid 1.3 job image.
#
# ⚠ REDISTRIBUTION: the `agents` target bakes in third-party CLIs under THEIR
# licenses — Claude Code ships under Anthropic's commercial terms, not an OSS
# license. Build this locally or publish to a PRIVATE registry; do not push it
# to a public one without clearing those terms. Only `rindo-runner` itself is
# Apache-2.0 (runner/LICENSE). This is why the image is absent from
# .github/workflows/publish-images.yml.
#
# Targets:
#   --target runner   the runner + shell tooling, no agent CLIs (bring your own
#                     wrapper script; smallest)
#   (default)         `agents` — the above plus Claude Code, Codex, gh
#
# Pinned deliberately (R1 policy: docs/05-ops-runbook.md §7.5) — bump with
# intent, not by drift. Every pin is a build ARG so a one-off bump needs no edit.

# ONE base pin consumed by every FROM — a split pin drifts (the content-service
# Dockerfile's ABI lesson). node:24 is Active LTS and satisfies Claude Code's
# `engines.node >=22`; slim is Debian, i.e. glibc — both agent CLIs resolve
# their `-linux-{x64,arm64}` native package there, never the musl one.
ARG NODE_BASE=node:24.18.0-slim

# ---- base: the shared OS layer (both targets, build and runtime) -------------
FROM ${NODE_BASE} AS base

# Extra apt packages for a site-specific host (compilers, docker-cli, a language
# toolchain your repos need). Deliberately empty by default — kept out so the
# stock image stays a general agent host, not a build farm.
ARG EXTRA_APT_PACKAGES=""

# UTF-8 everywhere: Rindo content spans en/ja/vi/ko, so an agent that reads a
# payload or writes a filename under the default POSIX locale will mangle CJK.
ENV LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    NPM_CONFIG_UPDATE_NOTIFIER=false \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# python3 here is for the AGENTS' scripting, not for the runner — the runner
# gets its own venv below. tini is the PID-1 reaper: job children are spawned
# with start_new_session, so a grandchild that outlives its leader reparents to
# PID 1, and a bare Python PID 1 never reaps it (zombie accumulation on a
# long-lived host). ripgrep/fd/jq are what the agents' shell tools reach for.
# bubblewrap is Codex's sandbox: it ships a bundled copy but warns on every
# single run when the binary is absent from PATH, and that warning lands in the
# job log every project member reads. (Note the sandbox ALSO needs unprivileged
# user namespaces, which Docker's default seccomp profile blocks — see README.)
RUN apt-get update && apt-get install -y --no-install-recommends \
      bash \
      bubblewrap \
      ca-certificates \
      curl \
      fd-find \
      git \
      git-lfs \
      gnupg \
      jq \
      less \
      make \
      openssh-client \
      procps \
      python3 \
      ripgrep \
      tini \
      tzdata \
      unzip \
      xz-utils \
      zip \
      ${EXTRA_APT_PACKAGES} \
    && ln -s "$(command -v fdfind)" /usr/local/bin/fd \
    && rm -rf /var/lib/apt/lists/*

# ---- runner-build: the runner venv ------------------------------------------
FROM base AS runner-build
# Same uv pin as backend/Dockerfile — one uv across the repo.
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/

WORKDIR /opt/rindo-runner
# UV_PYTHON_DOWNLOADS=never + an explicit interpreter: the venv MUST be built
# against the same /usr/bin/python3 the runtime stage carries, or the copied
# .venv would point at an interpreter that isn't in the final image. A base
# whose python3 falls below the project's >=3.11 floor fails the build loudly
# here rather than at first run.
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    UV_PYTHON_DOWNLOADS=never

# README.md is the package's long description since PY0 (`readme = "README.md"`):
# hatchling refuses to build without it.
COPY pyproject.toml uv.lock LICENSE README.md ./
COPY src/ src/
# --no-editable: install the package INTO the venv so the runtime stage needs
# only .venv (an editable install would point back at /opt/rindo-runner/src).
RUN uv sync --frozen --no-dev --no-editable --python /usr/bin/python3

# ---- runner: the runner + tooling, no agent CLIs ----------------------------
FROM base AS runner

# uv/uvx stay in the final image on purpose: it is how an agent gets a throwaway
# Python env (`uvx ruff`, `uv run script.py`) without ever touching — and
# corrupting — the runner's own venv.
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
COPY --from=runner-build /opt/rindo-runner/.venv /opt/rindo-runner/.venv

# uid 1000 is the node image's own `node` user; it is replaced rather than
# reused so the account name matches the product and a bind-mounted host
# workspace still maps to the usual first-user uid.
RUN userdel -r node 2>/dev/null || true \
    && useradd --create-home --uid 1000 --user-group --shell /bin/bash rindo \
    && mkdir -p /workspace /home/rindo/.claude /home/rindo/.codex /home/rindo/.config \
    && chown -R rindo:rindo /workspace /home/rindo

# A bind-mounted repo usually belongs to a different uid than the container
# user; without this git refuses every command with "dubious ownership". The
# container is a single-tenant agent sandbox, so the blanket form is correct here.
RUN git config --system --add safe.directory '*' \
    && git config --system init.defaultBranch main \
    && git config --system user.name "Rindo Agent" \
    && git config --system user.email "agent@rindo.invalid"

# Runner venv FIRST so nothing installed at runtime can shadow `rindo-runner`;
# ~/.local/bin lets an agent `uv tool install` without root. npm's prefix is
# DELIBERATELY left at its /usr/local default, which the runtime user cannot
# write: a user-writable prefix earlier on PATH meant `codex update` (or any
# `npm i -g`) would silently shadow the pinned CLIs with an unpinned copy —
# `codex doctor` flags exactly that as "would update a different install".
# Failing loudly beats defeating the pin; `npx` still covers one-off tools.
# JSON logs by default: this is a container, its stderr goes to a log collector
# (§20's convention). Override with RINDO_RUNNER_LOG_JSON=0.
ENV PATH="/opt/rindo-runner/.venv/bin:/home/rindo/.local/bin:${PATH}" \
    RINDO_RUNNER_LOG_JSON=1

# ENV PATH does not survive a LOGIN shell — /etc/profile overwrites PATH
# outright, so `docker exec … bash -l` (and any template routed through one)
# would not see rindo-runner or the agent CLIs. profile.d runs after that reset.
RUN printf '%s\n' \
      'export PATH="/opt/rindo-runner/.venv/bin:$HOME/.local/bin:$PATH"' \
      > /etc/profile.d/10-rindo-path.sh

# Mechanical proof the copied venv is self-contained (the smoke can't see a
# missing interpreter; --version needs no config, so it runs in any build).
RUN rindo-runner --version

COPY docker-entrypoint.sh /usr/local/bin/rindo-entrypoint
RUN chmod 755 /usr/local/bin/rindo-entrypoint

USER rindo
WORKDIR /workspace

# tini reaps orphaned grandchildren and forwards SIGTERM to the runner, whose
# own handler does the graceful stop (kill the job's process group, report it
# failed, exit 0) — so `docker stop` leaves truthful job history. It stays PID 1
# across the entrypoint's exec. The entrypoint is a no-op in this target (its
# work is all `command -v codex`-gated) and is mounted here anyway so both
# targets share one contract.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/rindo-entrypoint"]
CMD ["rindo-runner"]

# ---- agents: the default target — runner + the coding agents ----------------
FROM runner AS agents
USER root

# `latest` at pin time was 2.1.223; the `stable` dist-tag is what unattended
# hosts should track, so that is the pin. Both CLIs are native-binary packages
# with zero runtime deps — npm resolves the arch-matching optionalDependency, so
# the image is arch-native (build on the target arch, or use buildx).
ARG CLAUDE_CODE_VERSION=2.1.220
ARG CODEX_VERSION=0.147.0
# npm's default prefix (/usr/local) is deliberately left alone in this image —
# see the PATH comment in the runner stage for why the CLIs must live somewhere
# the runtime user cannot write.
RUN npm install -g --no-fund --no-audit \
      "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
      "@openai/codex@${CODEX_VERSION}" \
    && npm cache clean --force

# The two forge CLIs — Rindo integrates BOTH providers (GitHub F2, GitLab
# GL1/GL2), so an agent opening an MR is as expected as one opening a PR.
# Neither ships in Debian, so both come from their release channels; these are
# the only binaries here not pulled from a pinned registry, hence the checksum
# gate. Both projects publish `<sha256>  <filename>` lists, so one verifier
# covers them, and both name their debs by the same arch strings dpkg reports.
ARG GH_VERSION=2.97.0
ARG GLAB_VERSION=1.112.0
RUN set -eu; \
    arch="$(dpkg --print-architecture)"; \
    gh_base="https://github.com/cli/cli/releases/download/v${GH_VERSION}"; \
    glab_base="https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/packages/generic/glab/${GLAB_VERSION}"; \
    curl -fsSL -o /tmp/gh.deb        "${gh_base}/gh_${GH_VERSION}_linux_${arch}.deb"; \
    curl -fsSL -o /tmp/gh_sums.txt   "${gh_base}/gh_${GH_VERSION}_checksums.txt"; \
    curl -fsSL -o /tmp/glab.deb      "${glab_base}/glab_${GLAB_VERSION}_linux_${arch}.deb"; \
    curl -fsSL -o /tmp/glab_sums.txt "${glab_base}/checksums.txt"; \
    verify() { \
      expected="$(grep " $2\$" "$3" | cut -d' ' -f1)"; \
      [ -n "$expected" ] || { echo "no checksum listed for $2" >&2; exit 1; }; \
      echo "${expected}  $1" | sha256sum -c -; \
    }; \
    verify /tmp/gh.deb   "gh_${GH_VERSION}_linux_${arch}.deb"       /tmp/gh_sums.txt; \
    verify /tmp/glab.deb "glab_${GLAB_VERSION}_linux_${arch}.deb"   /tmp/glab_sums.txt; \
    apt-get update && apt-get install -y --no-install-recommends /tmp/gh.deb /tmp/glab.deb; \
    rm -rf /var/lib/apt/lists/* /tmp/gh.deb /tmp/glab.deb /tmp/gh_sums.txt /tmp/glab_sums.txt

# The image is pinned, so a self-update would silently defeat the pin — and the
# runtime user cannot write /usr/local anyway, so it would just fail noisily.
# The update NOTIFIERS matter for a second reason: everything a job prints is
# streamed to the server and shown to every project member (AGT-11), so a
# "what's new" banner or a telemetry error is noise in someone's job log.
ENV DISABLE_AUTOUPDATER=1 \
    GH_NO_UPDATE_NOTIFIER=1

# Same guard idiom as the runner stage: assert every CLI actually resolves on
# PATH and runs, at build time, on the arch that was built.
RUN claude --version && codex --version && gh --version && glab --version

USER rindo
WORKDIR /workspace

# glab's twin of the two ENVs above. Config, not env — glab exposes no env knob
# for either — and it must be written AS the runtime user, since it lands in
# ~/.config/glab-cli (a named volume mounted there inherits this seed file).
RUN glab config set --global check_update false \
    && glab config set --global telemetry false

# The Rindo MCP server, wired ONCE at build time and still deployment-agnostic:
# Claude Code resolves ${…} placeholders when it CONNECTS, not when the entry is
# added (verified against a stub — the stored config keeps the literal text
# while the server receives the real URL and token). So the same image works
# against any Rindo, no secret is ever written to disk, and rotating the runner
# token takes effect on the next job (a shared runner's job holds its OWN per-job
# `rndj_` under this name — the runner's `rndw_` never enters a job). `--scope
# user` because a project-scope
# .mcp.json would sit at "⏸ Pending approval" until a human confirmed it, and
# nobody is at the keyboard. Codex gets the same server from the entrypoint —
# it expands neither field.
RUN claude mcp add --scope user --transport http rindo '${RINDO_MCP_URL}' \
      --header 'Authorization: Bearer ${RINDO_RUNNER_TOKEN}'
