Skip to content

Commit acf5318

Browse files
fix: address code review feedback for multiple marketplace registrations
- Fix silent exception swallowing in registry.py: - _resolve_from_all() now accumulates fetch errors and includes them in PluginNotFoundError when all marketplaces fail - list_plugins() now raises PluginResolutionError with details when all fail - Improves debugging: users see actual errors instead of 'plugin not found' - Simplify agent_context.py deprecation logic: - Remove unnecessary effective_marketplace_path variable - Remove misleading comment about backward compatibility - Improve path validation security in types.py: - Add _validate_repo_path() helper using os.path.normpath() - Catches path traversal tricks like 'safe/../../../etc' - Both MarketplaceRegistration and PluginSource use shared validation - Fix state mutation atomicity in local_conversation.py: - Update both self.agent and self._state.agent within same lock context - Add comprehensive tests: - TestErrorAccumulation: 3 tests for error accumulation behavior - TestPathValidation: 5 tests for path security validation
1 parent 7fc6457 commit acf5318

5 files changed

Lines changed: 214 additions & 35 deletions

File tree

openhands-sdk/openhands/sdk/context/agent_context.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,26 +135,21 @@ def _load_auto_skills(self):
135135
if not self.load_user_skills and not self.load_public_skills:
136136
return self
137137

138-
# Handle backward compatibility: if marketplace_path is set but
139-
# registered_marketplaces is empty, emit deprecation warning and
140-
# convert to a default marketplace registration
141-
effective_marketplace_path = self.marketplace_path
138+
# Emit deprecation warning if using old marketplace_path field
142139
if self.marketplace_path is not None and not self.registered_marketplaces:
143140
warnings.warn(
144141
"AgentContext.marketplace_path is deprecated. "
145142
"Use registered_marketplaces instead.",
146143
DeprecationWarning,
147144
stacklevel=2,
148145
)
149-
# For backward compatibility, we still use marketplace_path
150-
# when registered_marketplaces is empty
151146

152147
auto_skills = load_available_skills(
153148
work_dir=None,
154149
include_user=self.load_user_skills,
155150
include_project=False,
156151
include_public=self.load_public_skills,
157-
marketplace_path=effective_marketplace_path,
152+
marketplace_path=self.marketplace_path,
158153
)
159154

160155
existing_names = {skill.name for skill in self.skills}

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,17 +1188,17 @@ def load_plugin(self, plugin_ref: str) -> None:
11881188
dict(self.agent.mcp_config) if self.agent.mcp_config else {}
11891189
)
11901190

