746 lines
27 KiB
Python
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]
|