diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4c3caf1 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +branch = True +source = . + +[report] +omit = + bot_bottle/egress_addon.py + bot_bottle/cli/tui.py + tests/* diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 513b330..cd563f5 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -39,8 +39,14 @@ jobs: with: python-version: "3.12" + - name: Install dev requirements + run: python3 -m pip install -r requirements-dev.txt + - name: Run unit tests - run: python3 -m unittest discover -t . -s tests/unit -v + run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v + + - name: Report unit coverage + run: python3 -m coverage report -m integration: runs-on: ubuntu-latest diff --git a/.gitea/workflows/update-badges.yml b/.gitea/workflows/update-badges.yml index 091d844..7db0341 100644 --- a/.gitea/workflows/update-badges.yml +++ b/.gitea/workflows/update-badges.yml @@ -8,6 +8,7 @@ on: - '**.py' - '.pylintrc' - 'pyrightconfig.json' + - '.coveragerc' workflow_dispatch: jobs: @@ -45,10 +46,19 @@ jobs: echo "errors=$ERRORS" >> $GITHUB_OUTPUT echo "Pyright errors: $ERRORS" + - name: Run coverage and extract percentage + id: coverage + run: | + python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true + PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1) + echo "percent=$PERCENT" >> $GITHUB_OUTPUT + echo "Coverage: $PERCENT%" + - name: Update badges in README run: | PYLINT_SCORE="${{ steps.pylint.outputs.score }}" PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}" + COVERAGE_PERCENT="${{ steps.coverage.outputs.percent }}" PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g') @@ -58,9 +68,12 @@ jobs: if [ -n "$PYRIGHT_ERRORS" ]; then sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md fi + if [ -n "$COVERAGE_PERCENT" ]; then + sed -i "s|/badge/coverage-[^)]*|/badge/coverage-${COVERAGE_PERCENT}%25-brightgreen|" README.md + fi echo "Updated badges:" - grep -E "pylint|pyright" README.md | head -2 + grep -E "pylint|pyright|coverage" README.md | head -3 - name: Commit and push badge updates run: | @@ -73,7 +86,7 @@ jobs: else echo "Badge changes detected, committing..." git add README.md - MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n\n'"[skip ci]" + MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n'"- Coverage: ${{ steps.coverage.outputs.percent }}%"$'\n\n'"[skip ci]" git commit -m "$MSG" git push fi diff --git a/.gitignore b/.gitignore index 8f7813b..03d76f4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ venv/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +.coverage diff --git a/README.md b/README.md index 70539ac..199fc53 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pyright](https://img.shields.io/badge/pyright-1%20errors-brightgreen)](https://github.com/microsoft/pyright) +[![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey)](https://coverage.readthedocs.io/) **Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data. diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index e5a50c1..3eeaeb6 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -319,7 +319,7 @@ def _list_once() -> int: return 0 -def _try_init_green() -> int: +def _try_init_green() -> int: # pragma: no cover """Initialise a green color pair and return its attr, or 0.""" try: curses.start_color() @@ -330,7 +330,7 @@ def _try_init_green() -> int: return 0 -def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore +def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragma: no cover curses.curs_set(0) stdscr.timeout(_REFRESH_INTERVAL_MS) green_attr = _try_init_green() @@ -420,7 +420,7 @@ def _render( status_line: str, *, green_attr: int = 0, # noqa: F841 — unused, but required by interface -) -> None: +) -> None: # pragma: no cover stdscr.erase() h, w = stdscr.getmaxyx() header = f"bot-bottle supervise ({len(pending)} pending)" @@ -471,7 +471,7 @@ def _detail_view( qp: QueuedProposal, *, green_attr: int = 0, -) -> None: +) -> None: # pragma: no cover """Render the full proposal. Scrollable. Press q to return.""" lines = _detail_lines(qp, green_attr=green_attr) offset = 0 @@ -523,7 +523,7 @@ def _detail_view( return -def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore +def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover """Suspend curses, open $EDITOR on the proposed file, return edited content.""" suffix = _suffix_for_tool(qp.proposal.tool) curses.endwin() @@ -534,7 +534,7 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: return edited -def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore +def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore # pragma: no cover """One-line input at the bottom of the screen.""" curses.curs_set(1) h, _ = stdscr.getmaxyx() diff --git a/requirements-dev.txt b/requirements-dev.txt index 09d3b2a..a34cdd7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ pylint>=3.0.0 pyright>=1.1.300 +coverage>=7.0.0 diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index 332ab2e..dcb3329 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -92,9 +92,9 @@ class TestSandboxEscape(unittest.TestCase): "on PATH: curl -sSL https://smolmachines.com/install.sh | sh" ) - # Throwaway "identity file" for the git-gate's `identity` field. - # It need not be a real SSH key: test 5 reaches gitleaks before - # any SSH attempt anyway. + # Throwaway static key for the git-gate fixture. It need not + # be a real SSH key: test 5 reaches gitleaks before any SSH + # attempt anyway. fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.") os.close(fd) cls._key_path = Path(kp) @@ -123,7 +123,10 @@ class TestSandboxEscape(unittest.TestCase): "git-gate": {"repos": { "throwaway": { "url": "ssh://git@unreachable.invalid:22/throwaway.git", - "identity": str(cls._key_path), + "key": { + "provider": "static", + "path": str(cls._key_path), + }, }, }}, }, diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 2f5606f..741d560 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -198,6 +198,7 @@ class TestSmolmachinesLaunch(unittest.TestCase): # connect fails, which is the property chunk 3 will # preserve once egress is actually running. r = self.bottle.exec( + "env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy " f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 " "2>&1 || true" ) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index fa6bf3b..3eaf3a0 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -4,6 +4,7 @@ import os import tempfile import unittest from pathlib import Path +from unittest.mock import patch from bot_bottle.git_gate import ( GitGate, @@ -13,6 +14,8 @@ from bot_bottle.git_gate import ( git_gate_render_access_hook, git_gate_render_entrypoint, git_gate_render_hook, + revoke_git_gate_provisioned_keys, + _resolve_identity_file, git_gate_upstreams_for_bottle, ) from bot_bottle.manifest import ManifestIndex @@ -328,6 +331,68 @@ class TestPrepare(unittest.TestCase): self.assertIn("exec git daemon", content) +class TestDynamicKeyProvisioning(unittest.TestCase): + def setUp(self): + self.stage = Path(tempfile.mkdtemp()) + + def tearDown(self): + import shutil + + shutil.rmtree(self.stage, ignore_errors=True) + + def _gitea_manifest(self): + return ManifestIndex.from_json_obj({ + "bottles": { + "dev": { + "git-gate": { + "repos": { + "repo": { + "url": "ssh://git@gitea.example.com/org/repo.git", + "key": { + "provider": "gitea", + "forge_token_env": "GITEA_TOKEN", + }, + "host_key": "ssh-ed25519 AAAA...", + }, + }, + } + } + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + + def test_resolve_identity_file_static_uses_entry_path(self): + entry = fixture_with_git().bottles["dev"].git[0] + self.assertEqual(entry.IdentityFile, _resolve_identity_file(entry, "demo", self.stage)) + + def test_resolve_identity_file_gitea_provisions_key(self): + entry = self._gitea_manifest().bottles["dev"].git[0] + with patch("bot_bottle.git_gate._provision_dynamic_key", return_value="/tmp/provisioned-key") as mock_provision: + self.assertEqual("/tmp/provisioned-key", _resolve_identity_file(entry, "demo", self.stage)) + mock_provision.assert_called_once() + + def test_revoke_skips_non_gitea_and_missing_id_file(self): + revoke_git_gate_provisioned_keys(fixture_with_git().bottles["dev"], self.stage) + + def test_revoke_calls_delete_for_gitea_entry(self): + bottle = self._gitea_manifest().bottles["dev"] + (self.stage / "repo-deploy-key-id").write_text("123\n") + with patch.dict("os.environ", {"GITEA_TOKEN": "token"}), patch( + "bot_bottle.deploy_key_provisioner.get_provisioner" + ) as mock_get_provisioner: + provisioner = mock_get_provisioner.return_value + revoke_git_gate_provisioned_keys(bottle, self.stage) + mock_get_provisioner.assert_called_once() + provisioner.delete.assert_called_once_with("org/repo", "123") + + def test_revoke_missing_token_raises(self): + bottle = self._gitea_manifest().bottles["dev"] + (self.stage / "repo-deploy-key-id").write_text("123\n") + with patch.dict("os.environ", {}, clear=True), self.assertRaises(RuntimeError) as cm: + revoke_git_gate_provisioned_keys(bottle, self.stage) + self.assertIn("env var is not set", str(cm.exception)) + + class TestShellEscaping(unittest.TestCase): """Regression tests: all three render functions must produce syntactically valid sh code even when names and upstream URLs contain shell-special diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index 0b4efa8..0eb11da 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -364,6 +364,23 @@ class TestHandleToolsCall(unittest.TestCase): self.config, ) + def test_missing_name_raises(self): + with self.assertRaises(_RpcError) as cm: + handle_tools_call({"arguments": {}}, self.config) + self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) + + def test_arguments_must_be_object(self): + with self.assertRaises(_RpcError) as cm: + handle_tools_call( + { + "name": _sv.TOOL_EGRESS_ALLOW, + "arguments": [], + }, + self.config, + ) + self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) + self.assertIn("must be an object", cm.exception.message) + def test_capability_block_call_raises_unknown_tool(self): with self.assertRaises(_RpcError) as cm: handle_tools_call( @@ -426,6 +443,31 @@ class TestHandleToolsCall(unittest.TestCase): class TestHandleListEgressRoutes(unittest.TestCase): + def test_success_returns_body_text(self): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool: + return False + + def read(self): + return b"[{\"host\": \"example.com\"}]" + + class _Opener: + def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore + return _Resp() + + with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()): + result = handle_list_egress_routes( + {}, + ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")), + ) + + self.assertFalse(result["isError"]) # type: ignore[index] + text = result["content"][0]["text"] # type: ignore[index] + self.assertIn("example.com", text) + def test_url_error_returns_tool_error(self): class _Opener: def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore @@ -485,6 +527,13 @@ class TestFormatResponseText(unittest.TestCase): self.assertIn("the operator modified", text.lower()) +class TestFormatPendingResponseText(unittest.TestCase): + def test_formats_timeout_message(self): + text = supervise_server.format_pending_response_text(12.5) + self.assertIn("status: pending", text) + self.assertIn("12.5s", text) + + # --- End-to-end HTTP sanity ------------------------------------------------