1191-
# Update agent with merged content
1192-
self.agent = self.agent.model_copy(
1191+
# Update agent and state atomically
1192+
# Create new agent first, then update both references together
1193+
new_agent = self.agent.model_copy(
11931194
update={
11941195
"agent_context": merged_context,
11951196
"mcp_config": merged_mcp,
11961197
}
11971198
)
1198-
1199-
# Update agent in state for API observability
12001199
with self._state:
1201-
self._state.agent = self.agent
1200+
self.agent = new_agent
1201+
self._state.agent = new_agent
12021202

12031203
# Track resolved plugin
12041204
resolved = ResolvedPluginSource.from_plugin_source(

openhands-sdk/openhands/sdk/plugin/registry.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,29 @@ def __init__(self, plugin_name: str, matching_marketplaces: list[str]):
3838
class PluginNotFoundError(PluginResolutionError):
3939
"""Raised when a plugin cannot be found in any registered marketplace."""
4040

41-
def __init__(self, plugin_name: str, marketplace_name: str | None = None):
41+
def __init__(
42+
self,
43+
plugin_name: str,
44+
marketplace_name: str | None = None,
45+
fetch_errors: dict[str, Exception] | None = None,
46+
):
4247
self.plugin_name = plugin_name
4348
self.marketplace_name = marketplace_name
49+
self.fetch_errors = fetch_errors or {}
50+
4451
if marketplace_name:
4552
msg = (
4653
f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'"
4754
)
55+
elif fetch_errors:
56+
# All marketplaces failed to fetch - show the actual errors
57+
error_details = "; ".join(
58+
f"'{name}': {err}" for name, err in fetch_errors.items()
59+
)
60+
msg = (
61+
f"Plugin '{plugin_name}' not found. "
62+
f"All {len(fetch_errors)} marketplace(s) failed to fetch: {error_details}"
63+
)
4864
else:
4965
msg = f"Plugin '{plugin_name}' not found in any registered marketplace"
5066
super().__init__(msg)
@@ -226,10 +242,13 @@ def _resolve_from_marketplace(
226242
def _resolve_from_all(self, plugin_name: str) -> PluginSource:
227243
"""Resolve a plugin by searching all registered marketplaces."""
228244
matches: list[tuple[str, PluginSource]] = []
245+
fetch_errors: dict[str, Exception] = {}
246+
searched_count = 0
229247

230248
for name, reg in self._registrations.items():
231249
try:
232250
marketplace, repo_path = self._fetch_marketplace(reg)
251+
searched_count += 1
233252
plugin_entry = marketplace.get_plugin(plugin_name)
234253

235254
if plugin_entry is not None:
@@ -244,12 +263,16 @@ def _resolve_from_all(self, plugin_name: str) -> PluginSource:
244263
matches.append((name, plugin_source))
245264

246265
except Exception as e:
266+
fetch_errors[name] = e
247267
logger.warning(
248268
f"Error searching marketplace '{name}' "
249269
f"for plugin '{plugin_name}': {e}"
250270
)
251271

252272
if not matches:
273+
# If all marketplaces failed to fetch, include errors in exception
274+
if fetch_errors and searched_count == 0:
275+
raise PluginNotFoundError(plugin_name, fetch_errors=fetch_errors)
253276
raise PluginNotFoundError(plugin_name)
254277

255278
if len(matches) > 1:
@@ -269,18 +292,33 @@ def list_plugins(self, marketplace_name: str | None = None) -> list[str]:
269292
270293
Returns:
271294
List of plugin names (may include duplicates if searching all).
295+
296+
Raises:
297+
PluginResolutionError: If all marketplaces fail to fetch when listing all.
272298
"""
273299
plugin_names: list[str] = []
274300

275301
if marketplace_name:
276302
marketplace, _ = self.get_marketplace(marketplace_name)
277303
plugin_names.extend(p.name for p in marketplace.plugins)
278304
else:
305+
fetch_errors: dict[str, Exception] = {}
279306
for name, reg in self._registrations.items():
280307
try:
281308
marketplace, _ = self._fetch_marketplace(reg)
282309
plugin_names.extend(p.name for p in marketplace.plugins)
283310
except Exception as e:
311+
fetch_errors[name] = e
284312
logger.warning(f"Error listing plugins from '{name}': {e}")
285313

314+
# If all marketplaces failed, raise with details
315+
if fetch_errors and not plugin_names and self._registrations:
316+
error_details = "; ".join(
317+
f"'{name}': {err}" for name, err in fetch_errors.items()
318+
)
319+
raise PluginResolutionError(
320+
f"Failed to list plugins. "
321+
f"All {len(fetch_errors)} marketplace(s) failed: {error_details}"
322+
)
323+
286324
return plugin_names

openhands-sdk/openhands/sdk/plugin/types.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
from pathlib import Path
78
from typing import TYPE_CHECKING, Any, Literal
89

@@ -74,18 +75,41 @@ class MarketplaceRegistration(BaseModel):
7475
@classmethod
7576
def validate_repo_path(cls, v: str | None) -> str | None:
7677
"""Validate repo_path is a safe relative path within the repository."""
77-
if v is None:
78-
return v
79-
# Must be relative (no absolute paths)
80-
if v.startswith("/"):
81-
raise ValueError("repo_path must be relative, not absolute")
82-
# No parent directory traversal
83-
if ".." in Path(v).parts:
84-
raise ValueError(
85-
"repo_path cannot contain '..' (parent directory traversal)"
86-
)
78+
return _validate_repo_path(v)
79+
80+
81+
def _validate_repo_path(v: str | None) -> str | None:
82+
"""Validate that a repo_path is a safe relative path.
83+
84+
Ensures the path:
85+
- Is relative (not absolute)
86+
- Does not escape the repository root via '..' traversal
87+
- Normalizes to a path within the repo even after resolution
88+
"""
89+
if v is None:
8790
return v
8891

92+
# Must be relative (no absolute paths)
93+
if v.startswith("/"):
94+
raise ValueError("repo_path must be relative, not absolute")
95+
96+
# Normalize the path to catch tricks like 'safe/../../../etc'
97+
# Use a dummy root to resolve against, then check if result stays inside
98+
dummy_root = Path("/repo")
99+
normalized = os.path.normpath(os.path.join(str(dummy_root), v))
100+
normalized_path = Path(normalized)
101+
102+
# Check if the normalized path is still under dummy_root
103+
# This catches paths like 'a/../../etc' which normalize to '/etc'
104+
try:
105+
normalized_path.relative_to(dummy_root)
106+
except ValueError:
107+
raise ValueError(
108+
f"repo_path '{v}' escapes repository root after normalization"
109+
)
110+
111+
return v
112+
89113

90114
class PluginSource(BaseModel):
91115
"""Specification for a plugin to load.
@@ -127,17 +151,7 @@ class PluginSource(BaseModel):
127151
@classmethod
128152
def validate_repo_path(cls, v: str | None) -> str | None:
129153
"""Validate repo_path is a safe relative path within the repository."""
130-
if v is None:
131-
return v
132-
# Must be relative (no absolute paths)
133-
if v.startswith("/"):
134-
raise ValueError("repo_path must be relative, not absolute")
135-
# No parent directory traversal
136-
if ".." in Path(v).parts:
137-
raise ValueError(
138-
"repo_path cannot contain '..' (parent directory traversal)"
139-
)
140-
return v
154+
return _validate_repo_path(v)
141155

142156

143157
class ResolvedPluginSource(BaseModel):

tests/sdk/plugin/test_marketplace_registry.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
MarketplaceRegistration,
1313
MarketplaceRegistry,
1414
PluginNotFoundError,
15+
PluginResolutionError,
1516
)
1617

1718

@@ -68,7 +69,7 @@ def test_repo_path_validation_rejects_absolute(self):
6869

6970
def test_repo_path_validation_rejects_traversal(self):
7071
"""Test that parent directory traversal is rejected."""
71-
with pytest.raises(ValueError, match="cannot contain '..'"):
72+
with pytest.raises(ValueError, match="escapes repository root"):
7273
MarketplaceRegistration(
7374
name="test",
7475
source="github:owner/repo",
@@ -319,3 +320,134 @@ def test_list_plugins_from_all(self, mock_marketplace_dir):
319320
plugins = registry.list_plugins()
320321

321322
assert set(plugins) == {"plugin-a", "plugin-b"}
323+
324+
325+
class TestErrorAccumulation:
326+
"""Tests for error accumulation when marketplaces fail."""
327+
328+
def test_resolve_plugin_all_marketplaces_fail_shows_errors(self):
329+
"""Test that when all marketplaces fail, errors are included in exception."""
330+
regs = [
331+
MarketplaceRegistration(name="mp1", source="github:owner/repo1"),
332+
MarketplaceRegistration(name="mp2", source="github:owner/repo2"),
333+
]
334+
registry = MarketplaceRegistry(regs)
335+
336+
# Mock _fetch_marketplace to always fail
337+
error1 = ConnectionError("Network unreachable for mp1")
338+
error2 = TimeoutError("Timeout connecting to mp2")
339+
340+
def mock_fetch(reg):
341+
if reg.name == "mp1":
342+
raise error1
343+
raise error2
344+
345+
with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch):
346+
with pytest.raises(PluginNotFoundError) as exc_info:
347+
registry.resolve_plugin("some-plugin")
348+
349+
# Error should mention all marketplace failures
350+
assert exc_info.value.fetch_errors is not None
351+
assert len(exc_info.value.fetch_errors) == 2
352+
assert "mp1" in exc_info.value.fetch_errors
353+
assert "mp2" in exc_info.value.fetch_errors
354+
# Exception message should contain details
355+
assert "All 2 marketplace(s) failed" in str(exc_info.value)
356+
assert "Network unreachable" in str(exc_info.value)
357+
assert "Timeout" in str(exc_info.value)
358+
359+
def test_resolve_plugin_partial_failures_dont_show_errors(
360+
self, mock_marketplace_dir
361+
):
362+
"""Test that partial failures (some succeed) don't include fetch_errors."""
363+
regs = [
364+
MarketplaceRegistration(name="failing", source="github:owner/bad"),
365+
MarketplaceRegistration(name="working", source=str(mock_marketplace_dir)),
366+
]
367+
registry = MarketplaceRegistry(regs)
368+
369+
marketplace = Marketplace.load(mock_marketplace_dir)
370+
371+
def mock_fetch(reg):
372+
if reg.name == "failing":
373+
raise ConnectionError("Network error")
374+
return (marketplace, mock_marketplace_dir)
375+
376+
with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch):
377+
# Plugin not in the marketplace that succeeded
378+
with pytest.raises(PluginNotFoundError) as exc_info:
379+
registry.resolve_plugin("nonexistent-plugin")
380+
381+
# Since one marketplace was searched successfully, we get normal error
382+
assert "not found in any registered marketplace" in str(exc_info.value)
383+
# fetch_errors should be empty (not all failed)
384+
assert not exc_info.value.fetch_errors
385+
386+
def test_list_plugins_all_marketplaces_fail_raises_error(self):
387+
"""Test that list_plugins raises error with details when all fail."""
388+
regs = [
389+
MarketplaceRegistration(name="mp1", source="github:owner/repo1"),
390+
MarketplaceRegistration(name="mp2", source="github:owner/repo2"),
391+
]
392+
registry = MarketplaceRegistry(regs)
393+
394+
def mock_fetch(reg):
395+
raise ConnectionError(f"Failed to fetch {reg.name}")
396+
397+
with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch):
398+
with pytest.raises(PluginResolutionError) as exc_info:
399+
registry.list_plugins()
400+
401+
# Error message should show all failures
402+
assert "All 2 marketplace(s) failed" in str(exc_info.value)
403+
assert "mp1" in str(exc_info.value)
404+
assert "mp2" in str(exc_info.value)
405+
406+
407+
class TestPathValidation:
408+
"""Tests for repo_path validation security."""
409+
410+
def test_repo_path_rejects_traversal_via_normalization(self):
411+
"""Test that paths like 'safe/../../../etc' are rejected."""
412+
with pytest.raises(ValueError, match="escapes repository root"):
413+
MarketplaceRegistration(
414+
name="test",
415+
source="github:owner/repo",
416+
repo_path="safe/../../../etc/passwd",
417+
)
418+
419+
def test_repo_path_allows_valid_nested_path(self):
420+
"""Test that valid nested paths are allowed."""
421+
reg = MarketplaceRegistration(
422+
name="test",
423+
source="github:owner/repo",
424+
repo_path="marketplaces/internal/plugins",
425+
)
426+
assert reg.repo_path == "marketplaces/internal/plugins"
427+
428+
def test_repo_path_allows_simple_path(self):
429+
"""Test that simple paths without any tricks work."""
430+
reg = MarketplaceRegistration(
431+
name="test",
432+
source="github:owner/repo",
433+
repo_path="plugins",
434+
)
435+
assert reg.repo_path == "plugins"
436+
437+
def test_repo_path_rejects_absolute_path(self):
438+
"""Test that absolute paths are rejected."""
439+
with pytest.raises(ValueError, match="must be relative"):
440+
MarketplaceRegistration(
441+
name="test",
442+
source="github:owner/repo",
443+
repo_path="/etc/passwd",
444+
)
445+
446+
def test_repo_path_rejects_simple_parent_traversal(self):
447+
"""Test that simple '..' traversal is rejected."""
448+
with pytest.raises(ValueError, match="escapes repository root"):
449+
MarketplaceRegistration(
450+
name="test",
451+
source="github:owner/repo",
452+
repo_path="../outside",
453+
)

0 commit comments

Comments
 (0)