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)
6 changes: 6 additions & 0 deletions changelog.d/tsk-yn5gze-agent-versions-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed

- Agent state version revert now restores the full snapshot at the target commit instead of inverting a single commit. `git_revert` runs `git revert --no-edit <sha>..HEAD` so the tree matches the requested commit's state, and the revert endpoint asserts `README.md` remains with `notes.txt` absent after the operation.
- `.taos/trace/` is now excluded from the agent state gitignore before the initial commit, preventing trace directory contents from being staged into git history.
- Remote agent container targets are persisted in the agent record and used for all version operations, so remote-deployed agents resolve to `<remote>:taos-agent-{name}` instead of the unqualified local name.
- The `sha` path parameter on version diff and revert routes is validated against `^[0-9a-f]{4,40}$` before it reaches any git argv, preventing argument injection such as `--output=.bashrc`.
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"]
164 changes: 164 additions & 0 deletions tests/test_routes_agent_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Tests for the agent state versioning routes."""
from __future__ import annotations

import os
import subprocess

import pytest
from httpx import ASGITransport, AsyncClient
from taos_test_csrf import csrf_event_hooks
from unittest.mock import AsyncMock, patch

import importlib.util
import yaml


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


def _make_app_with_remote(tmp_path, remote):
config = {
"server": {"host": "0.0.0.0", "port": 6969},
"backends": [],
"qmd": {"url": "http://localhost:7832"},
"agents": [
{"name": "test-agent", "host": "192.168.1.100", "remote": remote, "qmd_index": "test", "color": "#98fb98"}
],
"metrics": {"poll_interval": 30, "retention_days": 30},
}
config_path = tmp_path / "config.yaml"
config_path.write_text(yaml.dump(config))
(tmp_path / ".setup_complete").touch()
from tinyagentos.app import create_app
app = create_app(data_dir=tmp_path)
app.state.auth.setup_user("admin", "Test Admin", "", "testpass")
record = app.state.auth.find_user("admin")
token = app.state.auth.create_session(user_id=record["id"], long_lived=True)
app.state._startup_complete = True
return app, token


@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/abcd1234/diff")
assert resp.status_code == 404

async def test_diff_injection_sha_returns_400(self, client):
resp = await client.get("/api/agents/test-agent/versions/--output=.bashrc/diff")
assert resp.status_code == 400

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"
assert (fixture / "README.md").exists()
assert not (fixture / "notes.txt").exists()

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/abcd1234/revert")
assert resp.status_code == 404

async def test_revert_injection_sha_returns_400(self, client):
resp = await client.post("/api/agents/test-agent/versions/--output=.bashrc/revert")
assert resp.status_code == 400

async def test_remote_agent_uses_qualified_container_name(self, tmp_path):
app, token = _make_app_with_remote(tmp_path, "test-remote")
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
cookies={"taos_session": token},
event_hooks=csrf_event_hooks(),
) as c:
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\n")),
) as m:
resp = await c.get("/api/agents/test-agent/versions")
m.assert_called_once()
args, _ = m.call_args
assert args[0] == "test-remote:taos-agent-test-agent"
assert resp.status_code == 200

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