import json import shutil import subprocess from pathlib import Path import pytest ROOT = Path(__file__).parents[1] def _indent(line: str) -> int: return len(line) - len(line.lstrip()) def _service(name: str) -> list[str]: lines = (ROOT / "compose.yaml").read_text().splitlines() marker = f" {name}:" start = lines.index(marker) + 1 end = next( ( index for index in range(start, len(lines)) if lines[index].strip() and _indent(lines[index]) <= 2 ), len(lines), ) return lines[start:end] def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]: marker = f"{' ' * indent}{name}:" start = lines.index(marker) + 1 end = next( ( index for index in range(start, len(lines)) if lines[index].strip() and _indent(lines[index]) <= indent ), len(lines), ) return lines[start:end] def _value(value: str) -> str: value = value.strip() if len(value) >= 2 and value[0] == value[-1] == '"': return str(json.loads(value)) if len(value) >= 2 and value[0] == value[-1] == "'": return value[1:-1] return value def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]: entries = _section(lines, name, indent=indent) result: dict[str, str] = {} for line in entries: if _indent(line) != indent + 2 or line.lstrip().startswith("-"): continue key, separator, value = line.strip().partition(":") if separator: result[key] = _value(value) return result def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]: entries = _section(lines, name, indent=indent) prefix = f"{' ' * (indent + 2)}- " return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)] def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str: prefix = f"{' ' * indent}{name}:" line = next(line for line in lines if line.startswith(prefix)) return _value(line.removeprefix(prefix)) def test_config_preserves_builtin_permissions_and_restricts_research() -> None: config = json.loads((ROOT / "opencode" / "opencode.json").read_text()) assert "permission" not in config assert all(name not in config["agent"] for name in ("build", "plan", "general")) assert "permission" not in config["agent"]["explore"] assert config["agent"]["research"]["permission"] == { "*": "deny", "websearch": "allow", "context7_*": "allow", "gh_grep_*": "allow", } assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"] assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp" assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}" assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}" assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}" def test_compose_services_have_expected_runtime_contract() -> None: agentci = _service("agentci") opencode = _service("opencode") agentci_environment = _mapping(agentci, "environment") opencode_environment = _mapping(opencode, "environment") assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096" assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}") assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}") assert opencode_environment["HOME"] == "/etc/opencode/home" assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1" assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1" assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment assert _sequence(agentci, "volumes") == [ "agentci_data:/var/lib/agentci", "./install-scripts:/etc/agentci/install-scripts:ro", ] assert _sequence(opencode, "volumes") == [ "agentci_data:/var/lib/agentci", "opencode_home:/var/lib/opencode", ] assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"] assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"] assert json.loads(_scalar(opencode, "command")) == [ "opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096", ] image = "${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}" assert _scalar(agentci, "image") == image assert _scalar(opencode, "image") == image assert all(line.strip() != "build:" for line in [*agentci, *opencode]) dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6) assert _mapping([" dependency:", *dependency], "dependency", indent=6) == { "condition": "service_healthy" } healthcheck = _mapping(opencode, "healthcheck") health_command = json.loads(healthcheck["test"]) assert health_command[0] == "CMD-SHELL" assert "/global/health" in health_command[1] assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1] def test_compose_has_no_sandbox_security_exceptions() -> None: compose = (ROOT / "compose.yaml").read_text() for forbidden in ( "cap_add:", "security_opt:", "privileged:", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap", ): assert forbidden not in compose def test_gitea_workflow_publishes_master_images() -> None: lines = (ROOT / ".gitea" / "workflows" / "publish-image.yaml").read_text().splitlines() push = _section(_section(lines, "on", indent=0), "push", indent=2) assert _sequence(push, "branches", indent=4) == ["master"] assert _mapping(lines, "env", indent=0)["IMAGE_NAME"] == ( "git.krtss.de/stanponomarev/agentci" ) assert " ${{ env.IMAGE_NAME }}:latest" in lines assert " ${{ env.IMAGE_NAME }}:${{ gitea.sha }}" in lines assert " username: ${{ secrets.REGISTRY_USERNAME }}" in lines assert " password: ${{ secrets.REGISTRY_TOKEN }}" in lines def test_container_pins_opencode_major_version_contract() -> None: lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()] build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")} assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments assert any( line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines ) def test_compose_document_validates_when_compose_cli_is_available() -> None: docker = shutil.which("docker") if docker is None: pytest.skip("Docker Compose CLI is not installed") probe = subprocess.run( [docker, "compose", "version"], cwd=ROOT, capture_output=True, text=True, check=False, ) if probe.returncode: pytest.skip("Docker Compose provider is not available") result = subprocess.run( [docker, "compose", "config", "--quiet"], cwd=ROOT, capture_output=True, text=True, check=False, ) assert result.returncode == 0, result.stderr