diff --git a/.env.example b/.env.example index f43cadf..327d869 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,9 @@ NOUS_API_KEY= OPENAI_API_KEY= ANTHROPIC_API_KEY= DEEPSEEK_API_KEY= +KIMI_API_KEY= +# Optional OpenAI-compatible Kimi K3 endpoint override. +KIMI_BASE_URL= TELEGRAM_BOT_TOKEN= DISCORD_BOT_TOKEN= SLACK_BOT_TOKEN= diff --git a/CHANGELOG.md b/CHANGELOG.md index caa3c2d..65261b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added a built-in Kimi K3 Hermes template with an optional compatible endpoint + override and an explicit, private `zeus audit init` Kimi K3 configuration. - Replaced the vulnerable cryptography and Pillow pins in the hash-locked Hermes Agent 0.19.0 CI environment with their first patched releases while retaining an exact, verified compatibility override for upstream metadata. diff --git a/README.md b/README.md index 6a30ce7..1b94468 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ tracks PID ownership, and exposes a small loopback CLI/API for operators. ## Why Zeus - Run multiple Hermes bots from one workspace without hand-copying profile directories. -- Stamp out repeatable bot shapes from TOML templates: coding, research, support, DeepSeek, and custom profiles. +- Stamp out repeatable bot shapes from TOML templates: coding, research, + support, DeepSeek, Kimi K3, and custom profiles. - Keep secrets out of templates by rendering per-profile `.env` files that stay ignored by git. - Supervise gateway processes with ownership markers before stop/status actions trust a PID. - Account for Hermes async delegation with explicit `max_async_children` caps in every built-in template. @@ -58,9 +59,23 @@ It never reads dirty or untracked worktree content, and it does not edit, remediate, commit, push, schedule, deploy, notify, or coordinate across hosts. Cross-host policy remains outside Zeus. -All four audit commands first discover the containing Git repository and its +All five audit commands first discover the containing Git repository and its Zeus state context. +### Initialize Kimi K3 + +Select Kimi K3 for repository audits with an explicit initialization step: + +```bash +zeus audit init +``` + +Initialization writes a private schema-v1 configuration containing provider +`kimi-coding`, model `kimi-k3`, and the environment name `KIMI_API_KEY`. It +stores no credential value, makes no provider request, creates no audit run, +and refuses to replace an existing configuration. A missing configuration +remains unconfigured until this command is run. + ### Check readiness Run the non-mutating readiness preflight: @@ -77,19 +92,11 @@ downloads nothing. ### Run an audit Only `audit run` requires those runtime prerequisites. Zeus never pulls the -image. Before the first run, create private -`$ZEUS_STATE_DIR/audit/config.json` with an explicit lowercase Hermes provider, -model, and at least one provider-prefixed API key or authentication/access -token. Endpoint and account metadata variables may be added alongside it: - -```json -{ - "schema_version": 1, - "provider": "deepseek", - "model": "deepseek-chat", - "provider_env": ["DEEPSEEK_API_KEY"] -} -``` +image. The `audit init` command creates the standard Kimi K3 selection at +`$ZEUS_STATE_DIR/audit/config.json`. Operators can edit that private file to +select another explicit lowercase Hermes provider and model or add permitted +endpoint and account metadata environment names. For a compatible Kimi K3 +endpoint, add `KIMI_BASE_URL` alongside `KIMI_API_KEY` in `provider_env`. The state directory must be outside the worktree or ignored by `.gitignore` policy loaded from the exact committed `HEAD`; global excludes and @@ -425,7 +432,31 @@ values fail bot creation without printing the value. Keep `./.env` mode `0600`; Zeus also writes the imported values only to the selected profile's mode-`0600` `.env` file. -Built-in templates include OpenRouter-backed bots and `deepseek-coding-bot`, which uses Hermes' native DeepSeek provider with `DEEPSEEK_API_KEY`. Example templates also cover gateway operations, log triage, and documentation writing. +Built-in templates include OpenRouter-backed bots, `deepseek-coding-bot`, and +`kimi-k3-coding-bot`. The Kimi template uses Hermes provider `kimi-coding` and +model `kimi-k3`: + +```bash +zeus bot create kimi-coder \ + --template kimi-k3-coding-bot \ + --env-from KIMI_API_KEY +``` + +Without `KIMI_BASE_URL`, Hermes uses Moonshot's international Open Platform +default at `https://api.moonshot.ai/v1`. A vendor exposing an +OpenAI-compatible Kimi K3 endpoint with model ID `kimi-k3` can be selected +explicitly: + +```bash +zeus bot create kimi-coder \ + --template kimi-k3-coding-bot \ + --env-from KIMI_API_KEY \ + --env-from KIMI_BASE_URL +``` + +The template does not use Kimi Code subscription credentials or its `k3` +subscription alias. Example templates also cover gateway operations, log +triage, and documentation writing. Each template should set a bounded async delegation cap: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8378f25..d9ea32d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,12 +70,14 @@ Set `ZEUS_STATE_DIR` to use a different runtime root. ## Repository Audit Boundary -Repository audit is a host-local command path with four actions: doctor, run, -list, and show. It materializes the exact committed `HEAD`, never a dirty or -untracked worktree. `AuditService` starts before normal service construction, -loads settings without repository `.env`, and stores only private report -artifacts. The packaged `zeus.bundled_skills.audit` instruction is the sole -audit skill; general skill loading remains unavailable. +Repository audit is a host-local command path with five actions: init, doctor, +run, list, and show. `init` explicitly selects Kimi K3 by atomically creating a +private configuration; it does not contact a provider or create a run. The run +path materializes the exact committed `HEAD`, never a dirty or untracked +worktree. `AuditService` starts before normal service construction, loads +settings without repository `.env`, and stores only private configuration and +report artifacts. The packaged `zeus.bundled_skills.audit` instruction is the +sole audit skill; general skill loading remains unavailable. The audit path accepts only Hermes Agent 0.19.0 and an already preloaded, digest-qualified Docker image. A broker seals one Docker container with network diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 34e37f9..abbd7ac 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -27,7 +27,7 @@ The first version is intentionally narrow: - It does not add an HTTP API, a SQLite migration, or cross-host coordination. - It does not audit dirty or untracked worktree content. - It never remediates findings and does not schedule audit runs; an operator - explicitly invokes one of the four local audit commands. + explicitly invokes one of the five local audit commands. ## Chosen Architecture @@ -64,7 +64,7 @@ The implementation is divided into focused internal modules: artifacts. - `zeus.audit_doctor` verifies Git, Hermes, Docker, image, mount, network, and private-path prerequisites. -- `zeus.audit` exposes a thin `AuditService` used by all four CLI actions. +- `zeus.audit` exposes a thin `AuditService` used by all five CLI actions. - `zeus.bundled_skills.audit` contains the versioned `SKILL.md`. `zeus.cli` only parses and presents audit commands. It does not contain @@ -301,6 +301,24 @@ Unknown fields and invalid types fail closed. Schema version 1 supports: - `limits`: bounded overrides for run duration, command duration, finding count, model output, report artifacts, snapshot entries, and snapshot bytes. +`zeus audit init` explicitly selects the standard Kimi K3 configuration: + +```json +{ + "model": "kimi-k3", + "provider": "kimi-coding", + "provider_env": ["KIMI_API_KEY"], + "schema_version": 1 +} +``` + +Initialization discovers and revalidates the repository, enforces the same +state-path policy as a run, and atomically creates the private configuration. +It never replaces an existing configuration, reads or stores a credential +value, contacts a provider, or creates an audit run. A failed initialization +can leave newly created empty private directories, but not a partial +configuration file. + Version 1 has these hard ceilings: - one concurrent audit per repository; @@ -331,6 +349,12 @@ selections block preflight. Every named variable must have a non-empty value at invocation. Model stdout and each final report artifact are individually capped at 1 MiB. +Provider names normally derive their environment allowlist from their uppercase +prefix. Hermes provider `kimi-coding` additionally accepts the official +`KIMI_API_KEY` and `KIMI_BASE_URL` names. To use an OpenAI-compatible Kimi K3 +vendor endpoint, add `KIMI_BASE_URL` to `provider_env` alongside +`KIMI_API_KEY`; the default initializer names only `KIMI_API_KEY`. + The six supported categories are: 1. security and trust boundaries; @@ -342,17 +366,23 @@ The six supported categories are: ## CLI Contract -Version 1 adds four local commands: +Version 1 provides five local commands: ```text +zeus audit init [--json] zeus audit doctor [--json] zeus audit run [--json] zeus audit list [--json] zeus audit show [--json] ``` -All four commands discover the containing Git repository and its Zeus state -context. `audit doctor` performs all non-model readiness checks. It reports +All five commands discover the containing Git repository and its Zeus state +context. `audit init` is the explicit provider-selection consent step. Human +and JSON output identify only the provider, model, credential environment name, +and next readiness command; they disclose neither a credential value nor a +private absolute path. + +`audit doctor` performs all non-model readiness checks. It reports missing Docker, pinned Hermes, credential, or image prerequisites without creating a run or downloading dependencies. @@ -535,8 +565,9 @@ The feature is additive and host-local: - Git repository discovery applies to every audit command. The supported Hermes release, Docker, configured provider credentials, and a preloaded immutable audit image are external runtime prerequisites for `audit run` only; - `audit doctor` checks readiness, while `audit list` and `audit show` do not - invoke those runtime checks. + `audit init` creates only private configuration, `audit doctor` checks + readiness, and `audit list` and `audit show` do not invoke those runtime + checks. - The existing generic Hermes adapter is not reused because it loads complete profile environments, permits generic passthrough, and buffers unbounded output. Audit subprocess construction has its own strict environment and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c767dbc..1318053 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,6 +35,10 @@ reconciliation evidence on one host. - Workspace-local configuration export and import that never exports secrets. - A local TUI for lifecycle status and reconciliation history. - Local plugin discovery with explicit trust and compatibility boundaries. +- A harness-neutral Agent Client Protocol layer for startup, sessions, + prompt/event streaming, cancellation, inspection, permissions, and shutdown. + Grok Build is the proposed first adapter; this is an evaluation item, not a + Zeus 0.5.0 implementation commitment. ## Out of scope diff --git a/docs/TEMPLATE_AUTHORING.md b/docs/TEMPLATE_AUTHORING.md index e501ce3..4a0a705 100644 --- a/docs/TEMPLATE_AUTHORING.md +++ b/docs/TEMPLATE_AUTHORING.md @@ -95,3 +95,41 @@ zeus bot create deepseek-coder --template deepseek-coding-bot --env DEEPSEEK_API ``` Do not commit rendered profile `.env` files. + +## Kimi K3 + +The built-in `kimi-k3-coding-bot` uses Hermes provider `kimi-coding` and model +`kimi-k3`: + +```toml +[hermes] +required_env = ["KIMI_API_KEY", "KIMI_BASE_URL"] + +[hermes.model] +provider = "kimi-coding" +default = "kimi-k3" +``` + +Pass the Moonshot Open Platform key by environment name: + +```bash +zeus bot create kimi-coder \ + --template kimi-k3-coding-bot \ + --env-from KIMI_API_KEY +``` + +The model configuration deliberately omits `base_url` and `api_mode`; Hermes +uses Moonshot's international endpoint at `https://api.moonshot.ai/v1` when +`KIMI_BASE_URL` is absent. To use a vendor that exposes an OpenAI-compatible +Kimi K3 endpoint with model ID `kimi-k3`, supply both declared names: + +```bash +zeus bot create kimi-coder \ + --template kimi-k3-coding-bot \ + --env-from KIMI_API_KEY \ + --env-from KIMI_BASE_URL +``` + +`--env-from` keeps values out of command arguments. This template does not +accept Kimi Code subscription credentials or substitute the subscription model +alias `k3`. diff --git a/scripts/repo_check.sh b/scripts/repo_check.sh index 77fa786..7b1e6bd 100755 --- a/scripts/repo_check.sh +++ b/scripts/repo_check.sh @@ -54,6 +54,7 @@ scripts/verify_real_hermes.sh scripts/fresh_vps_verify.sh templates/coding-bot.toml templates/deepseek-coding-bot.toml +templates/kimi-k3-coding-bot.toml templates/docs-writer-bot.toml templates/gateway-operator.toml templates/log-triage-bot.toml @@ -62,6 +63,7 @@ templates/support-gateway.toml zeus/bundled_templates/__init__.py zeus/bundled_templates/coding-bot.toml zeus/bundled_templates/deepseek-coding-bot.toml +zeus/bundled_templates/kimi-k3-coding-bot.toml zeus/bundled_templates/docs-writer-bot.toml zeus/bundled_templates/gateway-operator.toml zeus/bundled_templates/log-triage-bot.toml diff --git a/scripts/wheel_smoke.sh b/scripts/wheel_smoke.sh index f0d3388..263369f 100755 --- a/scripts/wheel_smoke.sh +++ b/scripts/wheel_smoke.sh @@ -89,6 +89,7 @@ esac for template_id in \ coding-bot \ deepseek-coding-bot \ + kimi-k3-coding-bot \ docs-writer-bot \ gateway-operator \ log-triage-bot \ diff --git a/templates/kimi-k3-coding-bot.toml b/templates/kimi-k3-coding-bot.toml new file mode 100644 index 0000000..1d8d311 --- /dev/null +++ b/templates/kimi-k3-coding-bot.toml @@ -0,0 +1,38 @@ +id = "kimi-k3-coding-bot" +name = "Kimi K3 Coding Bot" +description = "Repository maintenance and implementation bot backed by Kimi K3 through Hermes." +version = "0.1.0" +soul = """ +You are a coding-focused Hermes agent backed by Kimi K3. Ground claims in files, commands, test output, and commit evidence. Prefer small, maintainable changes and call out uncertainty directly. +""" + +[hermes] +required_env = ["KIMI_API_KEY", "KIMI_BASE_URL"] + +[hermes.model] +provider = "kimi-coding" +default = "kimi-k3" + +[hermes.terminal] +backend = "docker" +cwd = "." +home_mode = "profile" +timeout = 300 +docker_image = "nikolaik/python-nodejs:python3.11-nodejs20@sha256:8f958bdc1b4a422bfafd97cab4f69836401f616ae985d4b57a53d254f5bcb038" +docker_mount_cwd_to_workspace = false + +[hermes.gateway] +enabled = true + +[hermes.delegation] +max_iterations = 50 +max_concurrent_children = 3 +max_async_children = 3 +child_timeout_seconds = 0 +subagent_auto_approve = false + +[metadata] +use_case = "Repository maintenance using Kimi K3 through Hermes" +risk_level = "medium" +recommended_restart_policy = "manual" +tags = ["coding", "kimi", "kimi-k3"] diff --git a/tests/test_audit_cli.py b/tests/test_audit_cli.py index 259656e..fdc9e4c 100644 --- a/tests/test_audit_cli.py +++ b/tests/test_audit_cli.py @@ -10,6 +10,13 @@ class AuditCliContractTests(unittest.TestCase): + def test_audit_init_parses_without_normal_service_settings(self) -> None: + parser = build_parser() + args = parser.parse_args(["audit", "init", "--json"]) + self.assertEqual("audit", args.resource) + self.assertEqual("init", args.action) + self.assertTrue(args.as_json) + def test_audit_commands_parse_without_normal_service_settings(self) -> None: parser = build_parser() args = parser.parse_args(["audit", "show", "a" * 32, "--json"]) @@ -87,6 +94,55 @@ def test_audit_doctor_human_json_and_exit_matrix(self) -> None: stdout.getvalue(), ) + def test_audit_init_human_and_json_output_are_secret_free(self) -> None: + from zeus.audit_config import parse_audit_config + + config = parse_audit_config( + { + "schema_version": 1, + "provider": "kimi-coding", + "model": "kimi-k3", + "provider_env": ["KIMI_API_KEY"], + } + ) + expected_json = { + "model": "kimi-k3", + "next_command": "zeus audit doctor", + "provider": "kimi-coding", + "provider_env": ["KIMI_API_KEY"], + } + expected_human = ( + "provider: kimi-coding\n" + "model: kimi-k3\n" + "provider_env: KIMI_API_KEY\n" + "next: zeus audit doctor\n" + ) + forbidden = ( + "credential-value-for-test", + "/private/audit-state-for-test", + "config.json", + "run_id", + ) + + for as_json in (False, True): + with self.subTest(as_json=as_json): + service = mock.Mock() + service.initialize.return_value = config + argv = ["audit", "init", *(["--json"] if as_json else [])] + with ( + mock.patch("zeus.audit.AuditService.from_cwd", return_value=service), + mock.patch("sys.stdout", new_callable=StringIO) as stdout, + ): + exit_code = main(argv) + self.assertEqual(0, exit_code) + output = stdout.getvalue() + if as_json: + self.assertEqual(expected_json, json.loads(output)) + else: + self.assertEqual(expected_human, output) + for value in forbidden: + self.assertNotIn(value, output) + def test_audit_run_human_json_and_status_exit_matrix(self) -> None: from zeus.audit_models import AuditStatus diff --git a/tests/test_audit_config.py b/tests/test_audit_config.py index 802222e..870473f 100644 --- a/tests/test_audit_config.py +++ b/tests/test_audit_config.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import math +import stat import tempfile import unittest from dataclasses import FrozenInstanceError @@ -9,6 +11,7 @@ from zeus.audit_config import ( DEFAULT_AUDIT_IMAGE, AuditConfigError, + initialize_audit_config, load_audit_config, parse_audit_config, ) @@ -19,6 +22,7 @@ AuditLimits, SuggestedCommand, ) +from zeus.private_io import UnsafeFileError CONFIGURABLE_LIMITS = { "overall_seconds": HARD_LIMITS.overall_seconds, @@ -46,6 +50,69 @@ def write_config_bytes(self, data: bytes) -> None: path.write_bytes(data) path.chmod(0o600) + def test_initializer_creates_exact_private_kimi_configuration(self) -> None: + config = initialize_audit_config(self.state_dir) + config_path = self.state_dir / "audit" / "config.json" + self.assertEqual( + { + "model": "kimi-k3", + "provider": "kimi-coding", + "provider_env": ["KIMI_API_KEY"], + "schema_version": 1, + }, + json.loads(config_path.read_bytes()), + ) + self.assertEqual("kimi-coding", config.provider) + self.assertEqual("kimi-k3", config.model) + self.assertEqual(("KIMI_API_KEY",), config.provider_env) + self.assertEqual(0o700, stat.S_IMODE(self.state_dir.stat().st_mode)) + self.assertEqual(0o700, stat.S_IMODE(config_path.parent.stat().st_mode)) + self.assertEqual(0o600, stat.S_IMODE(config_path.stat().st_mode)) + + def test_initializer_refuses_to_replace_existing_configuration(self) -> None: + initialize_audit_config(self.state_dir) + config_path = self.state_dir / "audit" / "config.json" + original = config_path.read_bytes() + + with self.assertRaises(UnsafeFileError): + initialize_audit_config(self.state_dir) + + self.assertEqual(original, config_path.read_bytes()) + + def test_kimi_provider_adds_canonical_aliases_to_derived_environment_names(self) -> None: + for provider_env in ( + ["KIMI_API_KEY"], + ["KIMI_API_KEY", "KIMI_BASE_URL"], + ["KIMI_CODING_API_KEY"], + ): + with self.subTest(provider_env=provider_env): + try: + config = parse_audit_config( + { + "schema_version": 1, + "provider": "kimi-coding", + "model": "kimi-k3", + "provider_env": provider_env, + } + ) + except AuditConfigError as exc: + self.fail(f"canonical Kimi environment was rejected: {exc}") + self.assertEqual(tuple(provider_env), config.provider_env) + + for provider_env in ( + ["KIMI_BASE_URL"], + ["KIMI_API_KEY", "OPENROUTER_API_KEY"], + ): + with self.subTest(provider_env=provider_env), self.assertRaises(AuditConfigError): + parse_audit_config( + { + "schema_version": 1, + "provider": "kimi-coding", + "model": "kimi-k3", + "provider_env": provider_env, + } + ) + def test_missing_config_returns_immutable_release_defaults(self) -> None: config = load_audit_config(self.state_dir) diff --git a/tests/test_audit_service.py b/tests/test_audit_service.py index bef603f..d603fc4 100644 --- a/tests/test_audit_service.py +++ b/tests/test_audit_service.py @@ -130,8 +130,109 @@ def test_public_service_interface_is_available(self) -> None: from zeus.audit import AuditService self.assertTrue(callable(AuditService.from_cwd)) - for name in ("doctor", "run", "list_reports", "show", "show_markdown"): - self.assertTrue(callable(getattr(AuditService, name))) + for name in ("initialize", "doctor", "run", "list_reports", "show", "show_markdown"): + self.assertTrue(callable(getattr(AuditService, name, None))) + + def test_initialize_creates_kimi_config_without_audit_run(self) -> None: + from zeus.audit import AuditService + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + self._repository(root) + service = AuditService.from_cwd(cwd=root, env={}) + + config = service.initialize() + + self.assertEqual("kimi-coding", config.provider) + self.assertEqual("kimi-k3", config.model) + self.assertEqual(("KIMI_API_KEY",), config.provider_env) + self.assertTrue((root / ".zeus" / "audit" / "config.json").is_file()) + self.assertFalse((root / ".zeus" / "audits").exists()) + self.assertFalse((root / ".zeus" / "audit" / "runs").exists()) + + def test_initialize_allows_state_outside_repository(self) -> None: + from zeus.audit import AuditService + + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary).resolve() + root = base / "repository" + state_dir = base / "private-state" + self._repository(root) + service = AuditService.from_cwd( + cwd=root, + env={"ZEUS_STATE_DIR": str(state_dir)}, + ) + + config = service.initialize() + + self.assertEqual("kimi-coding", config.provider) + self.assertTrue((state_dir / "audit" / "config.json").is_file()) + self.assertFalse((state_dir / "audits").exists()) + + def test_initialize_rejects_unignored_repository_state_without_writing(self) -> None: + from zeus.audit import AuditService, AuditServiceError + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + self._repository(root, ignored_state=False) + service = AuditService.from_cwd(cwd=root, env={}) + + with self.assertRaisesRegex(AuditServiceError, "ignored and untracked"): + service.initialize() + + self.assertFalse((root / ".zeus").exists()) + + def test_initialize_refuses_existing_config_without_changing_bytes(self) -> None: + from zeus.audit import AuditService, AuditServiceError + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + self._repository(root) + service = AuditService.from_cwd(cwd=root, env={}) + service.initialize() + config_path = root / ".zeus" / "audit" / "config.json" + original = config_path.read_bytes() + + with self.assertRaisesRegex(AuditServiceError, "initialization failed"): + service.initialize() + + self.assertEqual(original, config_path.read_bytes()) + + def test_initialize_revalidates_repository_before_writing(self) -> None: + from zeus.audit import AuditService, AuditServiceError + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + self._repository(root) + service = AuditService.from_cwd(cwd=root, env={}) + (root / "README").write_text("changed\n", encoding="utf-8") + run(("git", "-C", str(root), "add", "README"), check=True, stdin=DEVNULL) + run(("git", "-C", str(root), "commit", "-qm", "changed"), check=True, stdin=DEVNULL) + + with self.assertRaisesRegex(AuditServiceError, "initialization failed"): + service.initialize() + + self.assertFalse((root / ".zeus" / "audit" / "config.json").exists()) + + def test_initialize_writer_failure_leaves_no_partial_config(self) -> None: + from zeus.audit import AuditService, AuditServiceError + from zeus.private_io import UnsafeFileError + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + self._repository(root) + service = AuditService.from_cwd(cwd=root, env={}) + + with ( + mock.patch( + "zeus.audit_config.write_private_bytes_atomic", + side_effect=UnsafeFileError("injected write failure"), + ), + self.assertRaisesRegex(AuditServiceError, "initialization failed"), + ): + service.initialize() + + self.assertFalse((root / ".zeus" / "audit" / "config.json").exists()) def test_default_state_dir_is_repository_local_after_discovery(self) -> None: from zeus.audit import AuditService diff --git a/tests/test_renderer_state.py b/tests/test_renderer_state.py index b52b875..7ff30f5 100644 --- a/tests/test_renderer_state.py +++ b/tests/test_renderer_state.py @@ -494,6 +494,95 @@ def test_renderer_uses_native_deepseek_provider(self) -> None: (profile / ".env").read_text(encoding="utf-8"), ) + def test_renderer_uses_native_kimi_provider(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + hermes_root = root / ".zeus" / "hermes" + self.assertIn( + "kimi-k3-coding-bot", + {candidate.id for candidate in TemplateStore().list()}, + ) + template = TemplateStore().get("kimi-k3-coding-bot") + ProfileRenderer(hermes_root).render( + BotCreateRequest( + bot_id="kimi-coder", + template_id="kimi-k3-coding-bot", + env={"KIMI_API_KEY": "test-key"}, + ), + template, + ) + + profile = hermes_root / "profiles" / "kimi-coder" + config = (profile / "config.yaml").read_text(encoding="utf-8") + self.assertIn('provider: "kimi-coding"', config) + self.assertIn('default: "kimi-k3"', config) + self.assertNotIn("base_url:", config) + self.assertNotIn("api_mode:", config) + self.assertEqual( + "KIMI_API_KEY=test-key\n# KIMI_BASE_URL=\n", + (profile / ".env").read_text(encoding="utf-8"), + ) + + def test_renderer_writes_kimi_base_url_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + hermes_root = root / ".zeus" / "hermes" + self.assertIn( + "kimi-k3-coding-bot", + {candidate.id for candidate in TemplateStore().list()}, + ) + template = TemplateStore().get("kimi-k3-coding-bot") + ProfileRenderer(hermes_root).render( + BotCreateRequest( + bot_id="kimi-coder", + template_id="kimi-k3-coding-bot", + env={ + "KIMI_API_KEY": "test-key", + "KIMI_BASE_URL": "https://vendor.example/v1", + }, + ), + template, + ) + + profile = hermes_root / "profiles" / "kimi-coder" + config = (profile / "config.yaml").read_text(encoding="utf-8") + self.assertNotIn("base_url:", config) + self.assertNotIn("api_mode:", config) + _, env = HermesAdapter("hermes", hermes_root).command("kimi-coder", "gateway", "run") + self.assertEqual("test-key", env["KIMI_API_KEY"]) + self.assertEqual("https://vendor.example/v1", env["KIMI_BASE_URL"]) + + def test_renderer_rejects_undeclared_kimi_environment(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + hermes_root = root / ".zeus" / "hermes" + self.assertIn( + "kimi-k3-coding-bot", + {candidate.id for candidate in TemplateStore().list()}, + ) + template = TemplateStore().get("kimi-k3-coding-bot") + + with self.assertRaisesRegex( + TemplateError, + ( + "env contains unknown key\\(s\\) for template " + "kimi-k3-coding-bot: UNRELATED_API_KEY" + ), + ): + ProfileRenderer(hermes_root).render( + BotCreateRequest( + bot_id="kimi-coder", + template_id="kimi-k3-coding-bot", + env={ + "KIMI_API_KEY": "test-key", + "UNRELATED_API_KEY": "not-allowed", + }, + ), + template, + ) + + self.assertFalse((hermes_root / "profiles" / "kimi-coder").exists()) + def test_renderer_quotes_env_values_without_line_injection(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/test_repo_contracts.py b/tests/test_repo_contracts.py index b6b4df0..28d7832 100644 --- a/tests/test_repo_contracts.py +++ b/tests/test_repo_contracts.py @@ -221,12 +221,14 @@ def test_publishable_repository_files_exist(self) -> None: "zeus/bundled_templates/__init__.py", "zeus/bundled_templates/coding-bot.toml", "zeus/bundled_templates/deepseek-coding-bot.toml", + "zeus/bundled_templates/kimi-k3-coding-bot.toml", "zeus/bundled_templates/docs-writer-bot.toml", "zeus/bundled_templates/gateway-operator.toml", "zeus/bundled_templates/log-triage-bot.toml", "zeus/bundled_templates/research-bot.toml", "zeus/bundled_templates/support-gateway.toml", "templates/deepseek-coding-bot.toml", + "templates/kimi-k3-coding-bot.toml", "templates/docs-writer-bot.toml", "templates/gateway-operator.toml", "templates/log-triage-bot.toml", @@ -573,6 +575,7 @@ def test_wheel_smoke_exercises_installed_demo_entrypoint(self) -> None: for template_id in ( "coding-bot", "deepseek-coding-bot", + "kimi-k3-coding-bot", "docs-writer-bot", "gateway-operator", "log-triage-bot", @@ -647,6 +650,8 @@ def test_repo_check_script_verifies_required_handoff_artifacts(self) -> None: self.assertIn("scripts/fresh_vps_verify.sh", script) self.assertIn("zeus/bundled_templates/coding-bot.toml", script) self.assertIn("templates/deepseek-coding-bot.toml", script) + self.assertIn("templates/kimi-k3-coding-bot.toml", script) + self.assertIn("zeus/bundled_templates/kimi-k3-coding-bot.toml", script) self.assertIn("templates/docs-writer-bot.toml", script) self.assertIn("templates/gateway-operator.toml", script) self.assertIn("templates/log-triage-bot.toml", script) @@ -698,6 +703,7 @@ def test_repository_audit_contract_is_documented_and_packaged(self) -> None: self.assertRegex(stored_report_docs, rf"do not invoke[^.]*{runtime_check}") for command in ( + "zeus audit init [--json]", "zeus audit doctor [--json]", "zeus audit run [--json]", "zeus audit list [--json]", @@ -950,13 +956,15 @@ def test_onboarding_compatibility_and_roadmap_match_current_evidence(self) -> No self.assertIn("focused process", compatibility_text.lower()) self.assertIn("Windows is not currently automated", compatibility_text) - def test_env_example_lists_deepseek_and_api_auth(self) -> None: + def test_env_example_lists_provider_and_api_auth_settings(self) -> None: env = Path(".env.example").read_text(encoding="utf-8") api_docs = Path("docs/API.md").read_text(encoding="utf-8") self.assertIn("ZEUS_API_KEY=", env) self.assertIn("ZEUS_ALLOW_UNAUTH_READS=0", env) self.assertIn("DEEPSEEK_API_KEY=", env) + self.assertIn("KIMI_API_KEY=", env) + self.assertIn("KIMI_BASE_URL=", env) self.assertIn("ZEUS_ENV_PASSTHROUGH=", env) self.assertIn("All non-health endpoints require", api_docs) self.assertIn("POST /bots//restart", api_docs) diff --git a/tests/test_templates.py b/tests/test_templates.py index 144ad63..00b7226 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -10,6 +10,19 @@ class TemplateTests(unittest.TestCase): + def test_kimi_k3_template_selects_native_hermes_provider(self) -> None: + self.assertIn( + "kimi-k3-coding-bot", + {template.id for template in TemplateStore().list()}, + ) + template = TemplateStore().get("kimi-k3-coding-bot") + + self.assertEqual(["KIMI_API_KEY", "KIMI_BASE_URL"], template.hermes.required_env) + self.assertEqual("kimi-coding", template.hermes.model.provider) + self.assertEqual("kimi-k3", template.hermes.model.default) + self.assertIsNone(template.hermes.model.base_url) + self.assertIsNone(template.hermes.model.api_mode) + def test_builtin_templates_include_async_delegation_caps(self) -> None: templates = TemplateStore().list() self.assertGreaterEqual(len(templates), 7) diff --git a/zeus/audit.py b/zeus/audit.py index e1b5444..68850d3 100644 --- a/zeus/audit.py +++ b/zeus/audit.py @@ -15,7 +15,11 @@ from pathlib import Path from zeus import __version__ -from zeus.audit_config import AuditConfigError, load_audit_config +from zeus.audit_config import ( + AuditConfigError, + initialize_audit_config, + load_audit_config, +) from zeus.audit_container import AuditContainerError, AuditContainerRuntime, PreparedAuditContainer from zeus.audit_docker_broker import ( HERMES_VERSION, @@ -257,6 +261,23 @@ def doctor(self) -> AuditDoctorReport: ) return AuditDoctorReport((*report.checks, state_check)) + def initialize(self) -> AuditConfig: + try: + self.workspace.revalidate(self.location, deadline=self.deadline) + self._validate_state_path(deadline=self.deadline) + return initialize_audit_config(self.settings.state_dir) + except AuditServiceError: + raise + except ( + AuditWorkspaceError, + AuditConfigError, + OSError, + TypeError, + ValueError, + UnsafeFileError, + ) as exc: + raise AuditServiceError("audit initialization failed") from exc + def _report( self, *, diff --git a/zeus/audit_config.py b/zeus/audit_config.py index cf58434..64e6f3b 100644 --- a/zeus/audit_config.py +++ b/zeus/audit_config.py @@ -14,10 +14,17 @@ AuditLimits, SuggestedCommand, ) -from zeus.private_io import read_private_bytes +from zeus.private_io import ( + ensure_private_directory, + read_private_bytes, + write_private_bytes_atomic, +) AUDIT_CONFIG_SCHEMA_VERSION = 1 AUDIT_CONFIG_MAX_BYTES = 1024 * 1024 +DEFAULT_AUDIT_PROVIDER = "kimi-coding" +DEFAULT_AUDIT_MODEL = "kimi-k3" +DEFAULT_AUDIT_PROVIDER_ENV = ("KIMI_API_KEY",) DEFAULT_AUDIT_IMAGE = ( "nikolaik/python-nodejs:python3.11-nodejs20@sha256:" "8f958bdc1b4a422bfafd97cab4f69836401f616ae985d4b57a53d254f5bcb038" @@ -42,6 +49,12 @@ "ACCESS_TOKEN", } ) +_PROVIDER_ENV_ALIASES = { + "kimi-coding": frozenset({"KIMI_API_KEY", "KIMI_BASE_URL"}), +} +_PROVIDER_CREDENTIAL_ALIASES = { + "kimi-coding": frozenset({"KIMI_API_KEY"}), +} _SHA256_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") _REGISTRY_HOST_LABEL = r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" _REGISTRY_AUTHORITY_RE = re.compile( @@ -122,12 +135,14 @@ def _provider_env(value: object) -> tuple[str, ...]: def _provider_environment_allowlist(provider: str) -> frozenset[str]: prefix = provider.upper().replace("-", "_") - return frozenset(f"{prefix}_{suffix}" for suffix in _PROVIDER_ENV_SUFFIXES) + derived = frozenset(f"{prefix}_{suffix}" for suffix in _PROVIDER_ENV_SUFFIXES) + return derived | _PROVIDER_ENV_ALIASES.get(provider, frozenset()) def _provider_credential_allowlist(provider: str) -> frozenset[str]: prefix = provider.upper().replace("-", "_") - return frozenset(f"{prefix}_{suffix}" for suffix in _PROVIDER_CREDENTIAL_SUFFIXES) + derived = frozenset(f"{prefix}_{suffix}" for suffix in _PROVIDER_CREDENTIAL_SUFFIXES) + return derived | _PROVIDER_CREDENTIAL_ALIASES.get(provider, frozenset()) def validate_provider_selection(config: AuditConfig) -> None: @@ -335,3 +350,27 @@ def load_audit_config(state_dir: Path) -> AuditConfig: except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditConfigError("audit configuration is not valid UTF-8 JSON") from exc return parse_audit_config(value) + + +def default_audit_config_bytes() -> bytes: + value = { + "schema_version": AUDIT_CONFIG_SCHEMA_VERSION, + "provider": DEFAULT_AUDIT_PROVIDER, + "model": DEFAULT_AUDIT_MODEL, + "provider_env": list(DEFAULT_AUDIT_PROVIDER_ENV), + } + return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def initialize_audit_config(state_dir: Path) -> AuditConfig: + ensure_private_directory(state_dir) + audit_dir = state_dir / "audit" + ensure_private_directory(audit_dir) + data = default_audit_config_bytes() + write_private_bytes_atomic( + audit_dir / "config.json", + data, + AUDIT_CONFIG_MAX_BYTES, + replace=False, + ) + return load_audit_config(state_dir) diff --git a/zeus/bundled_templates/kimi-k3-coding-bot.toml b/zeus/bundled_templates/kimi-k3-coding-bot.toml new file mode 100644 index 0000000..1d8d311 --- /dev/null +++ b/zeus/bundled_templates/kimi-k3-coding-bot.toml @@ -0,0 +1,38 @@ +id = "kimi-k3-coding-bot" +name = "Kimi K3 Coding Bot" +description = "Repository maintenance and implementation bot backed by Kimi K3 through Hermes." +version = "0.1.0" +soul = """ +You are a coding-focused Hermes agent backed by Kimi K3. Ground claims in files, commands, test output, and commit evidence. Prefer small, maintainable changes and call out uncertainty directly. +""" + +[hermes] +required_env = ["KIMI_API_KEY", "KIMI_BASE_URL"] + +[hermes.model] +provider = "kimi-coding" +default = "kimi-k3" + +[hermes.terminal] +backend = "docker" +cwd = "." +home_mode = "profile" +timeout = 300 +docker_image = "nikolaik/python-nodejs:python3.11-nodejs20@sha256:8f958bdc1b4a422bfafd97cab4f69836401f616ae985d4b57a53d254f5bcb038" +docker_mount_cwd_to_workspace = false + +[hermes.gateway] +enabled = true + +[hermes.delegation] +max_iterations = 50 +max_concurrent_children = 3 +max_async_children = 3 +child_timeout_seconds = 0 +subagent_auto_approve = false + +[metadata] +use_case = "Repository maintenance using Kimi K3 through Hermes" +risk_level = "medium" +recommended_restart_policy = "manual" +tags = ["coding", "kimi", "kimi-k3"] diff --git a/zeus/cli.py b/zeus/cli.py index 7efcaf5..6949f2b 100644 --- a/zeus/cli.py +++ b/zeus/cli.py @@ -71,6 +71,7 @@ def build_parser() -> argparse.ArgumentParser: audit = sub.add_parser("audit", help=audit_description, description=audit_description) audit_sub = audit.add_subparsers(dest="action", required=True) for action, description in ( + ("init", "Initialize a private Kimi K3 audit configuration."), ("doctor", "Check audit prerequisites without running an audit."), ("run", "Run one isolated audit of committed HEAD."), ("list", "List stored audit reports."), @@ -821,6 +822,22 @@ def _run_audit(args: argparse.Namespace) -> int: try: service = AuditService.from_cwd() + if args.action == "init": + config = service.initialize() + payload = { + "model": config.model, + "next_command": "zeus audit doctor", + "provider": config.provider, + "provider_env": list(config.provider_env), + } + if args.as_json: + print(json.dumps(payload, sort_keys=True)) + else: + print(f"provider: {config.provider}") + print(f"model: {config.model}") + print(f"provider_env: {', '.join(config.provider_env)}") + print("next: zeus audit doctor") + return 0 if args.action == "doctor": doctor_report = service.doctor() print(