feat(kimi): Kimi K3 provider on the official kimi-code CLI (#713)

* feat(kimi): Kimi K3 provider on the official kimi-code CLI (Wave 1)

ModelProvider.KIMI routes through KimiCliProvider driving Moonshot's kimi
CLI on a Kimi subscription (OAuth device-code, no metered key). One-shot
delivery roles only (V1), interactive ban wired in both guard lists.

Auth: one shared RW auth mount; containers symlink credentials/ and
oauth/ (the CLI's cross-process refresh-lock dir) into a container-local
KIMI_CODE_HOME so every container and the host redeem the SAME rotating
refresh chain - live-verified that per-copy chains cross-invalidate after
the reuse-grace window. No orchestrator refresh daemon; an expires_at
preflight exits 78.

Config renderer mirrors the login-managed provider/model blocks
field-for-field (live-captured; the model value is the CLI-side name,
never the raw API id), plus per-role deny rules and the bash-guard as a
PreToolUse hook via a wrapper script (an env key on a hooks entry makes
the CLI silently drop ALL hooks - live-verified). Usage capture sums
wire.jsonl usage.record 4-bucket events; sniff classifies rate-limit/auth
from structured error text only, mapped to the shared 75/78 park
contract. Image installs the CLI latest-at-build (no version pin, by
policy) with the resolved version stamped as provenance, binary split to
/usr/local away from mutable state.

Migrations 090 (enum) + 091 (provider seed); catalog, pricing, routing
mode, and orchestrator park/usage wiring mirror the codex integration.

* feat(kimi): surface sweep + fleet-wide pin drop (Wave 2)

Compose x3 gain the agent-kimi-image service and the orchestrator's
read-write ~/.kimi-code mount + kimi-usage dir; .env.example documents
the Kimi block. Panel mirrors ModelProvider.KIMI and adds the kimi
routing mode (catalog filter, mode button, mix-picker group, badge) with
tests; provider routes gain the kimi remediation entry. CLAUDE.md and
docs/map document the runtime. Per the no-pins policy, agent-grok/
gemini/codex Dockerfiles drop their version pins for latest-at-build
with resolved-version provenance stamps (grok resolves 0.2.112 vs the
old 0.2.56 pin - verified by real builds of all four images).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-29 01:48:55 +02:00
committed by GitHub
co-authored by Renn F
parent eb470dfb33
commit 6374bbbed0
43 changed files with 3907 additions and 110 deletions
+11 -10
View File
@@ -19,17 +19,17 @@ FROM roboco-agent-base
USER root
# Install the official codex CLI globally via npm. Pinned — untrusted model
# output runs under it, so bump the version deliberately, never float. The
# npm route (not chatgpt.com/codex/install.sh, which the CDN denies to
# non-browser clients) has no postinstall network fetch: the native binary
# rides an optionalDependency (@openai/codex-linux-x64) served from the
# public npm registry. Global install symlinks `codex` onto PATH for the
# agent user; verify it runs so a broken install fails the build, not spawn.
ARG CODEX_CLI_VERSION=0.145.0
RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} \
# Install the official codex CLI globally via npm. NO version pin (2026-07-28
# policy: latest-at-build, always adapt — fleet-wide across grok/gemini/codex/
# kimi). The npm route (not chatgpt.com/codex/install.sh, which the CDN denies
# to non-browser clients) has no postinstall network fetch: the native binary
# rides an optionalDependency (@openai/codex-linux-x64) served from the public
# npm registry. Global install symlinks `codex` onto PATH for the agent user;
# verify it runs so a broken install fails the build, not spawn; the resolved
# version is stamped to /etc/codex-cli-version for per-image provenance.
RUN npm install -g @openai/codex \
&& command -v codex \
&& codex --version
&& codex --version | tee /etc/codex-cli-version
# Entrypoint: render ~/.codex/config.toml + execpolicy rules + the per-role
# sandbox flag, then run codex headless (overrides the base image's `claude`
@@ -47,5 +47,6 @@ ENV PATH="/home/agent/.codex/bin:/home/agent/.local/bin:/app/.venv/bin:$PATH"
LABEL role="codex-cli-runtime"
LABEL description="Codex (OpenAI) agent runtime — Codex Build via the official codex CLI"
LABEL codex.cli.pinned="false"
ENTRYPOINT ["/app/scripts/codex-cli-agent-entrypoint.sh"]
+9 -7
View File
@@ -17,15 +17,16 @@ FROM roboco-agent-base
USER root
# Install the official Gemini CLI. Pinned — untrusted model output runs under
# it, so bump the version deliberately, never float (spike verified 0.52.0 at
# github.com/google-gemini/gemini-cli @ 9681621c). npm installs to the global
# node_modules the base image's Node 22 already resolves onto PATH.
ARG GEMINI_CLI_VERSION=0.52.0
RUN npm install -g "@google/gemini-cli@${GEMINI_CLI_VERSION}" \
# Install the official Gemini CLI. NO version pin (2026-07-28 policy:
# latest-at-build, always adapt — fleet-wide across grok/gemini/codex/kimi).
# npm installs to the global node_modules the base image's Node 22 already
# resolves onto PATH; the resolved version is stamped to /etc/gemini-cli-version
# for per-image provenance (a record, not a pin).
RUN npm install -g @google/gemini-cli \
&& npm cache clean --force \
&& rm -rf /root/.npm /tmp/* \
&& gemini --version
&& command -v gemini \
&& gemini --version | tee /etc/gemini-cli-version
# Entrypoint: copy the staged OAuth credential into a writable ~/.gemini,
# render settings.json + policy TOML, then run gemini headless (overrides the
@@ -39,6 +40,7 @@ USER agent
LABEL role="gemini-cli-runtime"
LABEL description="Gemini (Google) agent runtime — Gemini Build via the official gemini CLI"
LABEL gemini.cli.pinned="false"
# advanced.autoConfigureMemory=false (rendered into settings.json) pins Node's
# heap sizing away from auto-detection against a shared host; this bounds it
+14 -9
View File
@@ -17,17 +17,21 @@ USER root
# Install the official grok CLI (Grok Build) for the agent user. The installer's
# default is $HOME/.grok/bin, so the binary lands at ~/.grok/bin/grok alongside
# its runtime (downloads / bundled / skills) under ~/.grok, all agent-owned.
# Pinned — untrusted model output runs under it, so bump the version deliberately,
# never float. Download the installer to a file first (a `curl | bash` pipe hides
# a curl failure as a silent no-op) and verify the binary installed AND runs, so
# a broken install fails the build here, not at spawn. (curl/bash from the base.)
ARG GROK_CLI_VERSION=0.2.56
# NO version pin (2026-07-28 policy: latest-at-build, always adapt — fleet-wide
# across grok/gemini/codex/kimi). Download the installer to a file first (a
# `curl | bash` pipe hides a curl failure as a silent no-op) and verify the
# binary installed AND runs, so a broken install fails the build here, not at
# spawn; the resolved version is stamped to /etc/grok-cli-version (build-log +
# on-disk provenance, not a pin — the next build reinstalls whatever's latest).
RUN su agent -s /bin/bash -c "set -euo pipefail; export HOME=/home/agent; \
curl -fsSL https://x.ai/cli/install.sh -o /tmp/grok-install.sh; \
bash /tmp/grok-install.sh ${GROK_CLI_VERSION}; \
test -x /home/agent/.grok/bin/grok; \
/home/agent/.grok/bin/grok --version" \
&& rm -rf /tmp/*
bash /tmp/grok-install.sh; \
test -x /home/agent/.grok/bin/grok" \
&& rm -rf /tmp/* \
# Provenance stamp runs as root (outside the su subshell — /etc is
# root-writable only) with root's HOME; fine while `grok --version`
# touches no $HOME-relative state.
&& /home/agent/.grok/bin/grok --version | tee /etc/grok-cli-version
# Entrypoint: render ~/.grok/config.toml + the per-role flags, then run grok
# headless (overrides the base image's `claude` entrypoint). ~/.grok is already
@@ -43,5 +47,6 @@ ENV PATH="/home/agent/.grok/bin:/app/.venv/bin:$PATH"
LABEL role="grok-cli-runtime"
LABEL description="Grok (xAI) agent runtime — Grok Build via the official grok CLI"
LABEL grok.cli.pinned="false"
ENTRYPOINT ["/app/scripts/grok-cli-agent-entrypoint.sh"]
+71
View File
@@ -0,0 +1,71 @@
# Kimi (Moonshot) Agent Image
# =============================================================================
# Runs Kimi K3 through Moonshot's official `kimi` (kimi-code) CLI,
# authenticated by a Kimi subscription via a symlinked-in
# ~/.kimi-code/credentials/kimi-code.json + oauth/ (the shared RW auth mount
# — see roboco.llm.providers.kimi's module docstring) — the parity analogue
# of the Claude Code path's mounted ~/.claude and the codex/gemini paths'
# subscription mounts (no metered API key). Reuses the base image's roboco
# venv + uv + the RoboCo MCP gateway servers. The entrypoint symlinks the
# mounted credential in, renders ~/.kimi-code/config.toml + mcp.json +
# AGENTS.md from the mounted mcp-config.json (see
# roboco.llm.providers.kimi_cli_config), and runs the CLI headless. One
# runtime image serves every one-shot delivery role — role behaviour comes
# from the mounted system prompt / manifest / mcp-config, exactly as on the
# Claude/grok/codex/gemini paths.
#
# V1 scope: no interactive intake/secretary variant of this image exists
# (unlike grok's agent-grok-prompter / agent-grok-secretary) — Kimi is
# one-shot delivery roles only for now.
# =============================================================================
FROM roboco-agent-base
USER root
# Install the official kimi-code CLI. NO version pin (CEO decision,
# 2026-07-28 — latest at build, always adapt: Kimi's CLI is very young and
# has already shipped a breaking rename within a week of release). The
# install script itself SHA-256-verifies the binary it fetches.
# KIMI_INSTALL_DIR splits the binary (/usr/local/bin/kimi — needs root to
# write) from KIMI_CODE_HOME's mutable per-agent state (~/.kimi-code,
# rendered fresh at container start, never baked into the image — see
# roboco.llm.providers.kimi_cli_config): the installer's own default
# co-locates both under ~/.kimi-code, which would mix a writable binary path
# into the exact tree the entrypoint later writes credentials/config into.
# Build-fails-loud verification (a broken install fails the build here, not
# at spawn); the resolved version is captured to both the build log (RUN
# output) and a baked-in file for runtime attribution — Docker has no native
# mechanism to compute a LABEL value from a RUN command's own output, so the
# file is the durable per-image provenance record (a record, not a pin: the
# next build always reinstalls whatever is latest that day).
ENV KIMI_INSTALL_DIR=/usr/local
RUN curl -fsSL https://code.kimi.com/kimi-code/install.sh -o /tmp/kimi-install.sh \
&& bash /tmp/kimi-install.sh \
&& command -v kimi \
&& kimi --version | tee /etc/kimi-cli-version \
&& rm -rf /tmp/*
# Entrypoint: symlink the mounted credential in, render config.toml/mcp.json/
# AGENTS.md, then run kimi headless (overrides the base image's `claude`
# entrypoint). Pre-create + chown ~/.kimi-code (mirrors the gemini image —
# the entrypoint's own symlink/render steps then just write into it).
COPY docker/scripts/kimi-cli-agent-entrypoint.sh /app/scripts/kimi-cli-agent-entrypoint.sh
COPY docker/scripts/kimi-bash-guard-wrapper.sh /app/scripts/kimi-bash-guard-wrapper.sh
RUN chmod 0755 /app/scripts/kimi-cli-agent-entrypoint.sh /app/scripts/kimi-bash-guard-wrapper.sh \
&& mkdir -p /home/agent/.kimi-code \
&& chown -R agent:agent /home/agent/.kimi-code
USER agent
LABEL role="kimi-cli-runtime"
LABEL description="Kimi (Moonshot) agent runtime — Kimi K3 via the official kimi CLI"
LABEL kimi.cli.pinned="false"
# Runtime self-update is pure spawn latency + an unreviewed binary fetch in
# an ephemeral container (an update can't persist anyway) — suppressed at
# the env level; `[upgrade] auto_install=false` in the rendered config.toml
# is the belt-and-suspenders config-level twin (roboco.llm.providers.kimi_cli_config).
ENV KIMI_CODE_NO_AUTO_UPDATE=1
ENTRYPOINT ["/app/scripts/kimi-cli-agent-entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Kimi's [[hooks]] TOML entry has no `env` field — an entry carrying one is
# silently dropped whole (live-verified: "Ignored invalid config ... hooks",
# run continues with NO hooks installed at all). ROBOCO_GUARD_SKIP_GIT=1 must
# therefore ride a wrapper's own export, not the hook config, since kimi's
# `command` is a plain path (unverified whether it shell-interprets the
# string, so `env VAR=1 cmd` is not assumed safe).
export ROBOCO_GUARD_SKIP_GIT=1
exec /app/scripts/bash-guard-hook.sh "$@"
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Entrypoint for the roboco-agent-kimi image (one-shot delivery roles only —
# see docker/agent-kimi.Dockerfile for the V1 scope note).
#
# Runs an agent on Moonshot's official `kimi` (kimi-code) CLI, authenticated
# by a Kimi subscription via a symlinked-in
# ~/.kimi-code/credentials/kimi-code.json + oauth/ (the shared RW auth mount)
# — the parity analogue of the codex-cli entrypoint's mounted ~/.codex. The
# gateway, identity, and workspace are mounted by the orchestrator's shared
# container assembly (the same that wires Claude/grok/codex); this entrypoint
# symlinks the host credential in, renders the kimi runtime config from that
# mount, and runs the CLI headless.
set -euo pipefail
# Split-install sanity: docker/agent-kimi.Dockerfile installs the binary to
# KIMI_INSTALL_DIR=/usr/local (split from KIMI_CODE_HOME's mutable state) —
# verify it actually resolved on PATH before doing any other work.
command -v kimi >/dev/null || {
echo "[kimi] kimi CLI not found on PATH — image build is broken." >&2
exit 1
}
# Symlink phase. The orchestrator mounts the host ~/.kimi-code DIRECTORY
# READ-WRITE at this path (roboco.llm.providers.kimi._append_kimi_auth_mount).
# Moonshot's refresh token is rotation-with-short-reuse-grace, not truly
# reusable (live-verified: a per-container COPY cross-invalidates the shared
# chain once the grace window passes — see roboco.llm.providers.kimi's
# module docstring), so every container must redeem the SAME chain the host
# uses: symlink credentials/ AND oauth/ (the cross-process refresh lock
# directory — entirely missing from the old copy-in, and load-bearing: it's
# what serializes concurrent redemptions across containers + the host)
# straight into the image's own, writable ~/.kimi-code. config.toml/mcp.json/
# AGENTS.md are still rendered fresh below, never symlinked.
AUTH_DIR="/home/agent/.kimi-code-auth"
mkdir -p /home/agent/.kimi-code
if [ -d "$AUTH_DIR/credentials" ]; then
mkdir -p "$AUTH_DIR/oauth"
ln -sfn "$AUTH_DIR/credentials" /home/agent/.kimi-code/credentials
ln -sfn "$AUTH_DIR/oauth" /home/agent/.kimi-code/oauth
fi
# Render ~/.kimi-code/config.toml (managed provider/model blocks + telemetry/
# upgrade knobs + per-role [[permission.rules]] + the bash-guard [[hooks]])
# + mcp.json + AGENTS.md. Run from /app so `python -m` resolves the INSTALLED
# roboco package: dev/doc/qa agents run at their workspace-clone cwd, whose
# own roboco/ dir would shadow it on the sys.path front (the same
# ModuleNotFound lesson the codex/grok entrypoints document).
( cd /app && python -m roboco.llm.providers.kimi_cli_config )
# Prompt-injection guard (parity with the Claude/grok/codex path): the task
# prompt is DATA, not instructions — refuse a poisoned one before the model
# ever sees it. The composed role blueprint travels separately via the
# additive AGENTS.md (rendered above), so only the raw task prompt is
# screened here. Run from /app too.
if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}" ); then
echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2
exit 1
fi
# Auth fail-fast guard. D2 resolved Kimi's refresh token as
# rotation-with-short-reuse-grace over ONE shared chain (symlinked, not
# copied — see above) — each container self-refreshes through the CLI's own
# cross-process lock, so there is no orchestrator-side refresh daemon to
# backstop, only this preflight: read the symlinked
# credentials/kimi-code.json's expires_at (a plain JSON field, no JWT
# decode) and refuse fast (exit 78 / EX_CONFIG) on missing/expired, instead
# of the CLI hanging or failing deep into the run.
if ! ( cd /app && python -m roboco.llm.providers.kimi_cli_config --check ); then
echo "[kimi] auth credential missing or expired — refusing to run. Run" \
"\`kimi login\` on the host (or set ROBOCO_HOST_KIMI_DIR to the" \
"directory holding credentials/kimi-code.json) before spawning Kimi" \
"agents." >&2
exit 78
fi
# Run the agent. `< /dev/null` keeps the headless run from blocking on
# stdin. We do NOT `exec`: the script regains control to classify the exit
# code + capture usage. The container's cwd is already the agent's
# workspace (the orchestrator sets it via docker run -w, mirroring the
# Claude/grok/codex path) — captured here BEFORE the usage-capture step's
# `cd /app` needs it to locate the right sessions/wd_<cwd-basename>_*/ dir.
# The prompt travels only as an env-var expansion into a single quoted argv
# token (never re-parsed by the shell) — the same injection-safety property
# as the grok/gemini path's env-var prompt passing; the composed role
# blueprint is NOT folded in here (unlike codex) since it already reached
# the model via the additive AGENTS.md rendered above.
WORKDIR="$PWD"
RUN_LOG="/tmp/kimi-run.jsonl"
ERR_LOG="/tmp/kimi-run.err"
# `--output-format stream-json` streams JSONL to stdout; `tee` shows it live
# via `docker logs` (parity with the Claude/grok/codex path) while ALSO
# capturing it to RUN_LOG for the usage-capture + sniff reads below. Never
# pipe this through `head` (a known EPIPE hazard on an early-closing reader —
# `tee` alone is safe, it always drains stdin to completion). stderr goes to
# ERR_LOG and is surfaced after the run.
set +e
kimi -p "${ROBOCO_INITIAL_PROMPT:-}" \
--output-format stream-json \
-m "${ROBOCO_AGENT_MODEL:-kimi-code/k3}" \
< /dev/null 2> "$ERR_LOG" | tee "$RUN_LOG"
run_rc=${PIPESTATUS[0]}
set -e
[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2
# Capture token usage from the session's wire.jsonl (kimi's stdout carries no
# usage summary of its own, unlike codex/gemini — see kimi_cli_usage for the
# session-dir resolution). Best-effort; never fails the run. Run from /app
# for the same module-resolution reason as the render above; ROBOCO_KIMI_WORKDIR
# carries the captured workspace cwd so the usage reader can find the right
# sessions/wd_<cwd-basename>_*/ directory after this subshell's own `cd /app`.
( cd /app && ROBOCO_KIMI_RUN_LOG="$RUN_LOG" ROBOCO_KIMI_WORKDIR="$WORKDIR" \
python -m roboco.llm.providers.kimi_cli_usage ) || true
# Kimi has NO documented exit-code taxonomy for `-p` (a claimed 75/1 split is
# unverified noise) — every failure looks the same at the process level.
# Classify the run WITHOUT scanning the full transcript: the model's own
# on-topic prose can false-positive a raw grep by construction — kimi_cli_sniff
# extracts ONLY structured error fields off error-bearing JSONL events plus
# stderr and classifies THAT, never stdout's echoed assistant/tool content.
# Mirrors the codex/grok/gemini entrypoints' exit-75/78 convention so the
# orchestrator's existing park-and-probe logic, scoped by provider_type,
# handles all four providers identically:
# - rate-limit/quota -> exit 75 (EX_TEMPFAIL): the orchestrator PARKS the
# provider instead of the dispatcher respawning the same task every tick.
# - auth/membership failure (a lapsed subscription or an expired credential
# discovered mid-run, past the --check backstop above) -> exit 78
# (EX_CONFIG): parked the same way as a pre-run auth miss.
SNIFF="$( (cd /app && python -m roboco.llm.providers.kimi_cli_sniff "$RUN_LOG" "$ERR_LOG") 2>/dev/null || true)"
if [ "$SNIFF" = "rate_limit" ]; then
echo "[kimi] rate-limited — exiting 75 so the orchestrator parks the" \
"provider; the task is retried when the limit lifts." >&2
exit 75
fi
if [ "$SNIFF" = "auth" ]; then
echo "[kimi] auth/membership failure detected mid-run — exiting 78 so the" \
"orchestrator parks the provider until the credential is refreshed." >&2
exit 78
fi
# A graceful exit without a terminal verb is handled server-side by the
# orchestrator (_handle_stopped_container substitutes the still-owned task) —
# the kimi-cli runtime needs no in-container SDK server for that.
exit "$run_rc"