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

746 lines
27 KiB
Python

"""Unit tests for the runner-side ``upload_file`` dispatch containment.
These cover the upload-containment fix: the runner's ``_execute_file_tool``
upload branch must resolve the agent-supplied ``path`` against the
session workspace and reject anything that escapes it, instead of
calling ``open(path, "rb")`` on the raw argument (which let an agent
read arbitrary host files from the un-sandboxed runner process).
The dispatch posts uploaded bytes to the session file store over
HTTP, so we drive it with an ``httpx.MockTransport`` that records
every request. A rejected path must produce an error string AND make
no POST at all (nothing is read or exfiltrated); an in-workspace path
must POST the file's real bytes and return the store's ``file_id``.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from omnigent.runner.tool_dispatch import _execute_file_tool, execute_tool
_CONVERSATION_ID = "conv_sec20870"
def _recording_client(captured: list[httpx.Request]) -> httpx.AsyncClient:
"""
Build an AP-server client that records requests and returns a
created-file response.
:param captured: List the handler appends each inbound request to,
so the test can assert whether (and what) the dispatch POSTed.
:returns: An ``httpx.AsyncClient`` backed by a mock transport that
answers the files endpoint with ``201`` and a fixed file id.
"""
def _handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
return httpx.Response(201, json={"id": "file_abc123"})
return httpx.AsyncClient(
transport=httpx.MockTransport(_handler),
base_url="http://ap-server",
)
@pytest.mark.parametrize(
"evil_path",
[
"/etc/passwd", # absolute escape
"../../../etc/passwd", # parent traversal
"../secrets.txt", # single-level traversal just outside root
],
)
@pytest.mark.asyncio
async def test_upload_rejects_paths_outside_workspace(
evil_path: str,
tmp_path: Path,
) -> None:
"""
An ``upload_file`` path that escapes the workspace is rejected and
no upload POST is made.
:param evil_path: An agent-supplied path that resolves outside the
workspace root, e.g. ``"/etc/passwd"``.
:param tmp_path: Pytest-provided workspace root for the session.
"""
# A real, readable host file outside the workspace would be the
# exfiltration target. Create one next to the workspace so the
# traversal cases point at a file that actually exists — the
# rejection must happen on path containment, NOT on the file
# being absent.
outside = tmp_path.parent / "secrets.txt"
outside.write_text("TOP SECRET")
captured: list[httpx.Request] = []
client = _recording_client(captured)
try:
result = await _execute_file_tool(
"upload_file",
{"path": evil_path},
client,
conversation_id=_CONVERSATION_ID,
agent_spec=None,
runner_workspace=tmp_path,
)
finally:
await client.aclose()
# Rejection surfaces as an error string from safe_resolve's
# ValueError. If this were a success JSON ({"file_id": ...}) the
# containment check failed open and the file was exfiltrated.
assert result.startswith("Error: sys_upload_file failed:"), (
f"Expected a containment rejection for {evil_path!r}, got: {result!r}"
)
assert "escapes" in result, (
f"Expected the workspace-escape reason in the error for {evil_path!r}, got: {result!r}"
)
# The decisive security assertion: nothing was POSTed, so the host
# file was never read into the session store. A non-empty list here
# means the runner read and uploaded an out-of-workspace file.
assert captured == [], (
f"upload_file POSTed despite an out-of-workspace path {evil_path!r}; "
f"the host file would have been exfiltrated"
)
@pytest.mark.asyncio
async def test_upload_rejects_symlink_escaping_workspace(tmp_path: Path) -> None:
"""
A workspace-local symlink that points at a host file outside the
workspace is rejected, and no upload POST is made.
This is the symlink variant of the containment check: an agent
could plant a relative symlink inside its workspace whose target
is a host secret. The dispatch must follow the link, see the
target escapes the root, and refuse — never reading or uploading
the linked file.
:param tmp_path: Pytest-provided workspace root for the session.
"""
secret = tmp_path.parent / "host_secret.txt"
secret.write_text("root:x:0:0:exfiltrated")
link = tmp_path / "looks_innocent.txt"
link.symlink_to(secret)
captured: list[httpx.Request] = []
client = _recording_client(captured)
try:
result = await _execute_file_tool(
"upload_file",
{"path": "looks_innocent.txt"},
client,
conversation_id=_CONVERSATION_ID,
agent_spec=None,
runner_workspace=tmp_path,
)
finally:
await client.aclose()
# The resolved symlink target escapes the workspace, so the upload
# is rejected before any read. A success envelope here would mean
# the host secret was followed and exfiltrated via the symlink.
assert result.startswith("Error: sys_upload_file failed:"), (
f"Expected a containment rejection for the escaping symlink, got: {result!r}"
)
assert "escapes" in result, f"Expected a workspace-escape reason, got: {result!r}"
# Decisive: nothing POSTed, so the symlinked host file was never read.
assert captured == [], (
"upload_file POSTed despite a symlink whose target escapes the "
"workspace; the host secret would have been exfiltrated"
)
@pytest.mark.asyncio
async def test_upload_in_workspace_succeeds(tmp_path: Path) -> None:
"""
An ``upload_file`` path inside the workspace uploads the file's raw
bytes and returns the store-issued ``file_id``.
:param tmp_path: Pytest-provided workspace root for the session.
"""
# Binary payload with a NUL byte proves the dispatch uploads raw
# bytes (not a text/line-converted view): if the read path ever
# routed through a text reader the NUL-containing content would be
# corrupted or rejected.
payload = b"chart-bytes\x00\x01\x02 end"
target = tmp_path / "output" / "chart.png"
target.parent.mkdir(parents=True)
target.write_bytes(payload)
captured: list[httpx.Request] = []
client = _recording_client(captured)
try:
result = await _execute_file_tool(
"upload_file",
{"path": "output/chart.png"},
client,
conversation_id=_CONVERSATION_ID,
agent_spec=None,
runner_workspace=tmp_path,
)
finally:
await client.aclose()
# Success returns the file store's id and the resolved basename —
# proving the relative in-workspace path was accepted and uploaded.
assert result == '{"file_id": "file_abc123", "filename": "chart.png"}', (
f"Expected a success envelope with the store file id, got: {result!r}"
)
# Exactly one POST, to the session's files endpoint.
assert len(captured) == 1, f"Expected exactly one upload POST, got {len(captured)} request(s)"
request = captured[0]
assert request.method == "POST"
assert request.url.path == f"/v1/sessions/{_CONVERSATION_ID}/resources/files"
# The multipart body must carry the real file bytes (incl. the NUL
# byte) — confirming the actual workspace file content traversed the
# pipeline, not an empty or placeholder upload.
assert payload in request.content, (
"Uploaded multipart body did not contain the workspace file's raw bytes"
)
# ── sys_session_send file_ids forwarding (parent → child at spawn) ──
#
# When ``sys_session_send`` carries ``file_ids``, the runner copies those
# parent files into the freshly-created child via the lineage-scoped copy
# endpoint, then attaches one file block per copied id to the child's
# first-turn content. These drive the dispatch end-to-end with a recording
# server transport, mirroring the upload-containment tests above and the
# ``sys_session_send`` tests in ``test_runner_dispatch.py``.
_PARENT_ID = "conv_parent_files"
_CHILD_ID = "conv_child_files"
def _spec_with_subagent() -> SimpleNamespace:
"""A parent-spec stub declaring one ``worker`` sub-agent (no harness CLI)."""
return SimpleNamespace(sub_agents=[SimpleNamespace(name="worker")])
def _spawn_server_handler(
*,
events: list[dict[str, Any]],
copies: list[dict[str, Any]],
mapping: dict[str, dict[str, Any]],
existing_child: dict[str, Any] | None = None,
copy_status: int = 200,
copy_error: dict[str, Any] | None = None,
deletes: list[str] | None = None,
delete_status: int = 200,
delete_exc: httpx.HTTPError | None = None,
events_status: int = 202,
meta_gets: list[str] | None = None,
):
"""
Build a mock Omnigent-server handler for a fresh named spawn.
Serves the no-existing-child lookup, the child create, the copy
endpoint (recording its body and answering with ``mapping`` or an
error), and the child events POST (recording its body). The enriched
copy response now carries filename + content_type per file, so the
dispatch path needs no per-file metadata GET — any such GET is recorded
in ``meta_gets`` so a test can assert it never happens.
:param events: List the handler appends each child events body to.
:param copies: List the handler appends each copy request body to.
:param mapping: ``{old_id: {new_id, filename, content_type}}`` returned
by the copy endpoint.
:param existing_child: Optional child-session summary returned by the
named-child lookup.
:param copy_status: HTTP status the copy endpoint returns.
:param copy_error: Error body for a non-2xx copy response.
:param delete_status: HTTP status the child-session delete returns.
:param delete_exc: Optional HTTP error raised by the delete request.
:param meta_gets: If provided, the handler records the path of any
per-file metadata GET here (expected to stay empty).
"""
async def _handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if request.method == "GET" and path == f"/v1/sessions/{_PARENT_ID}/child_sessions":
return httpx.Response(
200,
json={"data": [existing_child] if existing_child is not None else []},
)
if request.method == "POST" and path == "/v1/sessions":
return httpx.Response(201, json={"id": _CHILD_ID})
if request.method == "POST" and path == f"/v1/sessions/{_CHILD_ID}/resources/files:copy":
copies.append(json.loads(request.content))
if copy_status >= 400:
return httpx.Response(copy_status, json=copy_error or {"error": {}})
return httpx.Response(
copy_status,
json={
"object": "session.files.copied",
"session_id": _CHILD_ID,
"mapping": mapping,
},
)
if request.method == "GET" and path.startswith(
f"/v1/sessions/{_CHILD_ID}/resources/files/"
):
# The enriched copy response makes this GET unnecessary; record it
# so a test can prove the dispatch path no longer issues it.
if meta_gets is not None:
meta_gets.append(path)
return httpx.Response(404, json={"error": {"message": "should not be called"}})
if request.method == "POST" and path == f"/v1/sessions/{_CHILD_ID}/events":
events.append(json.loads(request.content))
if events_status >= 400:
return httpx.Response(events_status, json={"error": {"message": "boom"}})
return httpx.Response(202, json={"queued": True})
if request.method == "DELETE" and path == f"/v1/sessions/{_CHILD_ID}":
if deletes is not None:
deletes.append(_CHILD_ID)
if delete_exc is not None:
raise delete_exc
return httpx.Response(
delete_status,
json={"id": _CHILD_ID, "deleted": delete_status < 400},
)
return httpx.Response(404, json={"error": str(request.url)})
return _handler
def _copied(new_id: str, filename: str, content_type: str | None) -> dict[str, Any]:
"""An enriched copy-response mapping entry for one file."""
return {"new_id": new_id, "filename": filename, "content_type": content_type}
async def _run_spawn(
monkeypatch: pytest.MonkeyPatch,
*,
args_payload: Any,
handler,
) -> str:
"""Dispatch one ``sys_session_send`` against ``handler`` and clean up."""
from omnigent.runner import app as runner_app
monkeypatch.setattr(runner_app, "get_session_agent_id", lambda _sid: "ag_parent")
monkeypatch.setattr(runner_app, "register_child_session", lambda *a, **k: None)
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="http://server",
) as server_client:
try:
return await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps({"agent": "worker", "title": "task-1", "args": args_payload}),
server_client=server_client,
conversation_id=_PARENT_ID,
agent_spec=_spec_with_subagent(),
session_inbox=session_inbox,
)
finally:
runner_app.unregister_child_session(_CHILD_ID)
runner_app.unregister_subagent_work(_CHILD_ID)
runner_app._session_inboxes_ref.pop(_PARENT_ID, None)
@pytest.mark.asyncio
async def test_send_with_file_ids_copies_then_attaches_input_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
One ``file_ids`` entry copies parent→child, then the child events
content carries input_text + one input_file block on the MAPPED id.
Asserts the copy endpoint receives ``(parent_session, file_ids)`` and
that the posted block references the new child-scoped id, not the
original parent id — proving the runner threads the mapping through.
The content type comes from the copy response, so no per-file metadata
GET is issued.
"""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
meta_gets: list[str] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_parent": _copied("file_child", "notes.txt", "text/plain")},
meta_gets=meta_gets,
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "use this", "file_ids": ["file_parent"]},
handler=handler,
)
assert json.loads(output)["status"] == "launching"
# Exactly one copy, addressed parent→child with the requested ids.
assert len(copies) == 1
assert copies[0] == {"source_session_id": _PARENT_ID, "file_ids": ["file_parent"]}
# The child message: input_text first, then the file block on the
# mapped id (NOT the original parent id).
content = events[0]["data"]["content"]
assert content[0] == {"type": "input_text", "text": "use this"}
assert content[1] == {"type": "input_file", "file_id": "file_child"}
# The enriched copy response carried the content type, so the dispatch
# path never fetched per-file metadata.
assert meta_gets == [], "content type comes from the copy response — no metadata GET"
@pytest.mark.asyncio
async def test_send_with_image_file_id_attaches_input_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An ``image/*`` content type yields an ``input_image`` block."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_pic": _copied("file_child_pic", "chart.png", "image/png")},
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "look", "file_ids": ["file_pic"]},
handler=handler,
)
assert json.loads(output)["status"] == "launching"
content = events[0]["data"]["content"]
assert content[1] == {"type": "input_image", "file_id": "file_child_pic"}
@pytest.mark.asyncio
async def test_send_image_file_falls_back_to_filename_when_no_content_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A copied file with no content_type falls back to the filename guess."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_pic": _copied("file_child_pic", "chart.png", None)},
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "look", "file_ids": ["file_pic"]},
handler=handler,
)
assert json.loads(output)["status"] == "launching"
content = events[0]["data"]["content"]
# No content_type on the copy row → filename ".png" drives the split.
assert content[1] == {"type": "input_image", "file_id": "file_child_pic"}
@pytest.mark.asyncio
async def test_send_multiple_file_ids_preserve_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multiple file_ids produce one block each, in request order, mapped."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={
"f_a": _copied("c_a", "a.pdf", "application/pdf"),
"f_b": _copied("c_b", "b.png", "image/png"),
"f_c": _copied("c_c", "c.csv", "text/csv"),
},
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "three", "file_ids": ["f_a", "f_b", "f_c"]},
handler=handler,
)
assert json.loads(output)["status"] == "launching"
content = events[0]["data"]["content"]
assert content == [
{"type": "input_text", "text": "three"},
{"type": "input_file", "file_id": "c_a"},
{"type": "input_image", "file_id": "c_b"},
{"type": "input_file", "file_id": "c_c"},
]
@pytest.mark.asyncio
async def test_send_without_file_ids_is_unchanged_and_skips_copy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A plain-string send (no file_ids) posts a single input_text block and
never calls the copy endpoint — the text-only path is unchanged.
"""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={},
)
output = await _run_spawn(
monkeypatch,
args_payload="just text",
handler=handler,
)
assert json.loads(output)["status"] == "launching"
assert copies == [], "text-only send must not call the copy endpoint"
assert events[0]["data"]["content"] == [{"type": "input_text", "text": "just text"}]
@pytest.mark.asyncio
async def test_send_by_session_id_rejects_file_ids_before_server_call() -> None:
"""By-session-id sends cannot forward files."""
from omnigent.runner import app as runner_app
async def _handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"unexpected server call: {request.method} {request.url}")
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with httpx.AsyncClient(
transport=httpx.MockTransport(_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"session_id": _CHILD_ID,
"args": {"input": "continue", "file_ids": ["file_parent"]},
}
),
server_client=server_client,
conversation_id=_PARENT_ID,
agent_spec=_spec_with_subagent(),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop(_PARENT_ID, None)
assert "file_ids" in output
assert "existing session by id" in output
@pytest.mark.parametrize(
"file_ids",
[
[],
["file_parent", "file_parent"],
],
)
@pytest.mark.asyncio
async def test_send_rejects_invalid_file_ids_before_server_call(
file_ids: list[str],
) -> None:
"""Malformed file_ids are rejected before child lookup or copy."""
from omnigent.runner import app as runner_app
async def _handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"unexpected server call: {request.method} {request.url}")
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with httpx.AsyncClient(
transport=httpx.MockTransport(_handler),
base_url="http://server",
) as server_client:
try:
output = await execute_tool(
tool_name="sys_session_send",
arguments=json.dumps(
{
"agent": "worker",
"title": "task-1",
"args": {"input": "spawn", "file_ids": file_ids},
}
),
server_client=server_client,
conversation_id=_PARENT_ID,
agent_spec=_spec_with_subagent(),
session_inbox=session_inbox,
)
finally:
runner_app._session_inboxes_ref.pop(_PARENT_ID, None)
assert output.startswith("Error: sys_session_send invalid 'file_ids':"), output
@pytest.mark.asyncio
async def test_send_named_continuation_rejects_file_ids_before_copy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Named sends only copy files when they create the child session."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_parent": _copied("file_child", "notes.txt", "text/plain")},
existing_child={
"id": _CHILD_ID,
"title": "worker:task-1",
"tool": "worker",
"session_name": "task-1",
"busy": False,
"labels": {},
},
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "continue", "file_ids": ["file_parent"]},
handler=handler,
)
assert "file_ids" in output
assert "already exists" in output
assert copies == []
assert events == []
@pytest.mark.asyncio
async def test_send_with_bad_file_id_surfaces_copy_error_and_posts_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A copy that 404s (e.g. a hallucinated file id) surfaces an error to
the parent and posts no child event — never a malformed message.
"""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
deletes: list[str] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={},
copy_status=404,
copy_error={"error": {"message": "File 'file_bogus' not found in source session"}},
deletes=deletes,
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "use this", "file_ids": ["file_bogus"]},
handler=handler,
)
assert output.startswith("Error: failed to copy files to child:"), output
assert "404" in output
# Decisive: the copy was attempted but no (malformed) child event was posted.
assert len(copies) == 1
assert events == [], "no child event may be posted when the file copy fails"
# The freshly-created server child is torn down so it can't poison a
# retry with the same (agent, title) as a phantom existing child.
assert deletes == [_CHILD_ID], "failed spawn must delete the empty child session"
@pytest.mark.asyncio
async def test_copy_failure_surfaces_child_delete_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failed spawn reports when teardown cannot delete the child."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
deletes: list[str] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={},
copy_status=404,
copy_error={"error": {"message": "File 'file_bogus' not found in source session"}},
deletes=deletes,
delete_status=500,
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "use this", "file_ids": ["file_bogus"]},
handler=handler,
)
assert output.startswith("Error: failed to copy files to child:"), output
assert "Warning: failed to delete newly-created child session" in output
assert "500" in output
assert events == []
assert deletes == [_CHILD_ID, _CHILD_ID]
@pytest.mark.asyncio
async def test_send_message_failure_after_copy_tears_down_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A copy that succeeds but a child events POST that then fails must
tear down the freshly-created child — the copy already wrote
child-scoped file rows, so leaving the child behind would orphan
those rows and poison a same-(agent, title) retry with a phantom.
"""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
deletes: list[str] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_parent": _copied("file_child", "notes.txt", "text/plain")},
deletes=deletes,
events_status=500,
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "use this", "file_ids": ["file_parent"]},
handler=handler,
)
assert output.startswith("Error: failed to send message to child:"), output
assert "500" in output
# Copy happened and the event was attempted, but the failure must
# delete the child (which reclaims the copied file rows with it).
assert len(copies) == 1
assert len(events) == 1
assert deletes == [_CHILD_ID], "send failure after copy must delete the child"
@pytest.mark.asyncio
async def test_send_message_failure_surfaces_child_delete_http_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A message-post failure reports teardown transport failures too."""
events: list[dict[str, Any]] = []
copies: list[dict[str, Any]] = []
deletes: list[str] = []
handler = _spawn_server_handler(
events=events,
copies=copies,
mapping={"file_parent": _copied("file_child", "notes.txt", "text/plain")},
deletes=deletes,
delete_exc=httpx.ConnectError("delete failed"),
events_status=500,
)
output = await _run_spawn(
monkeypatch,
args_payload={"input": "use this", "file_ids": ["file_parent"]},
handler=handler,
)
assert output.startswith("Error: failed to send message to child:"), output
assert "Warning: failed to delete newly-created child session" in output
assert "ConnectError: delete failed" in output
assert len(copies) == 1
assert len(events) == 1
assert deletes == [_CHILD_ID, _CHILD_ID]