Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/tsk-fjmxzo-agent-state-versioning.md
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)
83 changes: 83 additions & 0 deletions tests/test_agent_committer.py
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")
50 changes: 50 additions & 0 deletions tests/test_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,3 +1378,53 @@ async def test_dedicated_base_preferred_over_generic(self, tmp_path):
"hermes", {"taos-hermes-base", "taos-base"}, tmp_path
)
assert launch == "taos-hermes-base"


class TestGitInitAndCommitter:
@pytest.mark.asyncio
async def test_deploy_emits_git_init_step(self, tmp_path):
req = _req(data_dir=tmp_path)

async def mock_exec(name, cmd, **kwargs):
if "hostname -I" in " ".join(cmd):
return (0, "10.0.0.5")
return (0, "ok")

with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \
patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \
patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \
patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \
patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock) as mock_git_init, \
patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock) as mock_gitignore, \
patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock) as mock_git_config, \
patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock) as mock_git_commit:
mock_create.return_value = {"success": True, "name": "taos-agent-test"}
result = await deploy_agent(req)
assert result["success"] is True
assert "git_init" in result["steps"]
mock_git_init.assert_awaited_once_with("taos-agent-test")
mock_gitignore.assert_awaited_once_with("taos-agent-test")
mock_git_config.assert_awaited_once_with("taos-agent-test", "test", "test@taos.local")
mock_git_commit.assert_awaited_once()

@pytest.mark.asyncio
async def test_deploy_emits_committer_installed_step(self, tmp_path):
req = _req(data_dir=tmp_path)

async def mock_exec(name, cmd, **kwargs):
if "hostname -I" in " ".join(cmd):
return (0, "10.0.0.5")
return (0, "ok")

with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \
patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \
patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \
patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \
patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock):
mock_create.return_value = {"success": True, "name": "taos-agent-test"}
result = await deploy_agent(req)
assert result["success"] is True
assert "committer_installed" in result["steps"]
119 changes: 119 additions & 0 deletions tests/test_routes_agent_versions.py
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)
109 changes: 109 additions & 0 deletions tinyagentos/agent_git.py
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]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: git_log parses output with line.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 the len(parts) == 5 guard. Use a delimiter that cannot appear in any field (e.g. NUL \0 with -z, or %x1f unit-separator) and split with split("\0", 4) / split("\x1f", 4).

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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: git_diff raises RuntimeError for any non-zero rc, but the route maps it to HTTP 404 ("not found"). When the container is down or git is broken, callers get a misleading 404 instead of a 5xx/409. Distinguish "bad sha" (parse stderr for unknown revision → 404) from "git command failed for other reasons" (→ 409 container_unreachable, like the list endpoint).

if rc != 0:
raise RuntimeError(f"git diff failed for {sha}: {out}")
return out


async def git_revert(container: str, sha: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: git_revert --no-edit <sha> is a reverse-apply of one commit, not a snapshot restore. The PR title and changelog promise "revert the state repo to a prior commit", but with this implementation reverting the initial commit deletes the files it added instead of restoring the working tree to that earlier state. The lead reviewer already flagged this on the PR; the fix-forward card tsk-yn5gze is supposed to address it. Either change semantics to git reset --hard <sha> (with the obvious caveats) or rename the endpoint and document the inverse-commit semantics.

rc, out = await _git(container, ["revert", "--no-edit", sha])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Externally reachable argument injection via git argv. The sha path parameter from /api/agents/{name}/versions/{sha}/{diff,revert} is concatenated straight into the git argv (["revert", "--no-edit", sha]). A user-supplied value starting with - (e.g. --upload-pack=..., --exec=...) is interpreted as a git option. Validate sha against ^[0-9a-fA-F]{4,64}$ (and ideally --end-of-options before the user value) before passing it through. The same issue applies to git_diff on line 100 and git_log's f"--format={fmt}" is safer but the formatted string still embeds | separators vulnerable to author-name | content (see git_log).

if rc != 0:
raise RuntimeError(f"git revert failed for {sha}: {out}")
Loading
Loading