Files
2026-07-13 13:12:00 +08:00

203 lines
6.6 KiB
Python

"""Unit tests for :mod:`omnigent.tools.builtins.export_agent`."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.export_agent import ExportAgentTool
@pytest.fixture()
def workspace(tmp_path: Path) -> Path:
"""Create a workspace with a sample agent directory."""
agent_dir = tmp_path / "workspace" / "my-agent"
agent_dir.mkdir(parents=True)
(agent_dir / "config.yaml").write_text("name: my-agent\n")
(agent_dir / "prompt.md").write_text("# Prompt\n")
return tmp_path / "workspace"
@pytest.fixture()
def tool_ctx(workspace: Path) -> ToolContext:
return ToolContext(
task_id="task_test",
agent_id="agent_test",
workspace=workspace,
)
# ── Schema ───────────────────────────────────────────────
def test_schema_shape() -> None:
"""Schema has source and target as required string params."""
tool = ExportAgentTool()
schema = tool.get_schema()
assert schema["type"] == "function"
func = schema["function"]
assert func["name"] == "export_agent"
assert set(func["parameters"]["required"]) == {"source", "target"}
props = func["parameters"]["properties"]
assert props["source"]["type"] == "string"
assert props["target"]["type"] == "string"
def test_name_and_description() -> None:
assert ExportAgentTool.name() == "export_agent"
assert len(ExportAgentTool.description()) > 0
# ── Invoke: success ──────────────────────────────────────
def test_invoke_copies_directory(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""invoke() copies the agent directory to the target path."""
target = tmp_path / "export-target" / "my-agent"
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "my-agent", "target": str(target)}),
tool_ctx,
)
assert "Exported agent to" in result
assert (target / "config.yaml").exists()
assert (target / "prompt.md").exists()
def test_invoke_refuses_existing_target(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""An existing target is refused and never deleted (no rmtree).
Regression test for arbitrary directory deletion: the old code
called ``shutil.rmtree`` on the LLM-controlled absolute target,
which could wipe any directory on the user's filesystem.
"""
target = tmp_path / "precious"
target.mkdir()
(target / "important.txt").write_text("do not delete")
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "my-agent", "target": str(target)}),
tool_ctx,
)
assert "Error" in result and "already exists" in result.lower()
# The pre-existing directory and its contents are untouched.
assert (target / "important.txt").read_text() == "do not delete"
assert not (target / "config.yaml").exists()
# ── Invoke: error cases ──────────────────────────────────
def test_invoke_missing_source(tool_ctx: ToolContext) -> None:
"""Error when source is empty."""
tool = ExportAgentTool()
result = tool.invoke(json.dumps({"target": "/tmp/out"}), tool_ctx)
assert "Error" in result and "source" in result.lower()
def test_invoke_missing_target(tool_ctx: ToolContext) -> None:
"""Error when target is empty."""
tool = ExportAgentTool()
result = tool.invoke(json.dumps({"source": "my-agent"}), tool_ctx)
assert "Error" in result and "target" in result.lower()
def test_invoke_source_not_found(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""Error when source directory doesn't exist in workspace."""
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "nonexistent", "target": str(tmp_path / "out")}),
tool_ctx,
)
assert "Error" in result and "not found" in result.lower()
def test_invoke_source_is_file(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""Error when source is a file, not a directory."""
assert tool_ctx.workspace is not None
(tool_ctx.workspace / "just-a-file.txt").write_text("not a dir")
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "just-a-file.txt", "target": str(tmp_path / "out")}),
tool_ctx,
)
assert "Error" in result and "not a directory" in result.lower()
def test_invoke_no_workspace() -> None:
"""Error when workspace is None."""
ctx = ToolContext(task_id="t", agent_id="a", workspace=None)
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "my-agent", "target": "/tmp/out"}),
ctx,
)
assert "Error" in result and "workspace" in result.lower()
# ── Invoke: containment / exfiltration ───────────────────
def test_invoke_rejects_source_escaping_workspace(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""A traversal source path is rejected and nothing is copied."""
target = tmp_path / "escape-out"
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "../../etc", "target": str(target)}),
tool_ctx,
)
assert "Error" in result and "escapes the workspace" in result.lower()
assert not target.exists()
def test_invoke_does_not_dereference_source_symlink(
tool_ctx: ToolContext,
tmp_path: Path,
) -> None:
"""A symlink inside source is copied as a link, not dereferenced.
Regression test for host-file exfiltration: copytree's default
symlink dereference would copy the *contents* of the symlink's
target (a host secret) into the exported directory.
"""
assert tool_ctx.workspace is not None
secret = tmp_path / "host-secret.txt"
secret.write_text("TOP SECRET")
(tool_ctx.workspace / "my-agent" / "leak").symlink_to(secret)
target = tmp_path / "export-symlink" / "my-agent"
tool = ExportAgentTool()
result = tool.invoke(
json.dumps({"source": "my-agent", "target": str(target)}),
tool_ctx,
)
assert "Exported agent to" in result
exported_link = target / "leak"
# The entry is preserved as a symlink rather than dereferenced
# into a regular file holding the secret bytes.
assert exported_link.is_symlink()
# Removing the host file leaves the export dangling, proving the
# secret's contents were never copied out of the workspace.
secret.unlink()
assert not exported_link.exists()