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
+164
View File
@@ -21,6 +21,7 @@ from roboco.llm.providers import (
ClaudeCodeProvider,
CodexCliProvider,
GrokCliProvider,
KimiCliProvider,
ProviderError,
ProviderNotRegisteredError,
ProviderRegistry,
@@ -48,6 +49,14 @@ def _isolate_codex_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path
return codex_dir
@pytest.fixture(autouse=True)
def _isolate_kimi_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point KIMI_AUTH_HOST_PATH at a fresh tmp dir (parity with codex above)."""
kimi_dir = tmp_path / "kimi-auth"
monkeypatch.setattr("roboco.llm.providers.kimi.KIMI_AUTH_HOST_PATH", str(kimi_dir))
return kimi_dir
def _config(
*,
agent_id: str = "be-dev-1",
@@ -99,6 +108,9 @@ class _FakeHost:
def _ensure_codex_usage_dir(self, agent_id: str) -> None:
self.data_dirs_ensured.append(agent_id)
def _ensure_kimi_usage_dir(self, agent_id: str) -> None:
self.data_dirs_ensured.append(agent_id)
def _resolve_host_paths(
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
) -> dict[str, str | None]:
@@ -109,6 +121,7 @@ class _FakeHost:
"settings": str(agent_settings_path) if agent_settings_path else None,
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
"codex_usage": f"/host/data/codex-usage/{config.agent_id}",
"kimi_usage": f"/host/data/kimi-usage/{config.agent_id}",
}
def _build_mount_args(
@@ -477,6 +490,157 @@ async def test_codex_spawn_raises_on_docker_failure() -> None:
await provider.spawn(_codex_config())
# ---------------------------------------------------------------------------
# KimiCliProvider
# ---------------------------------------------------------------------------
def _kimi_config(
*,
agent_id: str = "be-dev-1",
provider_base_url: str | None = "https://api.x.ai/v1",
provider_auth_token: str | None = "should-not-leak",
mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"),
) -> OrchestratorAgentConfig:
return OrchestratorAgentConfig(
agent_id=agent_id,
blueprint_path=Path("/app/system-prompt.md"),
model="kimi-code/k3",
mcp_config_path=mcp_config_path,
claude_session_id="sess-1",
provider_type="kimi",
provider_base_url=provider_base_url,
provider_auth_token=provider_auth_token,
)
async def test_kimi_spawn_requires_mcp_config() -> None:
provider = KimiCliProvider(_FakeHost())
with pytest.raises(ProviderError, match="MCP config"):
await provider.spawn(_kimi_config(mcp_config_path=None))
async def test_kimi_spawn_does_not_require_api_key() -> None:
# Subscription auth (mounted ~/.kimi-code) — a missing provider key is fine.
host = _FakeHost()
provider = KimiCliProvider(host)
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
result = await provider.spawn(_kimi_config(provider_auth_token=None))
assert result.instance_id == "roboco-agent-be-dev-1"
async def test_kimi_spawn_no_leaked_key_and_no_anthropic_leak() -> None:
host = _FakeHost()
provider = KimiCliProvider(host, image="roboco-agent-kimi:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_kimi_config(), initial_prompt="do the work")
cmd = list(exec_mock.call_args.args)
assert not any(c.startswith("MOONSHOT_API_KEY=") for c in cmd)
# The provider endpoint must NOT be injected as an Anthropic var.
assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd)
assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd)
assert host.mount_config is not None
assert host.mount_config.provider_base_url is None
assert host.mount_config.provider_auth_token is None
async def test_kimi_spawn_wires_gateway_env_and_image_last() -> None:
host = _FakeHost()
provider = KimiCliProvider(host, image="roboco-agent-kimi:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
result = await provider.spawn(_kimi_config())
cmd = list(exec_mock.call_args.args)
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd
assert "ROBOCO_AGENT_MODEL=kimi-code/k3" in cmd
# Usage capture: per-agent data dir mounted + the entrypoint's usage file.
assert host.data_dirs_ensured == ["be-dev-1"]
assert "/host/data/kimi-usage/be-dev-1:/home/agent/.kimi-usage" in cmd
assert "ROBOCO_KIMI_USAGE_FILE=/home/agent/.kimi-usage/usage.json" in cmd
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
assert cmd[-1] == "roboco-agent-kimi:test"
assert host.removed == ["roboco-agent-be-dev-1"]
assert host.remove_stop_reasons == ["pre_spawn_stale_clear"]
assert result == SpawnResult(
instance_id="roboco-agent-be-dev-1",
extra={"container_id": "cid", "model": "kimi-code/k3"},
)
async def test_kimi_spawn_mounts_auth_when_present(_isolate_kimi_auth: Path) -> None:
creds_dir = _isolate_kimi_auth / "credentials"
creds_dir.mkdir(parents=True, exist_ok=True)
(creds_dir / "kimi-code.json").write_text("{}", encoding="utf-8")
host = _FakeHost()
provider = KimiCliProvider(host)
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_kimi_config())
cmd = list(exec_mock.call_args.args)
# Mount the host ~/.kimi-code DIRECTORY read-write (rotation-with-grace,
# not truly reusable — every container must share ONE chain with the
# host, not a private copy) — the entrypoint symlinks credentials/ and
# oauth/ forward into a container-local, writable ~/.kimi-code.
expected = f"{_isolate_kimi_auth}:/home/agent/.kimi-code-auth"
assert expected in cmd
async def test_kimi_spawn_omits_auth_mount_when_absent() -> None:
host = _FakeHost()
provider = KimiCliProvider(host)
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_kimi_config())
cmd = list(exec_mock.call_args.args)
assert not any("/home/agent/.kimi-code-auth" in c for c in cmd)
async def test_kimi_spawn_warns_when_auth_absent(
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level("WARNING", logger="roboco.llm.providers.kimi")
host = _FakeHost()
provider = KimiCliProvider(host)
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
await provider.spawn(_kimi_config())
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert warnings, "expected a spawn-time WARNING for the missing host credential"
msg = warnings[0].getMessage()
assert "kimi-code.json" in msg
assert "kimi login" in msg
async def test_kimi_spawn_prompt_is_injection_safe() -> None:
host = _FakeHost()
provider = KimiCliProvider(host)
nasty = "--model evil --session-id pwned"
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_kimi_config(), initial_prompt=nasty)
cmd = list(exec_mock.call_args.args)
assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd
assert nasty not in cmd
async def test_kimi_spawn_raises_on_docker_failure() -> None:
provider = KimiCliProvider(_FakeHost())
with (
patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")),
),
pytest.raises(ProviderError, match="boom"),
):
await provider.spawn(_kimi_config())
# ---------------------------------------------------------------------------
# ClaudeCodeProvider
# ---------------------------------------------------------------------------