Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/execute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Command execution for dynamic skill context injection.

Supports inline !`command` syntax in skill content. Commands are executed
at render time and their output replaces the placeholder.

Safety rules:
- Fenced (```) and inline (`) code blocks are preserved, never executed.
- An unclosed fenced block (odd number of ```) extends to EOF, protecting
any trailing content from accidental execution.
- Use \\!`cmd` to produce the literal text !`cmd` without execution.

**Security Warning**: Commands are executed via shell with full process
privileges. Only use with trusted skill sources.
"""

from __future__ import annotations

import re
import subprocess
from pathlib import Path
from typing import Final

from openhands.sdk.logger import get_logger


logger = get_logger(__name__)

# 50KB per command output
MAX_OUTPUT_SIZE: Final[int] = 50 * 1024

# Default timeout per command in seconds
DEFAULT_TIMEOUT: Final[float] = 10.0

# Single-pass pattern: matches fenced code blocks, escaped commands, inline code,
# or !`command`. Order matters – earlier alternatives take priority.
#
# 1. Fenced blocks (``` ... ```). An *unclosed* fence (odd number of ```)
# matches through to the end of the string so that content after the last
# opening ``` is never accidentally executed.
# 2. Escaped commands (\!`...`) – the backslash is stripped and the rest is
# kept as a literal !`...` so authors can document the syntax itself.
# 3. Inline code (`...`) not preceded by `!`.
# 4. Executable commands (!`...`).
_COMBINED_PATTERN: re.Pattern[str] = re.compile(
r"(?P<fenced>```[\s\S]*?(?:```|$))" # fenced code block (unclosed → EOF)
r"|(?P<escaped>\\!`[^`]+`)" # escaped \!`command` → literal
r"|(?P<inline>(?<!!)`[^`]+`)" # inline code (not preceded by !)
r"|!`(?P<cmd>[^`]+)`" # !`command`
)


