Skip to content

Commit 1545d19

Browse files
authored
fix(python): harden discovery provider matching (#30)
This prevents empty or ambiguous app names from silently matching the wrong provider and keeps access tracking safe when discovery is called outside an active event loop. It adds targeted discovery tests for empty, ambiguous, and normalized name resolution.
1 parent 765467c commit 1545d19

2 files changed

Lines changed: 146 additions & 24 deletions

File tree

packages/python/slop-ai/src/slop_ai/discovery/service.py

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,59 @@
2525
from .relay_transport import BridgeRelayTransport
2626

2727

28+
def _normalize_match(value: str) -> str:
29+
return "".join(ch.lower() for ch in value if ch.isalnum())
30+
31+
32+
def _resolve_named_match(
33+
items: list[Any],
34+
id_or_name: str,
35+
*,
36+
get_id: Callable[[Any], str],
37+
get_name: Callable[[Any], str],
38+
) -> Any | None:
39+
raw = id_or_name.strip()
40+
if not raw:
41+
return None
42+
43+
lower = raw.lower()
44+
normalized = _normalize_match(raw)
45+
46+
exact_id = [item for item in items if get_id(item) == raw]
47+
if len(exact_id) == 1:
48+
return exact_id[0]
49+
50+
exact_text = [
51+
item
52+
for item in items
53+
if get_id(item).lower() == lower or get_name(item).lower() == lower
54+
]
55+
if len(exact_text) == 1:
56+
return exact_text[0]
57+
58+
exact_normalized = [
59+
item
60+
for item in items
61+
if _normalize_match(get_id(item)) == normalized
62+
or _normalize_match(get_name(item)) == normalized
63+
]
64+
if len(exact_normalized) == 1:
65+
return exact_normalized[0]
66+
67+
partial = [
68+
item
69+
for item in items
70+
if lower in get_id(item).lower()
71+
or lower in get_name(item).lower()
72+
or (len(normalized) >= 2 and normalized in _normalize_match(get_id(item)))
73+
or (len(normalized) >= 2 and normalized in _normalize_match(get_name(item)))
74+
]
75+
if len(partial) == 1:
76+
return partial[0]
77+
78+
return None
79+
80+
2881
class DiscoveryService:
2982
"""Discover, connect, and manage local and browser-backed SLOP providers."""
3083

@@ -129,7 +182,7 @@ def get_provider(self, provider_id: str) -> ConnectedProvider | None:
129182
provider = self._providers.get(provider_id)
130183
if provider is None or provider.status != "connected":
131184
return None
132-
self._last_accessed[provider.id] = asyncio.get_running_loop().time()
185+
self._touch_provider(provider.id)
133186
return provider
134187

135188
async def ensure_connected(self, id_or_name: str) -> ConnectedProvider | None:
@@ -426,35 +479,32 @@ def _create_transport(self, descriptor: ProviderDescriptor) -> Any | None:
426479
return None
427480

428481
def _find_connected_provider(self, id_or_name: str) -> ConnectedProvider | None:
429-
provider = self._providers.get(id_or_name)
482+
provider = _resolve_named_match(
483+
list(self._providers.values()),
484+
id_or_name,
485+
get_id=lambda item: item.id,
486+
get_name=lambda item: item.name,
487+
)
430488
if provider is not None and provider.status == "connected":
431-
self._last_accessed[provider.id] = asyncio.get_running_loop().time()
489+
self._touch_provider(provider.id)
432490
return provider
433-
434-
needle = id_or_name.lower()
435-
for provider in self._providers.values():
436-
if provider.status == "connected" and needle in provider.name.lower():
437-
self._last_accessed[provider.id] = asyncio.get_running_loop().time()
438-
return provider
439491
return None
440492

441493
def _find_any_provider(self, id_or_name: str) -> ConnectedProvider | None:
442-
provider = self._providers.get(id_or_name)
443-
if provider is not None:
444-
return provider
445-
446-
needle = id_or_name.lower()
447-
for provider in self._providers.values():
448-
if needle in provider.name.lower():
449-
return provider
450-
return None
494+
return _resolve_named_match(
495+
list(self._providers.values()),
496+
id_or_name,
497+
get_id=lambda item: item.id,
498+
get_name=lambda item: item.name,
499+
)
451500

452501
def _find_descriptor(self, id_or_name: str) -> ProviderDescriptor | None:
453-
needle = id_or_name.lower()
454-
for descriptor in self.get_discovered():
455-
if descriptor.id == id_or_name or needle in descriptor.name.lower():
456-
return descriptor
457-
return None
502+
return _resolve_named_match(
503+
self.get_discovered(),
504+
id_or_name,
505+
get_id=lambda item: item.id,
506+
get_name=lambda item: item.name,
507+
)
458508

459509
def _forget_provider(self, provider_id: str) -> None:
460510
self._providers.pop(provider_id, None)
@@ -464,6 +514,10 @@ def _forget_provider(self, provider_id: str) -> None:
464514
if reconnect_task is not None:
465515
reconnect_task.cancel()
466516

517+
def _touch_provider(self, provider_id: str) -> None:
518+
with contextlib.suppress(RuntimeError):
519+
self._last_accessed[provider_id] = asyncio.get_running_loop().time()
520+
467521
def _fire_state_change(self) -> None:
468522
for handler in self._state_change_handlers:
469523
handler()

packages/python/slop-ai/tests/test_discovery.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import socket
88
from pathlib import Path
9-
from typing import Any
9+
from typing import Any, cast
1010

1111
import pytest
1212

@@ -16,6 +16,11 @@
1616
DiscoveryOptions,
1717
DiscoveryService,
1818
)
19+
from slop_ai.discovery.models import (
20+
ConnectedProvider,
21+
ProviderDescriptor,
22+
TransportDescriptor,
23+
)
1924

