-
-
Notifications
You must be signed in to change notification settings - Fork 38
fold CodeRabbit findings on #2714 (tsk-fjmxzo): Agent state versioning: git-in-container with auto-commit + history/revert API #2717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| ### Added | ||
| - Agent state versioning: a git repo is initialised inside each agent container at deploy time, with a `.gitignore` that excludes secrets and bulk artefacts, and commit identity set to the agent's own slug. An auto-committer script runs as a background loop inside the container, committing dirty trees on a fixed interval with a timestamp + changed-file-summary message (#tsk-fjmxzo). | ||
| - Controller API for agent state history: `GET /api/agents/{name}/versions` lists commits, `GET /api/agents/{name}/versions/{sha}/diff` returns the patch for a commit, and `POST /api/agents/{name}/versions/{sha}/revert` reverts the state repo to a prior commit (#tsk-fjmxzo). | ||
|
|
||
| ### Fixed | ||
| - Fixed git_log to propagate Git-log failures to the route with RuntimeError, ensuring HTTP 409 when container is unreachable (tinyagentos/agent_git.py:84) | ||
| - Fixed git_revert to use a single git operation without leaving the index/dirty, preventing race condition with agent_committer (tinyagentos/agent_git.py:107) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| """Tests for the agent state auto-committer script.""" | ||
| from __future__ import annotations | ||
|
|
||
| import importlib.util | ||
| import os | ||
| import subprocess | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| _COMMITTER_PATH = ( | ||
| os.path.dirname(__file__).replace("tests", "tinyagentos") + "/scripts/agent_committer.py" | ||
| ) | ||
|
|
||
|
|
||
| def _load_committer(repo_path: str, interval: int = 1): | ||
| spec = importlib.util.spec_from_file_location("agent_committer", _COMMITTER_PATH) | ||
| mod = importlib.util.module_from_spec(spec) | ||
| spec.loader.exec_module(mod) | ||
| mod.REPO_PATH = repo_path | ||
| mod.INTERVAL = interval | ||
| return mod | ||
|
|
||
|
|
||
| def _init_repo(tmp_path): | ||
| subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True) | ||
| subprocess.run(["git", "config", "user.email", "test@test"], cwd=tmp_path, check=True, capture_output=True) | ||
| subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True, capture_output=True) | ||
|
|
||
|
|
||
| class TestAgentCommitter: | ||
| def test_commit_creates_commit_for_new_file(self, tmp_path): | ||
| _init_repo(tmp_path) | ||
| (tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n") | ||
| subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) | ||
| subprocess.run( | ||
| ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True | ||
| ) | ||
|
|
||
| committer = _load_committer(str(tmp_path)) | ||
| (tmp_path / "hello.txt").write_text("hello") | ||
| committer._commit() | ||
|
|
||
| rc, out, _ = committer._git("log", "--oneline") | ||
| assert rc == 0 | ||
| assert "auto:" in out | ||
| stat = committer._git("show", "--stat", "HEAD")[1] | ||
| assert "hello.txt" in stat | ||
|
|
||
| def test_gitignored_secret_not_committed(self, tmp_path): | ||
| _init_repo(tmp_path) | ||
| (tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n") | ||
| subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) | ||
| subprocess.run( | ||
| ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True | ||
| ) | ||
|
|
||
| committer = _load_committer(str(tmp_path)) | ||
| (tmp_path / ".env").write_text("SECRET=abc") | ||
| (tmp_path / "token.rsa").write_text("key") | ||
| committer._commit() | ||
|
|
||
| _, log_out, _ = committer._git("log", "--all", "--stat") | ||
| assert ".env" not in log_out | ||
| assert "token.rsa" not in log_out | ||
|
|
||
| def test_no_commit_when_clean(self, tmp_path): | ||
| _init_repo(tmp_path) | ||
| (tmp_path / ".gitignore").write_text("") | ||
| subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) | ||
| subprocess.run( | ||
| ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True | ||
| ) | ||
|
|
||
| committer = _load_committer(str(tmp_path)) | ||
| committer._commit() | ||
|
|
||
| rc, out, _ = committer._git("log", "--oneline") | ||
| assert rc == 0 | ||
| lines = out.strip().splitlines() | ||
| assert len(lines) == 1 | ||
| assert lines[0].endswith("initial") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| """Tests for the agent state versioning routes.""" | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import subprocess | ||
|
|
||
| import pytest | ||
| from httpx import ASGITransport, AsyncClient | ||
| from unittest.mock import AsyncMock, patch | ||
|
|
||
| import importlib.util | ||
|
|
||
|
|
||
| def _init_fixture_repo(path): | ||
| subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True, capture_output=True) | ||
| subprocess.run(["git", "config", "user.email", "agent@taos.local"], cwd=path, check=True, capture_output=True) | ||
| subprocess.run(["git", "config", "user.name", "test-agent"], cwd=path, check=True, capture_output=True) | ||
| (path / "README.md").write_text("initial") | ||
| subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True) | ||
| subprocess.run(["git", "commit", "-m", "initial"], cwd=path, check=True, capture_output=True) | ||
| (path / "notes.txt").write_text("second commit") | ||
| subprocess.run(["git", "add", "notes.txt"], cwd=path, check=True, capture_output=True) | ||
| subprocess.run(["git", "commit", "-m", "add notes"], cwd=path, check=True, capture_output=True) | ||
|
|
||
|
|
||
| def _fake_exec_for_repo(fixture_repo): | ||
| async def _fake(container, cmd, timeout=60): | ||
| if cmd[0] == "git" and cmd[1] == "-C" and cmd[2] == "/root": | ||
| git_args = cmd[3:] | ||
| result = subprocess.run( | ||
| ["git", "-C", str(fixture_repo), *git_args], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| return result.returncode, result.stdout | ||
| return 0, "" | ||
| return _fake | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestAgentVersionsRoutes: | ||
| async def test_list_versions_returns_commits(self, client): | ||
| with patch( | ||
| "tinyagentos.agent_git.exec_in_container", | ||
| new=AsyncMock(return_value=(0, "abc123|initial|agent|agent@taos.local|2026-01-01 00:00:00 +0000\ndef456|add notes|agent|agent@taos.local|2026-01-01 01:00:00 +0000\n")), | ||
| ): | ||
| resp = await client.get("/api/agents/test-agent/versions") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["agent"] == "test-agent" | ||
| assert len(data["versions"]) == 2 | ||
| assert data["versions"][0]["sha"] == "abc123" | ||
|
|
||
| async def test_list_versions_unknown_agent_returns_404(self, client): | ||
| resp = await client.get("/api/agents/ghost-agent/versions") | ||
| assert resp.status_code == 404 | ||
|
|
||
| async def test_diff_returns_patch(self, client): | ||
| with patch( | ||
| "tinyagentos.agent_git.exec_in_container", | ||
| new=AsyncMock(return_value=(0, "diff --git a/README.md b/README.md\n")), | ||
| ): | ||
| resp = await client.get("/api/agents/test-agent/versions/abc123/diff") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["sha"] == "abc123" | ||
| assert "diff --git" in data["diff"] | ||
|
|
||
| async def test_diff_unknown_sha_returns_404(self, client): | ||
| with patch( | ||
| "tinyagentos.agent_git.exec_in_container", | ||
| new=AsyncMock(side_effect=RuntimeError("unknown revision")), | ||
| ): | ||
| resp = await client.get("/api/agents/test-agent/versions/badsha/diff") | ||
| assert resp.status_code == 404 | ||
|
|
||
| async def test_revert_restores_content(self, tmp_path, client): | ||
| fixture = tmp_path / "repo" | ||
| fixture.mkdir() | ||
| _init_fixture_repo(fixture) | ||
|
|
||
| first_sha = subprocess.run( | ||
| ["git", "-C", str(fixture), "rev-parse", "HEAD~1"], | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ).stdout.strip() | ||
|
|
||
| with patch( | ||
| "tinyagentos.agent_git.exec_in_container", | ||
| new=_fake_exec_for_repo(fixture), | ||
| ): | ||
| resp = await client.post(f"/api/agents/test-agent/versions/{first_sha}/revert") | ||
| print("RESP:", resp.status_code, resp.text) | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["status"] == "reverted" | ||
| stat = subprocess.run( | ||
| ["git", "-C", str(fixture), "log", "--all", "--stat"], | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ).stdout | ||
| assert "Revert" in stat | ||
|
|
||
| async def test_revert_unknown_sha_returns_404(self, client): | ||
| with patch( | ||
| "tinyagentos.agent_git.exec_in_container", | ||
| new=AsyncMock(side_effect=RuntimeError("unknown revision")), | ||
| ): | ||
| resp = await client.post("/api/agents/test-agent/versions/badsha/revert") | ||
| assert resp.status_code == 404 | ||
|
|
||
| async def test_unauthenticated_returns_401(self, app): | ||
| async with AsyncClient( | ||
| transport=ASGITransport(app=app), | ||
| base_url="http://test", | ||
| ) as c: | ||
| resp = await c.get("/api/agents/test-agent/versions") | ||
| assert resp.status_code in (401, 403) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Git helpers for agent state versioning inside containers. | ||
|
|
||
| All container interactions go through ``exec_in_container`` and | ||
| ``push_file`` so the same helpers work for both LXC and Docker backends. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import os | ||
| import tempfile | ||
| from typing import List | ||
|
|
||
| from tinyagentos.containers import exec_in_container, push_file | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _REPO_PATH = "/root" | ||
|
|
||
| _GITIGNORE_CONTENTS = """\ | ||
| .env | ||
| *.cred | ||
| *token* | ||
| *.pem | ||
| *.p12 | ||
| *.key | ||
| *.secret | ||
| caches/ | ||
| venv/ | ||
| node_modules/ | ||
| .browser_profiles/ | ||
| __pycache__/ | ||
| *.pyc | ||
| """ | ||
|
|
||
|
|
||
| async def _git(container: str, args: List[str], timeout: int = 60) -> tuple[int, str]: | ||
| rc, out = await exec_in_container( | ||
| container, ["git", "-C", _REPO_PATH, *args], timeout=timeout | ||
| ) | ||
| return rc, out | ||
|
|
||
|
|
||
| async def git_init(container: str) -> None: | ||
| rc, out = await _git(container, ["init", "-b", "main"]) | ||
| if rc != 0: | ||
| raise RuntimeError(f"git init failed: {out}") | ||
|
|
||
|
|
||
| async def write_gitignore(container: str) -> None: | ||
| with tempfile.NamedTemporaryFile("w", suffix=".gitignore", delete=False) as tf: | ||
| tf.write(_GITIGNORE_CONTENTS) | ||
| tmp = tf.name | ||
| try: | ||
| rc, out = await push_file(container, tmp, "/root/.gitignore") | ||
| finally: | ||
| os.unlink(tmp) | ||
| if rc != 0: | ||
| raise RuntimeError(f"write .gitignore failed: {out}") | ||
|
|
||
|
|
||
| async def git_config_user(container: str, name: str, email: str) -> None: | ||
| await _git(container, ["config", "user.name", name]) | ||
| await _git(container, ["config", "user.email", email]) | ||
|
|
||
|
|
||
| async def git_add_commit(container: str, message: str) -> None: | ||
| rc, out = await _git(container, ["add", "-A"]) | ||
| if rc != 0: | ||
| raise RuntimeError(f"git add failed: {out}") | ||
| rc, out = await _git(container, ["commit", "-m", message, "--allow-empty"]) | ||
| if rc != 0: | ||
| raise RuntimeError(f"git commit failed: {out}") | ||
|
|
||
|
|
||
| async def git_is_dirty(container: str) -> bool: | ||
| rc, out = await _git(container, ["status", "--porcelain"]) | ||
| return rc == 0 and bool(out.strip()) | ||
|
|
||
|
|
||
| async def git_log(container: str) -> List[dict]: | ||
| fmt = "%H|%s|%an|%ae|%ai" | ||
| rc, out = await _git(container, ["log", f"--format={fmt}", "--reverse"]) | ||
| if rc != 0: | ||
| raise RuntimeError(f"git log failed: {out}") | ||
| commits: List[dict] = [] | ||
| for line in out.strip().splitlines(): | ||
| parts = line.split("|", 4) | ||
| if len(parts) == 5: | ||
| commits.append({ | ||
| "sha": parts[0], | ||
| "message": parts[1], | ||
| "author_name": parts[2], | ||
| "author_email": parts[3], | ||
| "date": parts[4], | ||
| }) | ||
| return commits | ||
|
|
||
|
|
||
| async def git_diff(container: str, sha: str) -> str: | ||
| rc, out = await _git(container, ["show", "--format=", "--patch", sha]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: |
||
| if rc != 0: | ||
| raise RuntimeError(f"git diff failed for {sha}: {out}") | ||
| return out | ||
|
|
||
|
|
||
| async def git_revert(container: str, sha: str) -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: |
||
| rc, out = await _git(container, ["revert", "--no-edit", sha]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Externally reachable argument injection via |
||
| if rc != 0: | ||
| raise RuntimeError(f"git revert failed for {sha}: {out}") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING:
git_logparses output withline.split("|", 4)over a user-controlled--format=%H|%s|%an|%ae|%ai. Author name or email containing|(legal in git) shifts columns silently; the resulting dict will have wrong fields and the loop will drop lines via thelen(parts) == 5guard. Use a delimiter that cannot appear in any field (e.g. NUL\0with-z, or%x1funit-separator) and split withsplit("\0", 4)/split("\x1f", 4).