def _execute_inline_command(
command: str,
working_dir: Path | None = None,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
Comment thread
VascoSch92 marked this conversation as resolved.
"""Execute a single inline shell command and return its output.

When *working_dir* is None the command inherits the current process's
cwd. Callers rendering skills during agent execution should pass the
workspace path explicitly so that workspace-relative commands (e.g.
``git status``) resolve correctly.
"""
cwd = str(working_dir) if working_dir else None
try:
result = subprocess.run(
command,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
message = (
f"Command `{command}` exited with "
f"code {result.returncode}: {result.stderr}"
)
logger.warning("Skill command failed: %s", message)
return f"[Error: {message}]"

output = result.stdout.strip()
if len(output.encode()) > MAX_OUTPUT_SIZE:
output = output.encode()[:MAX_OUTPUT_SIZE].decode("utf-8", errors="ignore")
output += "\n... [output truncated]"
return output

except subprocess.TimeoutExpired:
message = f"Command `{command}` timed out after {timeout}s"
logger.warning("Skill command failed: %s", message)
return f"[Error: {message}]"
except Exception as e:
message = f"Failed to execute command `{command}`: {e}"
logger.warning("Skill command failed: %s", message)
return f"[Error: {message}]"


def render_content_with_commands(
content: str,
working_dir: Path | None = None,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Execute inline !`command` patterns in content and replace with output.

Code blocks (fenced ``` and inline `) are preserved and not executed.
Unclosed fenced blocks (odd number of ```) are treated as extending to
EOF so that trailing content is never accidentally executed.
Use \\!`cmd` to produce the literal text !`cmd` without execution.
"""

def _replace(match: re.Match[str]) -> str:
if match.group("fenced") or match.group("inline"):
return match.group(0)
if match.group("escaped"):
# Strip leading backslash: \!`cmd` → !`cmd`
return match.group("escaped")[1:]
return _execute_inline_command(match.group("cmd"), working_dir, timeout)

return _COMBINED_PATTERN.sub(_replace, content)
20 changes: 20 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pydantic import BaseModel, Field, field_validator, model_validator

from openhands.sdk.context.skills.exceptions import SkillError, SkillValidationError
from openhands.sdk.context.skills.execute import render_content_with_commands
from openhands.sdk.context.skills.trigger import (
KeywordTrigger,
TaskTrigger,
Expand Down Expand Up @@ -625,6 +626,25 @@ def to_skill_info(self) -> SkillInfo:
is_agentskills_format=self.is_agentskills_format,
)

def render_content(
self,
working_dir: Path | None = None,
) -> str:
"""Render skill content, executing inline !`command` blocks.

Inline !`command` patterns in the content are executed and
replaced with their stdout output. Code blocks (fenced and
inline) are preserved. Unclosed fenced blocks are treated as
extending to EOF. Use \\!`cmd` to produce literal !`cmd` text.

Args:
working_dir: Directory to run commands in.

Returns:
Processed content with command outputs substituted.
"""
return render_content_with_commands(self.content, working_dir)


def load_skills_from_dir(
skill_dir: str | Path,
Expand Down
210 changes: 210 additions & 0 deletions tests/sdk/context/skill/test_skill_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Tests for inline !`command` execution in skill content.

The !`command` syntax lets skill authors embed dynamic shell output in
markdown. These tests verify:

- Basic execution: !`echo hello` → hello
- Error / timeout handling
- Output truncation for large outputs
- Code-block safety: fenced (```) and inline (`) blocks are never executed
- Unclosed fenced blocks: an odd number of ``` delimiters must not leak
commands that follow the last unclosed fence
- Escape hatch: \\!`cmd` is preserved as the literal text !`cmd`
- Integration with the Skill model (load + render)
"""

from pathlib import Path

import pytest

from openhands.sdk.context.skills import Skill
from openhands.sdk.context.skills.execute import (
MAX_OUTPUT_SIZE,
_execute_inline_command,
render_content_with_commands,
)


# ---------------------------------------------------------------------------
# Low-level: _execute_inline_command
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("command", "timeout", "check_fn"),
[
pytest.param("echo hello", 10.0, lambda r: r == "hello", id="success"),
pytest.param(
"printf 'line1\nline2\nline3'",
10.0,
lambda r: r == "line1\nline2\nline3",
id="multiline_output",
),
pytest.param("exit 1", 10.0, lambda r: "[Error:" in r, id="failure"),
pytest.param("sleep 5", 0.1, lambda r: "timed out" in r, id="timeout"),
],
)
def test_execute_inline_command(command, timeout, check_fn):
assert check_fn(_execute_inline_command(command, timeout=timeout))


def test_execute_inline_command_respects_working_dir(tmp_path: Path):
result = _execute_inline_command("pwd", working_dir=tmp_path)
assert result == str(tmp_path.resolve())


def test_execute_inline_command_truncates_large_output():
size = MAX_OUTPUT_SIZE + 100
result = _execute_inline_command(f"python3 -c \"print('x' * {size})\"")
assert result.endswith("... [output truncated]")
assert len(result.encode()) <= MAX_OUTPUT_SIZE + 50 # small overhead ok


# ---------------------------------------------------------------------------
# Rendering: basic command substitution
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("content", "expected"),
[
pytest.param("Hello world", "Hello world", id="plain_text_unchanged"),
pytest.param("Branch: !`echo main`", "Branch: main", id="single_command"),
pytest.param(
"A: !`echo one` B: !`echo two`", "A: one B: two", id="multiple_commands"
),
pytest.param("!``", "!``", id="empty_backticks_ignored"),
],
)
def test_render_basic(content, expected):
assert render_content_with_commands(content) == expected


# ---------------------------------------------------------------------------
# Rendering: code blocks are never executed
# ---------------------------------------------------------------------------


def test_render_preserves_inline_code():
"""Regular `code` spans are left alone."""
content = "Use `git status` to check"
assert render_content_with_commands(content) == content


def test_render_preserves_fenced_block():
"""Commands inside ``` fences are not executed."""
content = "Real: !`echo yes`\n```\n!`echo no`\n```"
result = render_content_with_commands(content)
assert "yes" in result
assert "!`echo no`" in result


def test_render_inline_code_next_to_command():
"""`code` immediately followed by a real !`cmd` — both handled correctly."""
content = "Run `git status` then !`echo done`"
result = render_content_with_commands(content)
assert "`git status`" in result
assert "done" in result


# ---------------------------------------------------------------------------
# Rendering: unclosed fenced blocks
#
# When a fenced block is opened but never closed (odd number of ```),
# everything after the opening ``` must be treated as inside the fence —
# no commands should be executed there.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("content", "executed", "preserved"),
[
pytest.param(
"```\nblock1\n```\n!`echo mid`\n```\n!`echo sneaky`\n",
"mid",
"!`echo sneaky`",
id="odd_fences_protects_trailing_command",
),
pytest.param(
"```\n!`echo nope`\n",
None,
"!`echo nope`",
id="single_unclosed_fence",
),
],
)
def test_render_unclosed_fenced_blocks(content, executed, preserved):
result = render_content_with_commands(content)
if executed is not None:
assert executed in result
assert preserved in result


def test_render_properly_closed_fences():
content = "```\nblock1\n```\n!`echo between`\n```\nblock2\n```"
result = render_content_with_commands(content)
assert "between" in result
assert "!`echo between`" not in result


# ---------------------------------------------------------------------------
# Rendering: escape hatch — \!`cmd` produces the literal text !`cmd`
#
# This lets skill authors document the !`...` syntax itself, or show
# examples of commands without them being run at render time.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("content", "expected_literal", "expected_executed"),
[
pytest.param(
r"\!`echo hello`",
"!`echo hello`",
None,
id="escaped_becomes_literal",
),
pytest.param(
r"Docs: \!`echo no` Real: !`echo yes`",
"!`echo no`",
"yes",
id="escaped_and_real_coexist",
),
],
)
def test_render_escaped_commands(content, expected_literal, expected_executed):
result = render_content_with_commands(content)
assert expected_literal in result
if expected_executed is not None:
assert expected_executed in result


def test_render_escape_inside_fenced_block_untouched():
r"""\\!`cmd` inside a fenced block is left completely as-is."""
content = "```\n\\!`echo hi`\n```"
result = render_content_with_commands(content)
assert result == content


# ---------------------------------------------------------------------------
# Integration: Skill.render_content
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("content", "expected"),
[
pytest.param("Plain text", "Plain text", id="no_commands"),
pytest.param("Out: !`echo hi`", "Out: hi", id="with_command"),
],
)
def test_skill_render_content(content, expected):
assert Skill(name="t", content=content).render_content() == expected


def test_skill_load_and_render(tmp_path: Path):
skill_md = tmp_path / "test-skill" / "SKILL.md"
skill_md.parent.mkdir()
skill_md.write_text("---\nname: test-skill\n---\nBranch: !`echo main`\n")
skill = Skill.load(skill_md)
assert skill.render_content() == "Branch: main"
Loading