2025

2126
def test_service_scans_and_prunes_descriptors(tmp_path: Path) -> None:
@@ -113,6 +118,64 @@ async def _run() -> None:
113118
asyncio.run(_run())
114119

115120

121+
def test_service_matching_rejects_empty_and_ambiguous_app_names() -> None:
122+
async def _run() -> None:
123+
service = DiscoveryService(DiscoveryOptions(providers_dirs=[]))
124+
service._local_descriptors = [
125+
ProviderDescriptor(
126+
id="canvas-app",
127+
name="Canvas App",
128+
slop_version="0.1",
129+
transport=TransportDescriptor(type="unix", path="/tmp/canvas.sock"),
130+
capabilities=["state"],
131+
),
132+
ProviderDescriptor(
133+
id="canvas-pro",
134+
name="Canvas Pro",
135+
slop_version="0.1",
136+
transport=TransportDescriptor(type="unix", path="/tmp/canvas-pro.sock"),
137+
capabilities=["state"],
138+
),
139+
]
140+
service._providers = {
141+
"canvas-app": ConnectedProvider(
142+
id="canvas-app",
143+
name="Canvas App",
144+
descriptor=service._local_descriptors[0],
145+
consumer=cast(Any, _FakeConsumer()),
146+
subscription_id="sub-1",
147+
status="connected",
148+
)
149+
}
150+
151+
assert service._find_descriptor("") is None
152+
assert service._find_descriptor(" ") is None
153+
assert service._find_connected_provider("") is None
154+
assert service._find_any_provider(" ") is None
155+
assert service._find_descriptor("canvas") is None
156+
assert service._find_descriptor("canvas-app") is not None
157+
assert service._find_descriptor("Canvas App") is not None
158+
assert service._find_connected_provider("Canvas App") is not None
159+
160+
asyncio.run(_run())
161+
162+
163+
def test_service_matching_supports_normalized_unique_matches() -> None:
164+
service = DiscoveryService(DiscoveryOptions(providers_dirs=[]))
165+
descriptor = ProviderDescriptor(
166+
id="whiteboard-canvas",
167+
name="Whiteboard Canvas",
168+
slop_version="0.1",
169+
transport=TransportDescriptor(type="unix", path="/tmp/whiteboard.sock"),
170+
capabilities=["state"],
171+
)
172+
service._local_descriptors = [descriptor]
173+
174+
assert service._find_descriptor("whiteboard canvas") == descriptor
175+
assert service._find_descriptor("WhiteboardCanvas") == descriptor
176+
assert service._find_descriptor("board") == descriptor
177+
178+
116179
async def _wait_until(predicate: Any, timeout: float = 1.0) -> None:
117180
deadline = asyncio.get_running_loop().time() + timeout
118181
while asyncio.get_running_loop().time() < deadline:
@@ -165,3 +228,8 @@ def start(self) -> None:
165228

166229
async def stop(self) -> None:
167230
return None
231+
232+
233+
class _FakeConsumer:
234+
def disconnect(self) -> None:
235+
return None

0 commit comments

Comments
 (0)