Task 1: Preserve Sanitized WebSocket Attachment Names in Replay Metadata

TerminallyLazy committed Aug 10, 2026 at 21:02 UTC 921a390c1418f4161bdb9a567c161259a109e18f
3 files changed +184 -1
plugins/_a0_connector/AGENTS.md
+1
@@ -59,6 +59,7 @@
59 - The protected v1 `agents_list` response uses the shared agent presentation
60 catalog rather than applying connector-specific visibility rules.
61 - Computer Use receipts describe transport success unless the connector returns explicit effect evidence. Linux target-bound typing requires a verified active/focused `window_id`; window activation uses focus, never a press action on an application or window node. Do not retry an identical failed Computer Use call.
62 +- Accepted WebSocket user-message replay metadata may include attachment basenames only; strip paths, query strings, fragments, and bytes before logging them in `kvps`.
63
64 ## Work Guidance
65
plugins/_a0_connector/api/ws_connector.py
+18 -1
@@ -3,6 +3,7 @@ from __future__ import annotations
3
4 import asyncio
5 from typing import TYPE_CHECKING, Any, ClassVar
6 +from urllib.parse import urlsplit
7
8 from helpers.print_style import PrintStyle
9 from helpers.ws import WsHandler
@@ -75,6 +76,22 @@ _TAIL_HISTORY_PAGE_SIZE = 100
76 _LIVE_STREAM_PAGE_SIZE = 100
77
78
79 +def _attachment_log_metadata(attachments: list[str]) -> dict[str, list[str]]:
80 + names: list[str] = []
81 + for attachment in attachments:
82 + normalized = str(attachment or "").strip().replace("\\", "/")
83 + if not normalized:
84 + continue
85 + parsed = urlsplit(normalized)
86 + path = parsed.path if parsed.scheme else normalized.split("?", 1)[0].split("#", 1)[0]
87 + if path.endswith("/"):
88 + continue
89 + name = path.rstrip("/").rsplit("/", 1)[-1]
90 + if name and name not in {".", ".."}:
91 + names.append(name)
92 + return {"attachments": names} if names else {}
93 +
94 +
95 class WsConnector(WsHandler):
96 _streaming_tasks: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
97
@@ -468,7 +485,7 @@ class WsConnector(WsHandler):
485 type="user",
486 heading="",
487 content=message,
471 - kvps={},
488 + kvps=_attachment_log_metadata(attachments),
489 id=message_id,
490 )
491
tests/test_a0_connector_attachment_metadata.py new
+165
@@ -0,0 +1,165 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +from types import SimpleNamespace
5 +from unittest.mock import AsyncMock
6 +
7 +import pytest
8 +
9 +from helpers.ws_manager import WsResult
10 +from plugins._a0_connector.api import ws_connector as ws_module
11 +from plugins._a0_connector.api.ws_connector import (
12 + WsConnector,
13 + _attachment_log_metadata,
14 +)
15 +from plugins._a0_connector.helpers.event_bridge import log_entry_to_connector_event
16 +
17 +
18 +def test_attachment_log_metadata_keeps_only_safe_basenames() -> None:
19 + assert _attachment_log_metadata(
20 + [
21 + "/a0/usr/uploads/scan.png",
22 + r"C:\\Users\\person\\result.jpg",
23 + "https://agent.test/api/image_get?path=/a0/usr/uploads/chart.webp&token=secret#view",
24 + "/a0/usr/uploads/",
25 + "",
26 + ]
27 + ) == {
28 + "attachments": ["scan.png", "result.jpg", "image_get"]
29 + }
30 +
31 +
32 +def test_attachment_log_metadata_omits_empty_metadata() -> None:
33 + assert _attachment_log_metadata([]) == {}
34 + assert _attachment_log_metadata(["", "/"]) == {}
35 +
36 +
37 +class RecordingLog:
38 + def __init__(self) -> None:
39 + self.calls: list[dict[str, object]] = []
40 +
41 + def log(self, **kwargs: object) -> None:
42 + self.calls.append(dict(kwargs))
43 +
44 +
45 +@pytest.mark.asyncio
46 +async def test_websocket_attachment_names_reach_replayed_user_event(
47 + monkeypatch: pytest.MonkeyPatch,
48 +) -> None:
49 + log = RecordingLog()
50 + context = SimpleNamespace(log=log)
51 + handler = WsConnector(None, None)
52 + monkeypatch.setattr(
53 + handler,
54 + "_resolve_context",
55 + AsyncMock(return_value=(context, "ctx-1")),
56 + )
57 + monkeypatch.setattr(
58 + ws_module,
59 + "subscribed_contexts_for_sid",
60 + lambda sid: {"ctx-1"} if sid == "sid-cli" else set(),
61 + )
62 +
63 + scheduled: list[bool] = []
64 +
65 + def close_scheduled(coroutine: object) -> SimpleNamespace:
66 + close = getattr(coroutine, "close")
67 + close()
68 + scheduled.append(True)
69 + return SimpleNamespace()
70 +
71 + monkeypatch.setattr(asyncio, "create_task", close_scheduled)
72 +
73 + result = await handler._handle_send_message(
74 + {
75 + "context_id": "ctx-1",
76 + "message": "Review these",
77 + "attachments": [
78 + "/a0/usr/uploads/scan.png",
79 + "/a0/usr/uploads/result.jpg",
80 + ],
81 + "client_message_id": "client-1",
82 + },
83 + "sid-cli",
84 + )
85 +
86 + assert result == {
87 + "context_id": "ctx-1",
88 + "status": "accepted",
89 + "client_message_id": "client-1",
90 + }
91 + assert scheduled == [True]
92 + assert log.calls == [
93 + {
94 + "type": "user",
95 + "heading": "",
96 + "content": "Review these",
97 + "kvps": {"attachments": ["scan.png", "result.jpg"]},
98 + "id": "client-1",
99 + }
100 + ]
101 +
102 + replayed = log_entry_to_connector_event(
103 + {"no": 0, **log.calls[0]},
104 + "ctx-1",
105 + )
106 + assert replayed["event"] == "user_message"
107 + assert replayed["data"]["meta"] == {
108 + "attachments": ["scan.png", "result.jpg"]
109 + }
110 +
111 +
112 +@pytest.mark.asyncio
113 +async def test_websocket_text_only_message_keeps_empty_kvps(
114 + monkeypatch: pytest.MonkeyPatch,
115 +) -> None:
116 + log = RecordingLog()
117 + context = SimpleNamespace(log=log)
118 + handler = WsConnector(None, None)
119 + monkeypatch.setattr(
120 + handler,
121 + "_resolve_context",
122 + AsyncMock(return_value=(context, "ctx-1")),
123 + )
124 + monkeypatch.setattr(
125 + ws_module,
126 + "subscribed_contexts_for_sid",
127 + lambda sid: {"ctx-1"} if sid == "sid-cli" else set(),
128 + )
129 +
130 + def close_scheduled(coroutine: object) -> SimpleNamespace:
131 + getattr(coroutine, "close")()
132 + return SimpleNamespace()
133 +
134 + monkeypatch.setattr(asyncio, "create_task", close_scheduled)
135 + await handler._handle_send_message(
136 + {"context_id": "ctx-1", "message": "Text only"},
137 + "sid-cli",
138 + )
139 + assert log.calls[0]["kvps"] == {}
140 +
141 +
142 +@pytest.mark.asyncio
143 +@pytest.mark.parametrize(
144 + ("payload", "code"),
145 + [
146 + (
147 + {
148 + "message": "image",
149 + "attachments": [{"path": "data:image/png;base64,AAAA"}],
150 + },
151 + "INVALID_ATTACHMENTS",
152 + ),
153 + ({"message": "", "attachments": []}, "MISSING_MESSAGE"),
154 + ],
155 +)
156 +async def test_websocket_rejected_attachments_do_not_reach_context(
157 + payload: dict[str, object],
158 + code: str,
159 +) -> None:
160 + handler = WsConnector(None, None)
161 + result = await handler.process("connector_send_message", payload, "sid-cli")
162 + assert isinstance(result, WsResult)
163 + rendered = result.as_result(handler_id="test", fallback_correlation_id=None)
164 + assert rendered["ok"] is False
165 + assert rendered["error"]["code"] == code