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