-
Notifications
You must be signed in to change notification settings - Fork 507
feat(skills): add shell command execution in frontmatter for dynamic context #2582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c77a8e1
feat(skills): Add shell command execution in frontmatter for context …
openhands-agent b3314bf
add command to skill
VascoSch92 191e967
feedbacks
VascoSch92 72a5951
adress comments
VascoSch92 f7485f5
refactor after discussion
VascoSch92 216e827
add docstring as promised
VascoSch92 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| """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: | ||
| """Execute a single inline shell command and return its output.""" | ||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.