Skip to content

Commit 9d0df4a

Browse files
committed
update after comments
1 parent 0ec7d7a commit 9d0df4a

File tree

2 files changed

+69
-17
lines changed

2 files changed

+69
-17
lines changed

openhands-sdk/openhands/sdk/skills/fork.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@
88

99
from __future__ import annotations
1010

11+
import re
1112
from pathlib import Path
1213
from typing import TYPE_CHECKING
1314

1415
from openhands.sdk.logger import get_logger
1516
from openhands.sdk.skills.execute import render_content_with_commands
1617

1718

19+
# Skill names are not guaranteed to be filesystem-safe:
20+
# - Legacy skills derive their name from a relative path, which can contain
21+
# "/" (e.g. "subdir/my_skill") and would create unintended nested dirs.
22+
# - Programmatic Skill(name=...) has no name validator; a crafted name like
23+
# "../../etc/passwd" would escape the forks directory.
24+
# AgentSkills-format skills ARE validated (lowercase [a-z0-9-]) but we
25+
# sanitize unconditionally to keep the fork persistence layout predictable.
26+
_UNSAFE_PATH_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
27+
28+
1829
if TYPE_CHECKING:
1930
from openhands.sdk.agent.base import AgentBase
2031
from openhands.sdk.skills.skill import Skill
@@ -47,19 +58,23 @@ def run_skill_forked(
4758
working_dir=Path(working_dir) if working_dir else None,
4859
)
4960

50-
# Strip skills from the subagent's context so forked skills cannot
51-
# re-trigger and cause infinite recursion. Everything else (system_message_suffix,
52-
# secrets, datetime) is preserved.
53-
sub_agent_context = (
54-
agent.agent_context.model_copy(update={"skills": []})
55-
if agent.agent_context is not None
56-
else None
57-
)
61+
# Strip fork-context skills from the subagent so forked skills cannot
62+
# re-trigger themselves (direct recursion) or each other (A → B → A loops).
63+
# Inline skills are kept: they only inject static content and are safe to
64+
# reuse inside a forked subagent. Everything else on agent_context
65+
# (system_message_suffix, secrets, datetime) is preserved.
66+
parent_context = agent.agent_context
67+
if parent_context is not None:
68+
safe_skills = [s for s in parent_context.skills if s.context != "fork"]
69+
sub_agent_context = parent_context.model_copy(update={"skills": safe_skills})
70+
else:
71+
sub_agent_context = None
5872
sub_agent = agent.model_copy(update={"agent_context": sub_agent_context})
5973

6074
fork_persistence_dir: str | None = None
6175
if persistence_dir is not None:
62-
fork_persistence_dir = str(Path(persistence_dir) / "forks" / skill.name)
76+
safe_name = _UNSAFE_PATH_CHARS.sub("_", skill.name)
77+
fork_persistence_dir = str(Path(persistence_dir) / "forks" / safe_name)
6378

6479
sub_conv = Conversation(
6580
agent=sub_agent,
@@ -79,5 +94,5 @@ def run_skill_forked(
7994
finally:
8095
try:
8196
sub_conv.close()
82-
except Exception:
83-
logger.debug("Ignoring error closing forked sub-conversation")
97+
except Exception as e:
98+
logger.debug("Ignoring error closing forked sub-conversation: %s", e)

tests/sdk/skills/test_skill_fork_execution.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,18 @@ def test_fork_skill_persistence_dir_forwarded(persistence_dir):
153153

154154

155155
@pytest.mark.parametrize(
156-
"persistence_dir, expected",
156+
"persistence_dir, skill_name, expected",
157157
[
158-
(None, None),
159-
("/state/abc123", "/state/abc123/forks/ddebug"),
158+
(None, "ddebug", None),
159+
("/state/abc123", "ddebug", "/state/abc123/forks/ddebug"),
160+
# Path-unsafe characters are sanitized to avoid nested dirs or traversal
161+
("/state/abc123", "subdir/my_skill", "/state/abc123/forks/subdir_my_skill"),
162+
("/state/abc123", "../evil", "/state/abc123/forks/___evil"),
160163
],
161164
)
162-
def test_fork_persistence_dir_path_construction(persistence_dir, expected):
163-
"""builds <persistence_dir>/forks/<skill.name> before passing to Conversation."""
164-
skill = _fork_skill()
165+
def test_fork_persistence_dir_path_construction(persistence_dir, skill_name, expected):
166+
"""builds <persistence_dir>/forks/<safe_name> before passing to Conversation."""
167+
skill = _fork_skill(name=skill_name)
165168
mock_agent = MagicMock()
166169
mock_agent.agent_context = None
167170

@@ -204,3 +207,37 @@ def test_fork_skill_fallback_when_agent_or_working_dir_missing(
204207
assert result is not None
205208
content, _ = result
206209
assert "inline fallback content" in content.text
210+
211+
212+
def test_subagent_context_keeps_inline_skills_drops_forks():
213+
"""Forked subagent retains inline skills but not other fork skills,
214+
and preserves system_message_suffix."""
215+
fork_skill = _fork_skill(name="ddebug")
216+
other_fork = _fork_skill(name="other_fork")
217+
inline_skill = Skill(
218+
name="inline_helper",
219+
content="inline body",
220+
trigger=KeywordTrigger(keywords=["helper"]),
221+
context="inline",
222+
)
223+
parent_ctx = AgentContext(
224+
skills=[fork_skill, other_fork, inline_skill],
225+
system_message_suffix="preserve me",
226+
)
227+
mock_agent = MagicMock()
228+
mock_agent.agent_context = parent_ctx
229+
# model_copy on the agent itself: capture what gets passed
230+
mock_agent.model_copy.return_value = MagicMock()
231+
232+
with patch(
233+
"openhands.sdk.conversation.conversation.Conversation"
234+
) as MockConversation:
235+
mock_conv = MagicMock()
236+
mock_conv.state.events = []
237+
MockConversation.return_value = mock_conv
238+
run_skill_forked(fork_skill, mock_agent, "/workspace")
239+
240+
_, agent_copy_kwargs = mock_agent.model_copy.call_args
241+
sub_ctx = agent_copy_kwargs["update"]["agent_context"]
242+
assert [s.name for s in sub_ctx.skills] == ["inline_helper"]
243+
assert sub_ctx.system_message_suffix == "preserve me"

0 commit comments

Comments
 (0)